2025-02-16

【PHP】以 Telegram bot 取代 LINE notify

* 先準備 2 支手機(在本筆記,2 支手機都是 Android),telegram 沒法建立一人群組(應該說我沒試成功)

* 2 支手機都安裝 telegram,並建立一個群組

* 在尋找聯絡人輸入 @botfather,找到後,將 botfather 加入聯絡人

* 進入 botfather聊天室,會看到 botfather 的招呼語【What can this bot do?

* 輸入【/start】,botfather 會列出一堆跟 bot 有關的指令

* 輸入【/newbot

botfather 會請您為您的 bot 取名字,因為後續的互動會是 json 格式,所以建議這次先以英文命名,較容易在 json 字串中辨識、找到。

* 接下來,botfather 請您設定您的 bot 的 id,bot 的 id 必須以 【bot】結尾,ex:TetrisBottetris_bot,這個 id 必須是在 telegram 唯一的,所以若您設定的名字已經有別人先用了,botfather 會請您改名字。

* 建立您的 bot 後,可輸入【/help】進一步了解

* 輸入【/mybots】,會得到您的 bottoken,格式是 【一串數字:大小寫英數字混合字串】很重要!請好好保存


* 將 bot 加入群組

→ 由群組中的任一人發送一個訊息到群組

→ 點擊標題欄,可以看到這個群組的成員,並且下方有個標題為【Links】的區塊,點擊第一個連結(最後面以 【/getUpdates】結尾),就會看到一段 json 字串,將這段 json 字串複製出來,並尋找其中

  1.  
  2. ...
  3. ...
  4.  
  5. "chat":{"id":-0123456789,
  6. "title":"mytest",
  7. "type":"group",
  8. "all_members_are_administrators":false
  9. }
  10.  
  11. ...
  12. ...
  13.  
這個就是群組的 id,通常是負數typegroup
如果找不到群組 id,請多試幾次(群組某人發送訊息,然後看 json 字串),甚至將 bot 踢出群組再重加入。

寫個測試的 PHP 程式

  1.  
  2. <?php
  3. date_default_timezone_set('Asia/Taipei');
  4.  
  5. $sToken = "0123456789:AAG9Sxwv3zc9YblablablaiBBJ3ZOUO4lFY"; // bot 的 token
  6. $sGroupId = "-9876543210"; // 群組 id
  7. // 訊息內容
  8. $sMsg = "Hello, this is oldgrayduck's telegram Bot test, ".date('Y-m-d H:i:s');
  9. $url = "https://api.telegram.org/bot$sToken/sendMessage";
  10. $data = [
  11. 'chat_id' => $sGroupId,
  12. 'text' => $sMsg
  13. ];
  14. $ch = curl_init();
  15. curl_setopt($ch, CURLOPT_URL, $url);
  16. curl_setopt($ch, CURLOPT_POST, 1);
  17. curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));
  18. curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
  19. $result = curl_exec($ch);
  20. curl_close($ch);
  21.  
  22. // 顯示回應
  23. echo $result;
  24. ?>
  25.  


群組就會收到訊息了。

2024-12-30

【LINE Messaging API】主動傳送訊息(推播) 給多位使用者

類似主動傳訊息給 user,不過在傳訊類型定義為 multicast。要傳訊的目標對象是多位 user,所以 UserId 要存在陣列中。


  1.  
  2. <php?
  3. date_default_timezone_set('Asia/Taipei');
  4. $sRootDir = $_SERVER['DOCUMENT_ROOT'];
  5. $channelAccessToken = '您的 channel Access Token';
  6. $bodyMsg = file_get_contents('php://input');
  7.  
  8. // LINE 不會幫我們記錄, 所以要自己寫 log
  9. // 這段不是必要, 只是方便自己除錯
  10. $sLogfile = 'log_'.date('Ymd').'.log'; // 指定 log 檔名, 一天一個檔, 檔名格式為: log_yyyymmdd.log
  11. $fp = fopen($sLogfile, "a+");
  12. fwrite($fp , print_r(date('Y-m-d H:i:s').', Recive: '.$bodyMsg."\n", true));
  13. fclose($fp);
  14.  
  15. ...
  16. ...
  17. // 取得 user id, 管道有很多, 此處不贅述
  18. $arrUserId = ['...','...', ...]; ← 要推播的目標 UserId 存入陣列
  19.  
  20. $payload = [ 'to' => $arrUserId,
  21. 'messages' => [
  22. ['type' => 'text',
  23. 'text' => '恭喜! 您中了特獎!',
  24. ]
  25. ]
  26. ];
  27. SendMsg($payload);
  28.  
  29.  
  30. // 傳送訊息給指定使用者
  31. function SendMsg($payload) {
  32. $ch = curl_init();
  33. // curl_setopt($ch, CURLOPT_URL, 'https://api.line.me/v2/bot/message/push');
  34. curl_setopt($ch, CURLOPT_URL, 'https://api.line.me/v2/bot/message/multicast'); ← 注意這裡要改成 multicast
  35. curl_setopt($ch, CURLOPT_POST, true);
  36. curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
  37. curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
  38. curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
  39. curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
  40. curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
  41. curl_setopt($ch, CURLOPT_HTTPHEADER, [ 'Content-Type: application/json',
  42. 'Authorization: Bearer ' . $GLOBALS['channelAccessToken']
  43. ]);
  44. $result = curl_exec($ch);
  45. curl_close($ch);
  46. // 寫 log
  47. $sLogfile = 'log_'.date('Ymd').'.log';
  48. $fp = fopen($sLogfile, "a+");
  49. fwrite($fp , print_r(date('Y-m-d H:i:s').', send message result: '.$result."\n", true));
  50. fclose($fp);
  51. }
  52. ?>
  53.  



相關筆記 ----


2024-08-28

【Kotlin】隨機取出指定筆數的記錄

題目:

有一含 1000 筆記錄的資料表 table1,要從中取出任意 100

在詢問 Google 的 Gemini、OpenAI 的 ChatGPT、Perplexity 的 Perplexity、微軟的 Copilot,多認識了一些類別及技巧,故做此筆記。


做法一:

  1.  
  2. val mScope = CoroutineScope(Job() + Dispatchers.IO)
  3. mScope.launch {
  4. val mDbHelper = SQLite(baseContext)
  5. var db = mDbHelper.readableDatabase
  6.  
  7. // 查詢本機資料庫有幾筆記錄
  8. var sSql = "SELECT * FROM table1 "
  9. val rs = db.rawQuery(sSql, null)
  10. val mRecnt = rs.count // 本機資料庫記錄筆數
  11. var mSentence = 100 // 迴圈數, 要取出 100 筆記錄
  12.  
  13. val mList = mutableListOf<Int>()
  14. var xx = 1
  15. // 這種做法, 有可能亂數取得的 mPos 已存在 mList 中
  16. while(xx<=mSentence) {
  17. val mPos = (0 until mRecnt).random() // 每筆記錄在 table1 的位置
  18. if(mPos !in mList) {
  19. mList.add(mPos)
  20. xx++
  21. }
  22. }
  23. mList.sorted() // 將 mList 內的元素由小到大排序
  24. // 如此, 在實際自 table1 取出記錄就會是自資料表首一路向資料表尾依序取出記錄
  25. ...
  26. ...
  27. }
  28.  



做法二:

  1.  
  2. val mScope = CoroutineScope(Job() + Dispatchers.IO)
  3. mScope.launch {
  4. ...
  5. ...
  6.  
  7. val mRaw = mutableSetOf<Int>() // mutableSet 的特性是存入時就會檢查每個元素的值都是唯一
  8. val mRandom = Random(System.currentTimeMillis())
  9. while(mRaw.size<mSentence) {
  10. mRaw.add(mRandom.nextInt(mRecnt))
  11. }
  12. // 但 mutableSet 不具排序功能,
  13. // 故將它轉型態成 list 並排序後存入 mList
  14. val mList = mRaw.toList().sorted()
  15. ...
  16. ...
  17. }
  18.  



做法三:

  1.  
  2. val mScope = CoroutineScope(Job() + Dispatchers.IO)
  3. mScope.launch {
  4. ...
  5. ...
  6.  
  7. // 這個做法最快, 一行指令搞定
  8. // 以 shuffled() 洗牌後, 再以 take() 取前面幾個元素, 再將元素排序
  9. // (0 until mRecnt) 是 IntRange
  10. val mList = (0 until mRecnt).shuffled().take(mSentence).sorted()
  11. // val mList = (0 until mRecnt).shuffled(Random(System.currentTimeMillis())).take(mSentence).sorted() // 可以在 shuffled() 內加上時間當做亂數種子, 增強其隨機性
  12. ...
  13. ...
  14. }
  15.  
  1.  



2024-07-13

【網頁程式】土砲打造 -- 依數個地點距離由近至遠規劃行程導航

參考資料 ----


當我們要前往數個地點,想依距離由近至遠排序,規劃開車行程,可以請 Google Cloud 服務,不過這是要收費的。

行程含目的地的停靠點在 10 個點以下,是一種費率,11(含)個點以上則會收較高的費用。

本筆記是自行計算及排序停靠點(注意,還是要收費的)。


您可以服務全開,不過我只使用其中 2 項 -- 取得地理位置(經緯度)導航







建立 GMap 類別
  1.  
  2. <?php
  3. date_default_timezone_set('Asia/Taipei');
  4. // 用來查詢地址的經、緯度
  5. class GMap {
  6. function getPageData($url) {
  7. $ch = curl_init();
  8. curl_setopt($ch, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_0);
  9. curl_setopt($ch, CURLOPT_HTTPHEADER, array('Expect: '));
  10. curl_setopt($ch, CURLOPT_IPRESOLVE, CURL_IPRESOLVE_V4);
  11. curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, FALSE);
  12. curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
  13. curl_setopt($ch, CURLOPT_URL, $url);
  14. curl_setopt($ch, CURLOPT_NOBODY, false);
  15. curl_setopt($ch, CURLOPT_FILETIME, true);
  16. curl_setopt($ch, CURLOPT_REFERER, $url);
  17. curl_setopt($ch, CURLOPT_BINARYTRANSFER, true);
  18. curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE);
  19. curl_setopt($ch, CURLOPT_FOLLOWLOCATION, 4);
  20. curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
  21. curl_setopt($ch, CURLOPT_TIMEOUT, 10);
  22. curl_setopt($ch, CURLOPT_USERAGENT, "Mozilla/4.0 (compatible; MSIE 5.01; Windows NT 5.0)");
  23. $result['data'] = curl_exec($ch);
  24. curl_close($ch);
  25. return $result;
  26. }
  27. // 初始化 apikey 金鑰
  28. function __construct() {
  29. $this->apikey = '向 Google map 申請 apikey';
  30. }
  31.  
  32.  
  33. // 從 google map 取得地址經緯度
  34. public function getLngLat($addr='',$sDebug='') {
  35. $apikey = $this->apikey;
  36. $url = "https://maps.googleapis.com/maps/api/geocode/json?address=$addr&key=$apikey";
  37. $geocode = $this->getPageData($url);
  38. if($sDebug=='debug')
  39. var_dump($geocode);
  40.  
  41. if(isset($geocode['data'])) {
  42. if(!$geocode["data"])
  43. // 當 Google map 解析不了時,回應 經/緯度值都是 -1 的虛擬經緯度
  44. $geocode = '{"results":[{"geometry":{"location":{"lat":-1,"lng":-1}}}]}';
  45. else
  46. $geocode = $geocode['data'];
  47. } else
  48. // 當 Google map 解析不了時,回應 經/緯度值都是 -1 的虛擬經緯度
  49. $geocode = '{"results":[{"geometry":{"location":{"lat":-1,"lng":-1}}}]}';
  50.  
  51. $output = json_decode($geocode);
  52. $latitude = $output->results[0]->geometry->location->lat;
  53. $longitude = $output->results[0]->geometry->location->lng;
  54. return array('lat'=>$latitude,'lng'=>$longitude);
  55. }
  56. }
  57. ?>
  58.  


getroute.php,用來依次計算起點至每個目的地的距離,取最近的地址做為下個停靠點,並再以這地址做為下次計算的起點
  1.  
  2. <?php
  3. // 切換至 https
  4. if (empty($_SERVER['HTTPS']) || $_SERVER['HTTPS'] === "off")
  5. {
  6. $location = 'https://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'];
  7. header('HTTP/1.1 301 Moved Permanently');
  8. header('Location: ' . $location);
  9. exit;
  10. }
  11.  
  12. include('gmap.php');
  13.  
  14. class MyDest {
  15. public $addr;
  16. public $len = 0;
  17. public $lng = -1;
  18. public $lat = -1;
  19. function __construct($aa) {
  20. $this->addr = $aa;
  21. }
  22. }
  23.  
  24.  
  25. if(isset($_GET['lat'])) {
  26. // 接收到起點 經/緯 度
  27. // $sLat = 22.612058525444247; // 測試用
  28. // $sLng = 120.3142669919977; // 測試用
  29. $sLat = $_GET['lat'];
  30. $sLng = $_GET['lng'];
  31.  
  32. $aDest = array(); // 存放上傳的目的地
  33. $aSort = array(); // 排序後的目的地
  34.  
  35. $gmap = new GMap();
  36. if(isset($_GET['dest1'])) {
  37. $obj = new MyDest($_GET['dest1']);
  38. $fLngLat = $gmap->getLngLat($obj->addr); // 上車地點經緯度
  39. $obj->lat = $fLngLat["lat"]; // 緯度
  40. $obj->lng = $fLngLat["lng"]; // 經度
  41. array_push($aDest, $obj);
  42. }
  43. if(isset($_GET['dest2'])) {
  44. $obj = new MyDest($_GET['dest2']);
  45. $fLngLat = $gmap->getLngLat($obj->addr); // 上車地點經緯度
  46. $obj->lat = $fLngLat["lat"]; // 緯度
  47. $obj->lng = $fLngLat["lng"]; // 經度
  48. array_push($aDest, $obj);
  49. }
  50. if(isset($_GET['dest3'])) {
  51. $obj = new MyDest($_GET['dest3']);
  52. $fLngLat = $gmap->getLngLat($obj->addr); // 上車地點經緯度
  53. $obj->lat = $fLngLat["lat"]; // 緯度
  54. $obj->lng = $fLngLat["lng"]; // 經度
  55. array_push($aDest, $obj);
  56. }
  57. if(isset($_GET['dest4'])) {
  58. $obj = new MyDest($_GET['dest4']);
  59. $fLngLat = $gmap->getLngLat($obj->addr); // 上車地點經緯度
  60. $obj->lat = $fLngLat["lat"]; // 緯度
  61. $obj->lng = $fLngLat["lng"]; // 經度
  62. array_push($aDest, $obj);
  63. }
  64. if(isset($_GET['dest5'])) {
  65. $obj = new MyDest($_GET['dest5']);
  66. $fLngLat = $gmap->getLngLat($obj->addr); // 上車地點經緯度
  67. $obj->lat = $fLngLat["lat"]; // 緯度
  68. $obj->lng = $fLngLat["lng"]; // 經度
  69. array_push($aDest, $obj);
  70. }
  71.  
  72. while(count($aDest)>0) {
  73. foreach($aDest as &$val) { // 注意:$val 前面有 &
  74. // 計算【各目的地】距離【起點】的長度
  75. $url = "https://maps.googleapis.com/maps/api/directions/json?origin=$sLat,$sLng&destination=".urlencode($val->addr)."&key=$apiKey";
  76. // 目的地 destination 參數也可以是經緯度
  77. $url = "https://maps.googleapis.com/maps/api/directions/json?origin=$sLat,$sLng&destination=".urlencode($val->lat).",".urlencode($val->lng)."&key=$apiKey";
  78. // 若希望路線避開上高速公路, 則可加 avoid=highways 參數
  79. $url = "https://maps.googleapis.com/maps/api/directions/json?origin=$sLat,$sLng&destination=".urlencode($val->addr)."&avoid=highways&key=$apiKey";
  80. // 此外, 還可避開 ----
  81. // tolls:避開收費路段
  82. // highways:避開高速公路
  83. // ferries:避開渡輪
  84. // indoor:避開室內路線(主要用於步行路線)
  85. // 初始化 cURL
  86. $ch = curl_init();
  87. curl_setopt($ch, CURLOPT_URL, $url);
  88. curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
  89. $response = curl_exec($ch);
  90. curl_close($ch);
  91. // 解析 json 響應
  92. $data = json_decode($response, true);
  93. if ($data['status']=='OK') {
  94. $val->len = $data['routes'][0]['legs'][0]['distance']['value'];
  95. } else {
  96. $val->len = -1;
  97. }
  98. unset($val);
  99. }
  100.  
  101. // 使用 usort 函數排序陣列,根據 $a 屬性值從小到大排序
  102. usort($aDest, function($a, $b) {
  103. return $a->len <=> $b->len;
  104. });
  105. // 排序後的第一個物件就是 距離最短的地址
  106. $minObj = $aDest[0];
  107. // print_r($aDest);
  108. array_push($aSort, ['addr' => ($minObj->addr)]);
  109. // 這個地址做為起點
  110. $sLat = $minObj->lat;
  111. $sLng = $minObj->lng;
  112. array_splice($aDest, 0, 1);
  113. }
  114. echo json_encode($aSort); // 回傳依距離排序後的停靠點及目的地
  115. }
  116.  
  117. ?>
  118.  


navi.php,將排好的順序丟給 GoogleMap 導航。
  1.  
  2. <?php
  3. // 切換至 https
  4. if(empty($_SERVER['HTTPS']) || $_SERVER['HTTPS'] === "off")
  5. {
  6. $location = 'https://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'];
  7. header('HTTP/1.1 301 Moved Permanently');
  8. header('Location: ' . $location);
  9. exit;
  10. }
  11. ?>
  12. <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
  13. <html style="height:100%;">
  14. <head>
  15. <meta charset="utf-8">
  16. <script src="https://ajax.googleapis.com/ajax/libs/jquery/2.0.0/jquery.min.js"></script>
  17. </head>
  18. <body>
  19. <br />
  20. <button onclick="startNavigation()" style="font-size:40pt;">巡航</button><br />
  21. <span style="font-size:40pt;">提醒:在 Android Chrome 開啟本網頁</span><br />
  22. <br />
  23. <br />
  24. <script>
  25. function startNavigation() {
  26. var aRoute = [
  27. "高雄市苓雅區四維三路2號",
  28. "高雄市前鎮區中華五路789號",
  29. "高雄市鼓山區濱海二路1號",
  30. "高雄市左營區博愛二路777號",
  31. "高雄市左營區高鐵路107號"
  32. ];
  33.  
  34. if (navigator.geolocation) {
  35. navigator.geolocation.getCurrentPosition(function(position) {
  36. var latitude = position.coords.latitude; // 緯度
  37. var longitude = position.coords.longitude; // 經度
  38. var str = "https://您自己的網址/getroute.php?lat="+latitude+"&lng="+longitude+"&dest1="+aRoute[0]+"&dest2="+aRoute[1]+"&dest3="+aRoute[2]+"&dest4="+aRoute[3]+"&dest5="+aRoute[4];
  39. // alert('str = '+str);
  40. // return;
  41.  
  42. var destinations = new Array();
  43. $.getJSON(str, function(res) {
  44. // alert("res = "+res);
  45. // 這段是從 client 端向 server 請求查詢
  46. // server 回傳 json 型態的陣列
  47. // 陣列內的元素是 addr
  48. res.forEach(function(element) {
  49. destinations.push(element.addr);
  50. });
  51. var waypoints = destinations.map(function(destination) {
  52. // Google map 規定停靠點以【|】字元分隔
  53. // 將 5 個地點以【|】字元分隔
  54. return encodeURIComponent(destination);
  55. }).join("|");
  56. destination = waypoints.split('|').pop(); // 取得最後【|】右邊的字串做為目的地
  57. waypoints = waypoints.slice(0, waypoints.lastIndexOf('|')); // 取得最後【|】的左邊字串做為停靠點
  58.  
  59. // 構建導航 URL
  60. var mapsURL = "https://www.google.com/maps/dir/?api=1&origin=" + latitude + "," + longitude + "&destination=" + destination + "&waypoints=" + waypoints + "&travelmode=driving";
  61. // alert(mapsURL);
  62. // return;
  63.  
  64. // 跳轉到 Google Maps
  65. window.location.href = mapsURL;
  66. });
  67. // return;
  68.  
  69. }, function(error) {
  70. alert("無法取得地理位置: " + error.message);
  71. }
  72. );
  73. } else {
  74. alert("瀏覽器不支援地理定位功能。");
  75. }
  76. }
  77. </script>
  78. <br />
  79. <br />
  80. </body>
  81. </html>
  82.  
  83.  


排序後,程式呼叫 Android 手機上的 Google Map app 進行導航。

第一次執行這個網頁程式時,會詢問是否同意瀏覽器存取你的地理位置,選擇同意且記住您的選擇,之後執行就不會再跳出詢問視窗了。

測試 Google Chrome、Firefox、Microsoft Edge 這幾個主流瀏覽器
Chrome 效果最佳,直接啟動 Google Map,畢竟都是自家的產品;
Edge 雖可呼叫 Google Map,但詢問視窗一直開著,不會自動關閉
Firefox 最差,啟動了 Google Map,但要導航的地址全不見了!!



另一個有趣的發現:
如果只單純導航前往一個地點,Google Map 並不管目的地是否與您的行車方向順向,只要抵達目的地附近,Google Map 就認為任務完成了。ex:出發點是高雄市中山大學,目的地是高雄女中(雄女),雄女的對面是國軍英雄館,Google 導航只要帶您到國軍英雄館,導航就結束了。
但當設了多個停靠點時,Google Map 就會使命必達,規劃的路線即使繞一圈也會帶您順向抵達雄女。

然後...老人家又好奇了...這個網頁程式在 iPhone 上打開會是什麼結果?
答案是:在 Safari 直接 Deny!! 不給開

2025


相關筆記 ----






2024-07-10

【MySQL】國定假日應用

參考資料 ----


自 政府資料開放平台 下載下來的檔案格式為 csv,內容依序為【西元日期】(格式為 yyyymmdd)、【星期】(國字)、【是否放假】(0=上班, 2=放假)、【備註】 4 個欄位,以逗點【,】分隔。自 01/01 ~ 12/31 一天一列。

比照這個 csv,在資料庫(我採用 MySQL) 中建一個 table,不過我的 table 有多一個【年度】欄位,並將【是否放假】欄位改名為【休假

我需要的只有放假日,也就是【休假】欄位值 = 2,因此先透過 phpMyAdmin 存入資料庫,再將【平日】刪除。

  1.  
  2. -- csv 最前面補加 西元年度欄位
  3.  
  4. -- csv 上傳存入 holiday_tmp
  5. -- holiday holiday_tmp 差別只在 holiday autoincrement primary key
  6.  
  7. -- 刪除平日, 只留假日
  8. DELETE FROM holiday_tmp
  9. WHERE 休假=0
  10.  
  11. -- 存入 holiday
  12. INSERT INTO holiday
  13. (年度,日期,星期,休假,備註)
  14. SELECT * FROM holiday_tmp
  15.  
  16. -- 清空 holiday_tmp
  17. DELETE FROM holiday_tmp
  18.  


2024-06-13

【SQLite】以預設值新增一筆記錄

當 CREATE TABLE 的欄位定義有預設值,或無預設值(NULL) 且未限制 NOT NULL 時,可使用下面指令。

  1.  
  2. INSERT INTO 資料表名 DEFAULT VALUES;
  3.  

2024-06-05

【Android】RelativeLayout 對齊螢幕中垂線

假設在 RelativeLayout 內放置 1 個 TextView

1. TextView 的中心對齊中垂線:
  1.  
  2. <?xml version="1.0" encoding="utf-8"?>
  3. <RelativeLayout
  4. xmlns:android="http://schemas.android.com/apk/res/android"
  5. xmlns:tools="http://schemas.android.com/tools"
  6. android:layout_width="match_parent"
  7. android:layout_height="match_parent"
  8. xmlns:ads="http://schemas.android.com/apk/res-auto"
  9. tools:context=".MainActivity">
  10.  
  11. ...
  12. ...
  13.  
  14. <TextView
  15. android:id="@+id/TextView1"
  16. android:layout_width="wrap_content"
  17. android:layout_height="wrap_content"
  18. android:layout_centerInParent="true"
  19. android:layout_centerVertical="true"
  20. android:text="中心對齊" />
  21. </RelativeLayout>
  22.  


2. TextView1 左邊對齊中垂線
  1.  
  2. <?xml version="1.0" encoding="utf-8"?>
  3. <RelativeLayout
  4. xmlns:android="http://schemas.android.com/apk/res/android"
  5. xmlns:tools="http://schemas.android.com/tools"
  6. android:layout_width="match_parent"
  7. android:layout_height="match_parent"
  8. xmlns:ads="http://schemas.android.com/apk/res-auto"
  9. tools:context=".MainActivity">
  10.  
  11. ...
  12. ...
  13. <!-- 看不見的中垂線 -->
  14. <View
  15. android:id="@+id/vCenterVertical"
  16. android:layout_width="0dp"
  17. android:layout_height="match_parent"
  18. android:layout_centerInParent="true" />
  19.  
  20. <TextView
  21. android:id="@+id/TextView1"
  22. android:layout_width="wrap_content"
  23. android:layout_height="wrap_content"
  24. android:layout_toEndOf="@id/vCenterVertical"
  25. android:text="左邊對齊中垂線" />
  26. </RelativeLayout>
  27.  


3. TextView1 右邊對齊中垂線
  1.  
  2. <?xml version="1.0" encoding="utf-8"?>
  3. <RelativeLayout
  4. xmlns:android="http://schemas.android.com/apk/res/android"
  5. xmlns:tools="http://schemas.android.com/tools"
  6. android:layout_width="match_parent"
  7. android:layout_height="match_parent"
  8. xmlns:ads="http://schemas.android.com/apk/res-auto"
  9. tools:context=".MainActivity">
  10.  
  11. ...
  12. ...
  13. <!-- 看不見的中垂線 -->
  14. <View
  15. android:id="@+id/vCenterVertical"
  16. android:layout_width="0dp"
  17. android:layout_height="match_parent"
  18. android:layout_centerInParent="true" />
  19.  
  20. <TextView
  21. android:id="@+id/TextView1"
  22. android:layout_width="wrap_content"
  23. android:layout_height="wrap_content"
  24. android:layout_toStartOf="@id/vCenterVertical"
  25. android:text="右邊對齊中垂線" />
  26. </RelativeLayout>
  27.  




2024-04-07

【Kotlin】以資源檔自定向量圖 ImageButton(View) 外觀

參考資料 ----


覺得以程式控制 ImageButton 的各種狀態(按下按鈕、disabled...) 時的外觀很麻煩,可以透過設定資源檔的方式達到目的。
由於 ImageButton 繼承自 View,原則上,本筆記的做法可以套用 View(含) 的其他子類別。
 

app 層級 build.gradle(若您的程式沒有用到視圖綁定,這部份可忽略)
  1.  
  2. ...
  3. ...
  4. android {
  5. ...
  6. ...
  7. buildFeatures {
  8. viewBinding true
  9. }
  10. ...
  11. ...
  12.  


色表 /values/color.xml
  1.  
  2. <?xml version="1.0" encoding="utf-8"?>
  3. <resources>
  4.  
  5. ...
  6. ...
  7. <color name="blue">#0000FF</color>
  8. <color name="red">#ffff0000</color>
  9. <color name="brown">#8B4513</color>
  10. <color name="gray">#ff888888</color>
  11. <color name="green">#ff00ff00</color>
  12. <color name="yellow">#FFFF00</color>
  13. ...
  14. ...
  15. </resources>
  16.  


分別建立 前景(向量圖)背景 在各種狀態時的顏色的資源檔,並置於 /res/color/
要注意:Android 套用資源檔的原則是以最低限度符合條件就採用,所以預設值要放在最後順位

前景 /res/color/image_state.xml,預設一般狀態是 藍色,按下按鈕時是 棕色,disabled 時是 灰色
  1.  
  2. <?xml version="1.0" encoding="utf-8"?>
  3. <selector xmlns:android="http://schemas.android.com/apk/res/android">
  4. <item android:state_pressed="true"
  5. android:color="@color/brown" />
  6. <item android:state_enabled="false"
  7. android:color="@color/gray" />
  8. <item android:color="@color/blue" /> ← 預設值要放在最後順位
  9. </selector>
  10.  


背景 /res/color/back_state.xml,無預設值(所以跟 app 的 Theme 設定相同),按下按鈕時是 黃色,disabled 時是 綠色
  1.  
  2. <?xml version="1.0" encoding="utf-8"?>
  3. <selector xmlns:android="http://schemas.android.com/apk/res/android">
  4. <item android:state_pressed="true"
  5. android:color="@color/yellow" />
  6. <item android:state_enabled="false"
  7. android:color="@color/green" />
  8. </selector> 


隨手找了個向量圖檔 /res/drawable/hand.xml,tint 屬性指向 image_state.xml
(tint 有著色的意思)
  1.  
  2. <vector
  3. android:height="48dp"
  4. android:width="48dp"
  5. android:tint="@color/image_state" ← 這裡指向 前景 image_state.xml
  6. android:viewportHeight="24"
  7. android:viewportWidth="24"
  8. xmlns:android="http://schemas.android.com/apk/res/android">
  9.  
  10. <path
  11. android:fillColor="@android:color/white"
  12. android:pathData="M13,24c-3.26,0 -6.19,-1.99 -7.4,-5.02l-3.03,-7.61C2.26,10.58 3,9.79 3.81,10.05l0.79,0.26c0.56,0.18 1.02,0.61 1.24,1.16L7.25,15H8V3.25C8,2.56 8.56,2 9.25,2s1.25,0.56 1.25,1.25V12h1V1.25C11.5,0.56 12.06,0 12.75,0S14,0.56 14,1.25V12h1V2.75c0,-0.69 0.56,-1.25 1.25,-1.25c0.69,0 1.25,0.56 1.25,1.25V12h1V5.75c0,-0.69 0.56,-1.25 1.25,-1.25S21,5.06 21,5.75V16C21,20.42 17.42,24 13,24z"/>
  13. </vector>
  14.  


activity_main.xml,ImageButton 的 backgroundTint 屬性指向 back_state.xml
  1.  
  2. <?xml version="1.0" encoding="utf-8"?>
  3. <RelativeLayout
  4. xmlns:android="http://schemas.android.com/apk/res/android"
  5. xmlns:app="http://schemas.android.com/apk/res-auto"
  6. xmlns:tools="http://schemas.android.com/tools"
  7. android:layout_width="match_parent"
  8. android:layout_height="match_parent"
  9. tools:context=".MainActivity">
  10.  
  11. <Button
  12. android:id="@+id/Button1"
  13. android:layout_width="wrap_content"
  14. android:layout_height="wrap_content"
  15. android:layout_alignParentStart="true"
  16. android:layout_alignParentTop="true"
  17. android:onClick="onButton1Clicked"/>
  18.  
  19. <ImageButton
  20. android:id="@+id/Button2"
  21. android:layout_width="wrap_content"
  22. android:layout_height="wrap_content"
  23. android:layout_centerInParent="true"
  24. app:srcCompat="@drawable/hand"
  25. android:backgroundTint="@color/back_state" ← 這裡指向 背景 back_state.xml
  26. android:clickable="true" />
  27.  
  28. </RelativeLayout>
  29.  


MainActivity.kt (程式沒什麼功能,只是觀察 Button2enabled / disabled 時的變化)
  1.  
  2.  
  3. ...
  4. ...
  5.  
  6. class MainActivity : AppCompatActivity() {
  7. private lateinit var binding: ActivityMainBinding
  8.  
  9. override fun onCreate(savedInstanceState: Bundle?) {
  10. super.onCreate(savedInstanceState)
  11. binding = ActivityMainBinding.inflate(layoutInflater)
  12. val view = binding.root
  13. setContentView(view)
  14. }
  15.  
  16. fun onButton1Clicked(vv: View) {
  17. binding.Button2.isEnabled = !binding.Button2.isEnabled
  18. }
  19. }
  20.  


相關筆記 ----


2024-04-06

【Kotlin】刪除指定目錄內的所有檔案

  1.  
  2. class MainActivity : AppCompatActivity() {
  3.  
  4. fun onButtonClicked(vv: View) {
  5. // 刪除內部儲存空間的所有檔案
  6. delAll(filesDir.toString())
  7. }
  8.  
  9. ...
  10. ...
  11. fun delAll(sDir: String) {
  12. val mScope = CoroutineScope(Job() + Dispatchers.IO)
  13. mScope.launch {
  14. val directory = File(sDir)
  15. val files = directory.listFiles()
  16. for (file in files) {
  17. file.delete()
  18. }
  19. }
  20. }
  21. }
  22.  


【Kotlin】查詢 Android 可用的 app 專屬外部儲存空間

參考資料 ----


Android app,可儲存 app 自身專屬(用) 檔案於 內部儲存空間外部儲存空間,內部儲存空間很小,只適合存放小容量的檔案,若不是有特殊需求,官方建議存於外部儲存空間。

雖然是使用外部儲存空間,但因為僅供 app 自身專用,所以並不須向系統請求權限。

  1.  
  2. class MainActivity : AppCompatActivity() {
  3. private val TAG = "MainActivity"
  4.  
  5. override fun onCreate(savedInstanceState: Bundle?) {
  6. super.onCreate(savedInstanceState)
  7. setContentView(R.layout.activity_main)
  8. // 查詢磁碟區狀態
  9. val sExternalStorage = Environment.getExternalStorageState()
  10. Log.d(TAG, "可用外部空間媒體資訊: $sExternalStorage") // mounted: 可讀/寫
  11. // 狀態有: MEDIA_UNKNOWN, MEDIA_REMOVED, MEDIA_UNMOUNTED, MEDIA_CHECKING, MEDIA_NOFS, MEDIA_MOUNTED, MEDIA_MOUNTED_READ_ONLY, MEDIA_SHARED, MEDIA_BAD_REMOVAL, MEDIA_UNMOUNTABLE
  12. // 以前的 Android 因為 ROM 小, 多半會有 SD 卡插槽
  13. // 現在 ROM 變大了, 也漸漸不提供 SD 卡插槽了, 並模擬出一塊虛擬 ROM
  14. val externalStorageVolumes: Array<out File> = ContextCompat.getExternalFilesDirs(applicationContext, null)
  15. Log.d(TAG, "可用外部空間媒體: ${externalStorageVolumes.size} 個") // 2 個, 1 個實體 SD 卡, 1 個虛擬 ROM
  16. for(xx in externalStorageVolumes.indices) {
  17. val primaryExternalStorage = externalStorageVolumes[xx]
  18. Log.d(TAG, "可用外部空間 $xx: $primaryExternalStorage")
  19. // 可用外部空間 0: /storage/emulated/0/Android/data/[您的 app 完整 package 名]/files, 虛擬 ROM 通常會是第 1 個
  20. // 可用外部空間 1: /storage/9E1E-5F1E/Android/data/[您的 app 完整 package 名]/files, 實體 SD 卡
  21. }
  22. if(Environment.getExternalStorageState()==Environment.MEDIA_MOUNTED) {
  23. // 有外部儲存空間媒體, 且可 讀/寫
  24. // 官方強烈建議, 若沒有特殊需要, 優先使用第 1 個可用外部空間
  25. val mFilePath = externalStorageVolumes[0]
  26. Log.d(TAG, "mFilePath: $mFilePath") // /storage/emulated/0/Android/data/[您的 app 完整 package 名]/files
  27. val mExtdir = File(getExternalFilesDir(null), "Chkspace")
  28.  
  29. // 查詢可用空間
  30. val mScope = CoroutineScope(Job() + Dispatchers.IO)
  31. mScope.launch {
  32. var mBytes: Long = 0
  33. // 1. 由於在 Android8(Oreo) 推出新的 API26,
  34. // storageManager.getUuidForPath 不能在主執行緒呼叫, 因此要在 coroutine 內執行
  35. // 2. 舊的 API 在未來會被棄用, 所以要分開處理
  36. if(Build.VERSION.SDK_INT>=26) {
  37. val storageManager = getSystemService(Context.STORAGE_SERVICE) as StorageManager
  38. val uuid = storageManager.getUuidForPath(externalStorageVolumes[0])
  39. try {
  40. mBytes = storageManager.getAllocatableBytes(uuid)
  41. } catch(err: IOException) {
  42. err.printStackTrace()
  43. }
  44. } else {
  45. val path = Environment.getDataDirectory()
  46. val stat = StatFs(path.path)
  47. val blockSize: Long = stat.blockSizeLong
  48. val availableBlocks: Long = stat.availableBlocksLong
  49. mBytes = availableBlocks * blockSize
  50. }
  51. val formatter = DecimalFormat("#,###")
  52. Log.d(TAG, "可用剩餘外部儲存空間 = ${formatter.format(mBytes)} ")
  53.  
  54. if(mBytes>=1024) {
  55. val mKB = (mBytes/1024) // 換算為 kb
  56. if(mKB>=1024) {
  57. val mmMB = (mKB/1024) // 換算為 mb
  58. if(mmMB>=1024) {
  59. val mGB = (mmMB/1024) // 換算為 gb
  60. Log.d(TAG, "可用剩餘外部儲存空間 = $mGB gb")
  61. } else
  62. Log.d(TAG, "可用剩餘外部儲存空間 = $mmMB mb")
  63. } else {
  64. Log.d(TAG, "可用剩餘外部儲存空間 = $mKB kb")
  65. }
  66. } else {
  67. Log.d(TAG, "可用剩餘外部儲存空間 = $mBytes bytes")
  68. }
  69. }
  70. }
  71. }
  72. }
  73.