Advertisement
tuomasvaltanen

Untitled

Mar 23rd, 2023 (edited)
175
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
text 22.92 KB | None | 0 0
  1. Edistynyt mobiiliohjelmointi, 23.3.2023
  2. ---------------------------------------
  3.  
  4. Asennetaan Directus omalle tietokoneelle, ks. ohjeet Moodlessa Harjoituksen 3 alla. Asennuksen jälkeen jatketaan normaalisti Harjoitus 3 - Directus, kunnes dataa saadaan nettiselaimeen näkyviin. Lopulta tällaisten osoitteiden tulisi toimia Directusin ollessa päällä:
  5.  
  6. Kaikki Feedback-data:
  7. http://localhost:8055/items/feedback?access_token=OMA_ACCESS_TOKEN_TÄHÄN
  8.  
  9. Vain yksi Feedback-data:
  10. http://localhost:8055/items/feedback/1?access_token=OMA_ACCESS_TOKEN_TÄHÄN
  11.  
  12. Uuden Feedbackin lähettäminen Insomniasta,
  13. Osoite: http://localhost:8055/items/feedback?access_token=OMA_ACCESS_TOKEN_TÄHÄN
  14. Method: POST
  15.  
  16. JSON Body:
  17.  
  18. {
  19. "name": "Insomnia Test",
  20. "location": "Interwebs",
  21. "value": "Toimiiko tämä myös täältä käsin??"
  22. }
  23.  
  24. json2kt.comia varten tarvitaan yksittäinen Feedback-data ilman data-wrapperia, eli:
  25.  
  26. {"id":2,"name":"Somebody Else","location":"Someplace","value":"This is some random other feedback value."}
  27.  
  28. Generoituna tulee seuraavanlainen luokka:
  29.  
  30. import com.google.gson.annotations.SerializedName
  31. data class Feedback (
  32.  
  33. @SerializedName("id" ) var id : Int? = null,
  34. @SerializedName("name" ) var name : String? = null,
  35. @SerializedName("location" ) var location : String? = null,
  36. @SerializedName("value" ) var value : String? = null
  37.  
  38. )
  39.  
  40. Lisää Feedback-luokka projektiisi esim. datatypes -> feedback -kansioon/packageen.
  41.  
  42. Lisätään AndroidManifestin application-tagin sisälle:
  43. android:usesCleartextTraffic="true"
  44.  
  45. Tämän jälkeen tyhjennä emulaattori (wipe data) jotta asetus tulee käyttöön.
  46.  
  47. Haetaan ensin raakadata omasta rajapinnasta, FeedbackReadFragment, esim:
  48.  
  49. class FeedbackReadFragment : Fragment() {
  50. // TODO: Rename and change types of parameters
  51. // change this to match your fragment name
  52. private var _binding: FragmentFeedbackReadBinding? = null
  53.  
  54. // This property is only valid between onCreateView and
  55. // onDestroyView.
  56. private val binding get() = _binding!!
  57.  
  58. override fun onCreateView(
  59. inflater: LayoutInflater,
  60. container: ViewGroup?,
  61. savedInstanceState: Bundle?
  62. ): View? {
  63. _binding = FragmentFeedbackReadBinding.inflate(inflater, container, false)
  64. val root: View = binding.root
  65.  
  66.  
  67. // the binding -object allows you to access views in the layout, textviews etc.
  68.  
  69. getFeedbacks()
  70.  
  71. return root
  72. }
  73.  
  74. fun getFeedbacks() {
  75. // tähän tulee lopuksi Volley-koodi, jolla haetaan dataa Directusista
  76. // Directusin data on JSONia, ja muunnamme sen käyttökelpoiseen muotoon
  77. // GSONilla. Tätä varten tarvitaan dataluokka json2kt.comin kautta.
  78.  
  79. // this is the url where we want to get our data
  80. // Note: if using a local server, use http://10.0.2.2 for localhost. this is a virtual address for Android emulators, since
  81. // localhost refers to the Android device instead of your computer
  82. val JSON_URL = "http://10.0.2.2:8055/items/feedback?access_token=OMA_ACCESS_TOKEN_TÄHÄN"
  83.  
  84. // Request a string response from the provided URL.
  85. val stringRequest: StringRequest = object : StringRequest(
  86. Request.Method.GET, JSON_URL,
  87. Response.Listener { response ->
  88.  
  89. Log.d("ADVTECH", response)
  90.  
  91. // response from API, you can use this in TextView, for example
  92. // Check also out the example below
  93.  
  94. // Note: if you send data to API instead, this might not be needed
  95. },
  96. Response.ErrorListener {
  97. // typically this is a connection error
  98. Log.d("ADVTECH", it.toString())
  99. })
  100. {
  101. @Throws(AuthFailureError::class)
  102. override fun getHeaders(): Map<String, String> {
  103. // we have to specify a proper header, otherwise Apigility will block our queries!
  104. // define we are after JSON data!
  105. val headers = HashMap<String, String>()
  106. headers["Accept"] = "application/json"
  107. headers["Content-Type"] = "application/json; charset=utf-8"
  108. return headers
  109. }
  110. }
  111.  
  112. // Add the request to the RequestQueue. This has to be done in both getting and sending new data.
  113. val requestQueue = Volley.newRequestQueue(context)
  114. requestQueue.add(stringRequest)
  115. }
  116.  
  117. override fun onDestroyView() {
  118. super.onDestroyView()
  119. _binding = null
  120. }
  121. }
  122.  
  123. // muokataan getFeedbacks-metodia, muutetaan GSON:lla raakadata listaksi Feedbackeja
  124.  
  125. fun getFeedbacks() {
  126. // tähän tulee lopuksi Volley-koodi, jolla haetaan dataa Directusista
  127. // Directusin data on JSONia, ja muunnamme sen käyttökelpoiseen muotoon
  128. // GSONilla. Tätä varten tarvitaan dataluokka json2kt.comin kautta.
  129.  
  130. // this is the url where we want to get our data
  131. // Note: if using a local server, use http://10.0.2.2 for localhost. this is a virtual address for Android emulators, since
  132. // localhost refers to the Android device instead of your computer
  133. val JSON_URL = "http://10.0.2.2:8055/items/feedback?access_token=OMA_ACCESS_TOKEN_TÄHÄN"
  134. var feedbacks : List<Feedback> = emptyList();
  135. val gson = GsonBuilder().setPrettyPrinting().create()
  136.  
  137. // Request a string response from the provided URL.
  138. val stringRequest: StringRequest = object : StringRequest(
  139. Request.Method.GET, JSON_URL,
  140. Response.Listener { response ->
  141.  
  142. Log.d("ADVTECH", response)
  143.  
  144. // joudumme kaivamaan data-kentän kautta varsinaisen feedback-datan
  145. val jObject = JSONObject(response)
  146. val jArray = jObject.getJSONArray("data")
  147.  
  148. // muutetaan "datan" alta löytyvä JSON listaksi feedbackeja
  149. feedbacks = gson.fromJson(jArray.toString() , Array<Feedback>::class.java).toList()
  150.  
  151. // luodaan adapteri ListViewille, voit korvata tämän RecyclerViewillä myös!
  152. // Huom: tämä ei toimi suoraan, johtuen ListViewin vaatimuksesta
  153. // käyttää vain Stringejä, ks. punainen kommentti alempaa
  154. val adapter = ArrayAdapter(activity as Context, R.layout.simple_list_item_1, feedbacks)
  155.  
  156. // muista myös lisätä ListView fragmentin ulkoasuun (xml)
  157. // ListView löytyy Design-valikosta otsikon "Legacy" alta
  158. binding.listViewFeedbacks.adapter = adapter
  159. binding.listViewFeedbacks.setAdapter(adapter)
  160.  
  161. },
  162. Response.ErrorListener {
  163. // typically this is a connection error
  164. Log.d("ADVTECH", it.toString())
  165. })
  166. {
  167. @Throws(AuthFailureError::class)
  168. override fun getHeaders(): Map<String, String> {
  169. // we have to specify a proper header, otherwise Apigility will block our queries!
  170. // define we are after JSON data!
  171. val headers = HashMap<String, String>()
  172. headers["Accept"] = "application/json"
  173. headers["Content-Type"] = "application/json; charset=utf-8"
  174. return headers
  175. }
  176. }
  177.  
  178. // Add the request to the RequestQueue. This has to be done in both getting and sending new data.
  179. val requestQueue = Volley.newRequestQueue(context)
  180. requestQueue.add(stringRequest)
  181. }
  182.  
  183.  
  184. // tämä toimii nyt, mutta data tulee rumassa muodossa ListViewiin. ListView osaa käsitellä vain String-dataa, mutta oma datamme on
  185. // tyyppiä Feedback. Voimme yliajaa Feedback-luokan toString-metodin, ja vaikuttaa siihen mitä ListView tulostaa listaan:
  186.  
  187. data class Feedback (
  188.  
  189. @SerializedName("id" ) var id : Int? = null,
  190. @SerializedName("name" ) var name : String? = null,
  191. @SerializedName("location" ) var location : String? = null,
  192. @SerializedName("value" ) var value : String? = null
  193.  
  194. )
  195. {
  196. override fun toString(): String {
  197. return name + ": " + value
  198. }
  199. }
  200.  
  201. // UUDEN FEEDBACKIN LÄHETTÄMINEN, FeedbackSendFragment.kt
  202.  
  203. Pohjakoodi:
  204.  
  205. class FeedbackSendFragment : Fragment() {
  206. // TODO: Rename and change types of parameters
  207. // change this to match your fragment name
  208. private var _binding: FragmentFeedbackSendBinding? = null
  209.  
  210. // This property is only valid between onCreateView and
  211. // onDestroyView.
  212. private val binding get() = _binding!!
  213.  
  214. override fun onCreateView(
  215. inflater: LayoutInflater,
  216. container: ViewGroup?,
  217. savedInstanceState: Bundle?
  218. ): View? {
  219. _binding = FragmentFeedbackSendBinding.inflate(inflater, container, false)
  220. val root: View = binding.root
  221.  
  222. binding.buttonSendFeedback.setOnClickListener {
  223. // haetaan edittextien arvot ja lähetetään sendFeedback-funktioon
  224. }
  225.  
  226. // the binding -object allows you to access views in the layout, textviews etc.
  227.  
  228. return root
  229. }
  230.  
  231. fun sendFeedback(name: String, location: String, value: String) {
  232. // tähän koodi, joka käynnistetään napin kautta (Submit)
  233. // lähetetään POST-kysely Volleylla Directusiin
  234. // bodyna uusi data JSON-muodossa ilman id:tä (käytetään GSONia
  235. // muuntamaan data JSONIksi)
  236.  
  237. // haetaan uuden feedbackin tiedot EditTexteistä (3 kpl)
  238. }
  239.  
  240. override fun onDestroyView() {
  241. super.onDestroyView()
  242. _binding = null
  243. }
  244. }
  245.  
  246. // Volley mukaan sekä siirretään EditTextien tiedot apufunktioon. Luodaan uusi JSON-body Feedback-luokan ja GSONin avulla:
  247.  
  248. class FeedbackSendFragment : Fragment() {
  249. // TODO: Rename and change types of parameters
  250. // change this to match your fragment name
  251. private var _binding: FragmentFeedbackSendBinding? = null
  252.  
  253. // This property is only valid between onCreateView and
  254. // onDestroyView.
  255. private val binding get() = _binding!!
  256.  
  257. override fun onCreateView(
  258. inflater: LayoutInflater,
  259. container: ViewGroup?,
  260. savedInstanceState: Bundle?
  261. ): View? {
  262. _binding = FragmentFeedbackSendBinding.inflate(inflater, container, false)
  263. val root: View = binding.root
  264.  
  265. binding.buttonSendFeedback.setOnClickListener {
  266. // haetaan edittextien arvot ja lähetetään sendFeedback-funktioon
  267. var name = binding.editTextFeedbackName.text.toString()
  268. var location = binding.editTextFeedbackLocation.text.toString()
  269. var value = binding.editTextFeedbackValue.text.toString()
  270.  
  271. sendFeedback(name, location, value)
  272. }
  273.  
  274. // the binding -object allows you to access views in the layout, textviews etc.
  275.  
  276. return root
  277. }
  278.  
  279. fun sendFeedback(name: String, location: String, value: String) {
  280. // tähän koodi, joka käynnistetään napin kautta (Submit)
  281. // lähetetään POST-kysely Volleylla Directusiin
  282. // bodyna uusi data JSON-muodossa ilman id:tä (käytetään GSONia
  283. // muuntamaan data JSONIksi)
  284.  
  285. val JSON_URL = "http://10.0.2.2:8055/items/feedback?access_token=uxlR0aabxnfb2gTo4os3on6y5K72NckW"
  286.  
  287. // Request a string response from the provided URL.
  288. val stringRequest: StringRequest = object : StringRequest(
  289. Request.Method.POST, JSON_URL,
  290. Response.Listener { response ->
  291.  
  292. // response from API, you can use this in TextView, for example
  293. // Check also out the example below
  294. // "Handling the JSON in the Volley response" for this part
  295.  
  296. // Note: if you send data to API instead, this might not be needed
  297. },
  298. Response.ErrorListener {
  299. // typically this is a connection error
  300. Log.d("ADVTECH", it.toString())
  301. })
  302. {
  303. @Throws(AuthFailureError::class)
  304. override fun getHeaders(): Map<String, String> {
  305. // we have to specify a proper header, otherwise Apigility will block our queries!
  306. // define we are after JSON data!
  307. val headers = HashMap<String, String>()
  308. headers["Accept"] = "application/json"
  309. headers["Content-Type"] = "application/json; charset=utf-8"
  310. return headers
  311. }
  312.  
  313. // let's build the new data here
  314. @Throws(AuthFailureError::class)
  315. override fun getBody(): ByteArray {
  316. // this function is only needed when sending data
  317. var body = ByteArray(0)
  318. try { // check the example "Converting a Kotlin object to JSON"
  319. // on how to create this newData -variable
  320. var newData = ""
  321.  
  322. // rakennetaan uuden Feedbacking olio EditTextien datan pohjalta
  323. var f : Feedback = Feedback()
  324. f.location = location
  325. f.name = name
  326. f.value = value
  327.  
  328. // muutetaan Feedback-olio -> JSONiksi
  329. var gson = GsonBuilder().create();
  330. newData = gson.toJson(f)
  331.  
  332. Log.d("ADVTECH", newData)
  333.  
  334. // JSON to bytes
  335. body = newData.toByteArray(Charsets.UTF_8)
  336. } catch (e: UnsupportedEncodingException) {
  337. // problems with converting our data into UTF-8 bytes
  338. }
  339. return body
  340. }
  341. }
  342.  
  343. // Add the request to the RequestQueue. This has to be done in both getting and sending new data.
  344. val requestQueue = Volley.newRequestQueue(context)
  345. requestQueue.add(stringRequest)
  346. }
  347.  
  348. override fun onDestroyView() {
  349. super.onDestroyView()
  350. _binding = null
  351. }
  352. }
  353.  
  354. // TEMP ACCESS TOKENIN KÄYTTÄMINEN ANDROIDISSA, TÄSÄS TAPAUKSESSA DIRECTUSIN RAJAPINTA
  355.  
  356. class TempAccessFragment : Fragment() {
  357.  
  358. // HUOM! Tämä esimerkki on tehty hyvin pitkälle tyyliin "siitä mistä aita on matalin".
  359. // Jos haluat optimoida tätä rakennetta, ks. alla olevat kommentit
  360.  
  361. // tällä hetkellä koodin logiikka on tämä:
  362.  
  363. // jos datan hakemisessa tulee Auth-error -> kirjaudutaan kokonaan uudestaan rajapintaan.
  364. // tämäkin ratkaisu toimii (varsinkin pienillä käyttäjämäärillä), mutta tämän johdosta
  365. // Android-sovellus tekee paljon turhia kyselyjä Directusiin, koska kirjautuminen tehdään
  366. // aina virheen sattuessa tai fragmentin latautuessa uudelleen
  367.  
  368. // tämä voi muodostua ongelmaksi, mikäli sovelluksella on tuhansia aktiivisia käyttäjiä.
  369. // tällaisessa tilanteessa jokainen säästetty ja optimoitu kysely Directusin rajapintaan
  370. // säästää Androidin käyttämää suoritusaikaa, akkua sekä myös Directusista tulevaa käyttölaskua.
  371. // Mitä vähemmän Android-sovellus "rassaa" Directusin rajapintaa, sen halvempi ja energiatehokkaampi
  372. // oma Android-sovellus on.
  373.  
  374. // Parannusehdotus 1:
  375.  
  376. // hyödynnetään refresh tokenia access tokenin uusimisessa (kevyempi operaatio kuin uudestaan kirjautuminen)
  377. // refresh token tulee samassa datassa kuin access token myös. Access token on 15min voimassa, ja refresh
  378. // token on 7 päivää voimassa. Refresh tokenin avulla voi pyytää uuden access tokenin, mikäli refresh token
  379. // on vielä voimassa. Jos myös refresh token vanhenee -> kirjaudutaan uudestaan. (tämä logiikka pitää koodata itse)
  380.  
  381. // Parannusehdotus 2:
  382.  
  383. // Directusin kirjautumisdatassa tulee mukana myös tieto siitä, kuinka kauan access token on voimassa kirjautumishetkestä
  384. // alkaen, oletuksena 900000 millisekuntia -> 900 sekuntia -> 15min
  385. // Voidaan koodata Android-sovellus siten, että niin kauan kuin aikaa on jäljellä, Directusiin ei lähetetä
  386. // yhtään kirjautumispyyntöä. Tällä tavalla Android-sovellus ei turhaan ole yhteydessä Directusiin,
  387. // koska äppi pitää itse kirjaa siitä milloin pitää kirjautua uusiksi.
  388.  
  389. // Parannusehdotus 3:
  390.  
  391. // kaikki kirjautumiseen liittyvä Volley-logiikka on hyvä keskittää yhteen paikkaan, joko kustomoituun
  392. // Application -luokkaan, tai tehdä (suositellumpi tapa) Volley-kutsuille om a Singleton-luokka.
  393. // ks. Google ja Volleyn dokumentaatio esimerkistä miten tämä tehdään.
  394.  
  395. // change this to match your fragment name
  396. private var _binding: FragmentTempAccessBinding? = null
  397.  
  398. // This property is only valid between onCreateView and
  399. // onDestroyView.
  400. private val binding get() = _binding!!
  401.  
  402. // VARIABLES USED BY THE SESSION MANAGEMENT
  403. val LOGIN_URL = "http://10.0.2.2:8055/auth/login"
  404.  
  405. // these should be placed in the local properties file and used by BuildConfig
  406. // JSON_URL should be WITHOUT a trailing slash (/)!
  407. val JSON_URL = "http://10.0.2.2:8055"
  408.  
  409. // if using username + password in the service (e.g. Directus), use these
  410. val username = "DIRECTUSKÄYTTÄJÄN EMAIL"
  411. val password = "DIRECTUSKÄYTTÄJÄN SALASANA"
  412.  
  413. // request queues for requests
  414. var requestQueue: RequestQueue? = null
  415. var refreshRequestQueue: RequestQueue? = null
  416.  
  417. // state booleans to determine our session state
  418. var loggedIn: Boolean = false
  419. var needsRefresh: Boolean = false
  420.  
  421. // stored tokens. refresh is used when our session token has expired
  422. // access token in this case is the same as session token
  423. var refreshToken = ""
  424. var accessToken = ""
  425.  
  426. // fragment entry point
  427. override fun onViewCreated(view: View, savedInstanceState: Bundle?)
  428. {
  429. super.onViewCreated(view, savedInstanceState);
  430.  
  431. requestQueue = Volley.newRequestQueue(context)
  432. refreshRequestQueue = Volley.newRequestQueue(context)
  433.  
  434. // start with login
  435. loginAction()
  436. }
  437.  
  438. // button methods
  439. fun loginAction()
  440. {
  441. Log.d("ADVTECH", "login")
  442. Log.d("ADVTECH", JSON_URL + " login")
  443. requestQueue?.add(loginRequest)
  444. }
  445.  
  446. fun refreshLogin() {
  447. if (needsRefresh) {
  448. loggedIn = false
  449. // use this if using refresh logic
  450. //refreshRequestQueue?.add(loginRefreshRequest)
  451.  
  452. // if using refresh logic, comment this line out
  453. loginAction()
  454. }
  455. }
  456.  
  457. fun dataAction() {
  458. if (loggedIn) {
  459. requestQueue?.add(dataRequest)
  460. }
  461. }
  462.  
  463. // REQUEST OBJECT 1: LOGIN
  464. var loginRequest: StringRequest = object : StringRequest(
  465. Request.Method.POST, LOGIN_URL,
  466. Response.Listener { response ->
  467.  
  468. var responseJSON: JSONObject = JSONObject(response)
  469.  
  470. // save the refresh token too if using refresh logic
  471. // refreshToken = responseJSON.get("refresh_token").toString()
  472.  
  473. // this part depends completely on the service that is used
  474. // Directus uses the data -> access_token -approach
  475. // IBM Cloud handles the version in comments
  476. // accessToken = responseJSON.get("access_token").toString()
  477. accessToken = responseJSON.getJSONObject("data").get("access_token").toString()
  478.  
  479. loggedIn = true
  480.  
  481. // after login's done, get data from API
  482. dataAction()
  483.  
  484. Log.d("ADVTECH", response)
  485. },
  486. Response.ErrorListener {
  487. // typically this is a connection error
  488. Log.d("ADVTECH", it.toString())
  489. }) {
  490. @Throws(AuthFailureError::class)
  491. override fun getHeaders(): Map<String, String> {
  492. // we have to provide the basic header info
  493. // + Bearer info => accessToken
  494. val headers = HashMap<String, String>()
  495. headers["Accept"] = "application/json"
  496.  
  497. // for Directus, the typical approach works:
  498. headers["Content-Type"] = "application/json; charset=utf-8"
  499.  
  500. return headers
  501. }
  502.  
  503. // use this to build the needed JSON-object
  504. // this approach is used by Directus, IBM Cloud uses the commented version instead
  505. @Throws(AuthFailureError::class)
  506. override fun getBody(): ByteArray {
  507. // this function is only needed when sending data
  508. var body = ByteArray(0)
  509. try {
  510. // on how to create this newData -variable
  511. var newData = ""
  512.  
  513. // a very quick 'n dirty approach to creating the needed JSON body for login
  514. newData = "{\"email\":\"${username}\", \"password\": \"${password}\"}"
  515.  
  516. // JSON to bytes
  517. body = newData.toByteArray(Charsets.UTF_8)
  518. } catch (e: UnsupportedEncodingException) {
  519. // problems with converting our data into UTF-8 bytes
  520. }
  521. return body
  522. }
  523.  
  524. }
  525.  
  526. // REQUEST OBJECT 3 : ACTUAL DATA -> FEEDBACK
  527. var dataRequest: StringRequest = object : StringRequest(
  528. Request.Method.GET, JSON_URL+"/items/feedback",
  529. Response.Listener { response ->
  530. Log.d("ADVTECH", response)
  531. // jos halutaan asettaa raakadata ulkoasuun TextViewiin
  532. //binding.textViewRawData.text = response
  533. },
  534. Response.ErrorListener {
  535. // typically this is a connection error
  536. Log.d("ADVTECH", it.toString())
  537.  
  538. if (it is AuthFailureError) {
  539. Log.d("ADVTECH", "EXPIRED start")
  540.  
  541. needsRefresh = true
  542. loggedIn = false
  543. refreshLogin()
  544.  
  545. Log.d("ADVTECH", "EXPIRED end")
  546. }
  547. }) {
  548. @Throws(AuthFailureError::class)
  549. override fun getHeaders(): Map<String, String> {
  550. // we have to provide the basic header info
  551. // + Bearer info => accessToken
  552. val headers = HashMap<String, String>()
  553. // headers["Accept"] = "application/json"
  554. // headers["Content-Type"] = "application/json; charset=utf-8"
  555. headers["Authorization"] = "Bearer " + accessToken
  556. return headers
  557. }
  558.  
  559. }
  560.  
  561. override fun onCreateView(
  562. inflater: LayoutInflater,
  563. container: ViewGroup?,
  564. savedInstanceState: Bundle?
  565. ): View? {
  566. _binding = FragmentTempAccessBinding.inflate(inflater, container, false)
  567. val root: View = binding.root
  568.  
  569. // the binding -object allows you to access views in the layout, textviews etc.
  570.  
  571. // jos ulkoasussa on nappi jolla voi hakea dataa uudestaan ja testata
  572. // vieläkö access token on voimassa:
  573. /*
  574. binding.buttonGetTempData.setOnClickListener {
  575. dataAction()
  576. }
  577. */
  578.  
  579. return root
  580. }
  581.  
  582. override fun onDestroyView() {
  583. super.onDestroyView()
  584. _binding = null
  585. }
  586. }
Advertisement
Add Comment
Please, Sign In to add comment
Advertisement