顯示具有 Kotlin 標籤的文章。 顯示所有文章
顯示具有 Kotlin 標籤的文章。 顯示所有文章

2026-04-06

【Kotlin】自定義 app 結束對話框

本筆記搭配使用 ViewBinding, AdMob, Edge-to-Edge


app  層級 build.gradle
 
android {
    
    compileSdk {
        version = release(36)
    }
...
...

    buildFeatures {
        viewBinding = true
    }
    
    defaultConfig {
        ...
        ...
        minSdk = 26
        targetSdk = 36
    }
}

...
...

dependencies {

    ...
    ...
    
    // adMob
    implementation("com.google.android.gms:play-services-ads:25.1.0")
}
 


strings.xml
 
<resources>
    
    <string name="dialog_title">確定要離開?</string>
    <string name="dialog_message">您確定要關閉應用程式嗎?</string>
    <string name="btn_cancel">取消</string>
    <string name="btn_exit">離開</string>
    
    ...
    ...
 
    <!-- adMob app id 測試 -->
    <string name="admob_app_id">ca-app-pub-3940256099942544~3347511713</string>
    <!-- 橫幅 測試 -->
    <string name="banner_ad_unit_id">ca-app-pub-3940256099942544/6300978111</string>
 
</resources>
 


AndroidManifest.xml
 
<manifest
 
    <!-- 存取網路連線 -->
    <uses-permission android:name="android.permission.INTERNET" />
 
    ...
    ...
 
    <application  ... >
 
        <meta-data
            android:name="com.google.android.gms.version"
            android:value="@integer/google_play_services_version" />
        <meta-data
            android:name="com.google.android.gms.ads.APPLICATION_ID"
            android:value="@string/admob_app_id" />
 
        ...
        ...
 
        <!-- AdMob 的 AdActivity -->
        <activity
            android:name="com.google.android.gms.ads.AdActivity"
            android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|uiMode|screenSize|smallestScreenSize"
            android:theme="@android:style/Theme.Translucent" />
 
        ...
        ...
 
    </application>
 
</manifest>
 


activity_main.xml
 
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/main"    ← 注意這裡
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Hello World!"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>
 


自定義對話框 dialog_exit.xml,因為版面很單純,所以採用 LinearLayout。
 
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:ads="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="vertical"
    android:padding="24dp">

    <TextView
        android:id="@+id/tv_dialog_title"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="@string/dialog_title"
        android:textSize="20sp"
        android:textStyle="bold"
        android:textColor="?attr/colorOnSurface"/>

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="24dp"
        android:gravity="end"
        android:orientation="horizontal">

        <Button
            android:id="@+id/btn_cancel"
            style="?attr/borderlessButtonStyle"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@string/btn_cancel" />

        <Button
            android:id="@+id/btn_exit"
            style="?attr/borderlessButtonStyle"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginStart="8dp"
            android:text="@string/btn_exit"
            android:textColor="?attr/colorError"/>

    </LinearLayout>

    <!-- 做為 banner 的容器 -->
    <FrameLayout
        android:id="@+id/adContainer"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="16dp"
        android:minHeight="250dp" />
        
</LinearLayout>
 


MainActivity.kt
 
class MainActivity : AppCompatActivity() {
    private lateinit var binding: ActivityMainBinding
    
    private var preloadedAdView: AdView? = null    // 預載的廣告物件
    

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        binding = ActivityMainBinding.inflate(layoutInflater)
        val view = binding.root
        setContentView(view)

        // 設定返回鍵攔截(支援新舊所有 Android 版本)
        setupBackPressedListener()
        
        ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->
            val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
            v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
            insets
        }
        
        // 對話框的 AdMob banner
        preloadBannerAd()
    }
override fun onPause() { preloadedAdView?.pause() super.onPause() } override fun onResume() { super.onResume() preloadedAdView?.resume() } override fun onDestroy() { preloadedAdView?.destroy() super.onDestroy() } private fun setupBackPressedListener() { val callback = object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { // 當使用者按下返回鍵(或手勢返回)時,顯示對話框 showExitCustomDialog() } } // 將 callback 加到 Dispatcher 中,它會自動處理生命週期 onBackPressedDispatcher.addCallback(this, callback) } private fun showExitCustomDialog() { // 初始化 Dialog 的 View Binding val dialogBinding = DialogExitBinding.inflate(layoutInflater) // 將預載好的 AdView 放入對話框容器中 preloadedAdView?.let { adView -> // 避免 AdView 重複被加入不同的 Parent (adView.parent as? ViewGroup)?.removeView(adView) dialogBinding.adContainer.addView(adView) } val dialog = MaterialAlertDialogBuilder(this) .setView(dialogBinding.root) .setCancelable(true) .create() dialogBinding.btnCancel.setOnClickListener { dialog.dismiss() } dialogBinding.btnExit.setOnClickListener { dialog.dismiss() finish() } dialog.show() } private fun preloadBannerAd() { preloadedAdView = AdView(this).apply { adUnitId = getString(R.string.banner_ad_unit_id) setAdSize(AdSize.MEDIUM_RECTANGLE) // 開始非同步載入 loadAd(AdRequest.Builder().build()) } } }  

實際的對話框效果


2025-06-21

【Android】設定建構變化版本 -- 分開建立測試、正式版本 app

參考資料 ----


老人家的 app 有置入 AdMob 播放廣告,但 Google 要求 app 開發、測試期間,app 內的廣告要採用測試廣告,且抓得頗嚴,所以覺得蠻麻煩的。有時候,app 上架了才發現沒有換回正式廣告,就得再更正後再重上架...😒


偶然間,發現可以【設定建構變化版本】,這功能超好用的!

AdMob 的每種類型廣告都有個廣告 ID,在開發、測試期間要使用測試 ID,debug 時播放的廣告就會是測試廣告,要設定廣告,須在 /res/values/strings.xml 指定該類型廣告的 ID
 
<resources>
...
...

<!-- 上架前要 mark 起來 -->
<!-- adMob app id 測試 -->
<string name="admob_app">ca-app-pub-3940256099942544~3347511713</string>
<!-- 橫幅 測試 -->
<string name="admob_banner">ca-app-pub-3940256099942544/6300978111</string>
<!-- 插頁 測試 -->
<string name="admob_interstitial">ca-app-pub-3940256099942544/8691691433</string>
<!--  影片 測試 -->
<string name="admob_video">ca-app-pub-3940256099942544/5224354917</string>

<!-- 開發時期先 mark 起來,等要上架前再恢復 -->
<!-- adMob app id 正式 -->
<!-- <string name="admob_app">ca-app-pub-blablaXXXXXXXXXX~XXXXXXXXXX</string> -->
<!-- AdMob 橫幅 正式 -->
<!-- <string name="admob_banner">ca-app-pub-blablaXXXXXXXXXX/aaaaaaaaaa</string> -->
<!-- AdMob 插頁 正式 -->
<!-- <string name="admob_interstitial">ca-app-pub-blablaXXXXXXXXXX/bbbbbbbbbb</string> -->
<!-- AdMob 影片 正式 -->
<!-- <string name="admob_video">ca-app-pub-blablaXXXXXXXXXX/cccccccccc</string> -->

...
...
</resources>
 


現在,改在 app 層級的 build.gradle 設定
 
android {

...
...

    buildTypes {
        debug {
            // 開發、測試期
            applicationIdSuffix ".debug"    // debug 版本的 applicationId 會是 com.example.debug
            
            resValue 'string', 'app_name', '測試版 app'
            
            // adMob app id
            resValue "string", "admob_app_id", "ca-app-pub-3940256099942544~3347511713"
            // 橫幅 測試
            resValue "string", "banner_id", "ca-app-pub-3940256099942544/6300978111"
            // 插頁 測試
            resValue "string", "interstitial_id", "ca-app-pub-3940256099942544/8691691433"
        }

        release {
            // 正式
            resValue 'string', 'app_name', '正式版 app'
            
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'

            // adMob app id
            resValue "string", "admob_app_id", "ca-app-pub-blablaXXXXXXXXXX~XXXXXXXXXX"
            // 橫幅 正式
            resValue "string", "banner_id", "ca-app-pub-blablaXXXXXXXXXX/aaaaaaaaaa"
            // 插頁 正式
            resValue "string", "interstitial_id", "ca-app-pub-blablaXXXXXXXXXX/bbbbbbbbbb"
        }
    }

...
...
}
 

不僅如此,還可以設定 測試版 跟 正式版 不同的 applicationId,這麼做,就可以在同一支開發手機同時安裝 測試版 跟 正式版 app。

功能表選擇 Build → Select Build Variant,會看到可編譯的版本的選項。


而 productFlavors 是 BuildTypes 的進階設定,可以變化出更多的 Build Variant,如下圖:


另有一個常見的情況:
某個特定的 API 在某版本起被棄用,Google 強制規定要改用新的 API(Google 還蠻常做這事的...所以要配合 Google,大約每半年要檢視自己的 app 是否能在各 Android 版本上正常運行);例如:
app 原本在 API SDK30 前都可正常運行,但自 SDK31 起須改變程式寫法,在 build.gradle 設定 minSdkVersion 31,程式改好後,可將新版的 apk 取不同的檔名,上架到 PlayStore,如此,就分出新舊版本了,手機中的 PlayStore app 會自動判別,挑選最適合手機的版本 -- 手機版本在 SDK30 前的 user 會下載安裝舊版的 app,而手機版本在 SDK31 後的 user 則會下載新版的 app,不會因為 app 改版而放棄舊版本手機的 user。



2024-08-28

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

題目:

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

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


做法一:

 
val mScope = CoroutineScope(Job() + Dispatchers.IO)
mScope.launch {
    val mDbHelper = SQLite(baseContext)
    var db = mDbHelper.readableDatabase

    // 查詢本機資料庫有幾筆記錄
    var sSql = "SELECT * FROM table1 "
    val rs = db.rawQuery(sSql, null)
    val mRecnt = rs.count    // 本機資料庫記錄筆數
    var mSentence = 100   // 迴圈數, 要取出 100 筆記錄

    val mList = mutableListOf<Int>()
    var xx = 1
    // 這種做法, 有可能亂數取得的 mPos 已存在 mList 中
    while(xx<=mSentence) {
        val mPos = (0 until mRecnt).random()    // 每筆記錄在 table1 的位置
        if(mPos !in mList) {
            mList.add(mPos)
            xx++
        }
    }
    mList.sorted()    // 將 mList 內的元素由小到大排序
    // 如此, 在實際自 table1 取出記錄就會是自資料表首一路向資料表尾依序取出記錄
    
    ...
    ...
}
 



做法二:

 
val mScope = CoroutineScope(Job() + Dispatchers.IO)
mScope.launch {
    ...
    ...

    val mRaw = mutableSetOf<Int>()    // mutableSet 的特性是存入時就會檢查每個元素的值都是唯一
    val mRandom = Random(System.currentTimeMillis())
    while(mRaw.size<mSentence) {
        mRaw.add(mRandom.nextInt(mRecnt))
    }
    // 但 mutableSet 不具排序功能, 
    // 故將它轉型態成 list 並排序後存入  mList
    val mList = mRaw.toList().sorted()
    
    ...
    ...
}
 



做法三:

 
val mScope = CoroutineScope(Job() + Dispatchers.IO)
mScope.launch {
    ...
    ...

    // 這個做法最快, 一行指令搞定
    // 以 shuffled() 洗牌後, 再以 take() 取前面幾個元素, 再將元素排序
    // (0 until mRecnt) 是 IntRange
    val mList = (0 until mRecnt).shuffled().take(mSentence).sorted()
    // val mList = (0 until mRecnt).shuffled(Random(System.currentTimeMillis())).take(mSentence).sorted()   // 可以在 shuffled() 內加上時間當做亂數種子, 增強其隨機性
    
    ...
    ...
}
 



2024-04-07

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

參考資料 ----


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

app 層級 build.gradle(若您的程式沒有用到視圖綁定,這部份可忽略)
 
...
...
 
android {
    
    ...
    ...
    
    buildFeatures {
        viewBinding true
    }
    
    ...
    ...
 


色表 /values/color.xml
 
<?xml version="1.0" encoding="utf-8"?>
<resources>

    ...
    ...
	
    <color name="blue">#0000FF</color>
    <color name="red">#ffff0000</color>
    <color name="brown">#8B4513</color>
    <color name="gray">#ff888888</color>
    <color name="green">#ff00ff00</color>
    <color name="yellow">#FFFF00</color>
	
    ...
    ...
</resources>
 


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

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


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


隨手找了個向量圖檔 /res/drawable/hand.xml,tint 屬性指向 image_state.xml
(tint 有著色的意思)
 
<vector
    android:height="48dp"
    android:width="48dp"
    android:tint="@color/image_state"    ← 這裡指向 前景 image_state.xml
    android:viewportHeight="24"
    android:viewportWidth="24"
    xmlns:android="http://schemas.android.com/apk/res/android">

    <path
        android:fillColor="@android:color/white"
        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"/>
</vector>
 


activity_main.xml,ImageButton 的 backgroundTint 屬性指向 back_state.xml
 
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <Button
        android:id="@+id/Button1"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignParentStart="true"
        android:layout_alignParentTop="true"
        android:onClick="onButton1Clicked"/>

    <ImageButton
        android:id="@+id/Button2"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_centerInParent="true"
        app:srcCompat="@drawable/hand"
        android:backgroundTint="@color/back_state"    ← 這裡指向 背景 back_state.xml
        android:clickable="true" />

</RelativeLayout>
 


MainActivity.kt (程式沒什麼功能,只是觀察 Button2 在 enabled / disabled 時的變化)
 

...
...

class MainActivity : AppCompatActivity() {
    private lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        val view = binding.root
        setContentView(view)
    }

    fun onButton1Clicked(vv: View) {
        binding.Button2.isEnabled = !binding.Button2.isEnabled
    }
}
 


相關筆記 ----


2024-04-06

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

 
class MainActivity : AppCompatActivity() {

    fun onButtonClicked(vv: View) {
        // 刪除內部儲存空間的所有檔案
        delAll(filesDir.toString())
    }

    ...
    ...
    
    fun delAll(sDir: String) {
        val mScope = CoroutineScope(Job() + Dispatchers.IO)
        mScope.launch {
            val directory = File(sDir)
            val files = directory.listFiles()
            for (file in files) {
                file.delete()
            }
        }
    }
}
 


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

參考資料 ----


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

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

 
class MainActivity : AppCompatActivity() {
    private val TAG = "MainActivity"

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
  
        // 查詢磁碟區狀態
        val sExternalStorage = Environment.getExternalStorageState()
        Log.d(TAG, "可用外部空間媒體資訊: $sExternalStorage")    // mounted: 可讀/寫
        // 狀態有: MEDIA_UNKNOWN, MEDIA_REMOVED, MEDIA_UNMOUNTED, MEDIA_CHECKING, MEDIA_NOFS, MEDIA_MOUNTED, MEDIA_MOUNTED_READ_ONLY, MEDIA_SHARED, MEDIA_BAD_REMOVAL, MEDIA_UNMOUNTABLE
  
        // 以前的 Android 因為 ROM 小, 多半會有 SD 卡插槽
        // 現在 ROM 變大了, 也漸漸不提供 SD 卡插槽了, 並模擬出一塊虛擬 ROM
        val externalStorageVolumes: Array<out File> = ContextCompat.getExternalFilesDirs(applicationContext, null)
        Log.d(TAG, "可用外部空間媒體: ${externalStorageVolumes.size} 個")    // 2 個, 1 個實體 SD 卡, 1 個虛擬 ROM
  
        for(xx in externalStorageVolumes.indices) {
            val primaryExternalStorage = externalStorageVolumes[xx]
            Log.d(TAG, "可用外部空間 $xx: $primaryExternalStorage")
            // 可用外部空間 0: /storage/emulated/0/Android/data/[您的 app 完整 package 名]/files, 虛擬 ROM 通常會是第 1 個
            // 可用外部空間 1: /storage/9E1E-5F1E/Android/data/[您的 app 完整 package 名]/files, 實體 SD 卡
        }
  
        if(Environment.getExternalStorageState()==Environment.MEDIA_MOUNTED) {
            // 有外部儲存空間媒體, 且可 讀/寫
            // 官方強烈建議, 若沒有特殊需要, 優先使用第 1 個可用外部空間
            val mFilePath = externalStorageVolumes[0]
            Log.d(TAG, "mFilePath: $mFilePath")    //   /storage/emulated/0/Android/data/[您的 app 完整 package 名]/files
            val mExtdir = File(getExternalFilesDir(null), "Chkspace")

            // 查詢可用空間
            val mScope = CoroutineScope(Job() + Dispatchers.IO)
            mScope.launch {
                var mBytes: Long = 0
                // 1. 由於在 Android8(Oreo) 推出新的 API26,
                // storageManager.getUuidForPath 不能在主執行緒呼叫, 因此要在 coroutine 內執行
                // 2. 舊的 API 在未來會被棄用, 所以要分開處理
                if(Build.VERSION.SDK_INT>=26) {
                    val storageManager = getSystemService(Context.STORAGE_SERVICE) as StorageManager
                    val uuid = storageManager.getUuidForPath(externalStorageVolumes[0])
                    try {
                        mBytes = storageManager.getAllocatableBytes(uuid)
                    } catch(err: IOException) {
                        err.printStackTrace()
                    }
                } else {
                    val path = Environment.getDataDirectory()
                    val stat = StatFs(path.path)
                    val blockSize: Long = stat.blockSizeLong
                    val availableBlocks: Long = stat.availableBlocksLong
                    mBytes = availableBlocks * blockSize
                }
                val formatter = DecimalFormat("#,###")
                Log.d(TAG, "可用剩餘外部儲存空間 = ${formatter.format(mBytes)} ")

                if(mBytes>=1024) {
                    val mKB = (mBytes/1024) // 換算為 kb
                    if(mKB>=1024) {
                        val mmMB = (mKB/1024) // 換算為 mb
                        if(mmMB>=1024) {
                            val mGB = (mmMB/1024) // 換算為 gb
                            Log.d(TAG, "可用剩餘外部儲存空間 = $mGB gb")
                        } else
                            Log.d(TAG, "可用剩餘外部儲存空間 = $mmMB mb")
                    } else {
                        Log.d(TAG, "可用剩餘外部儲存空間 = $mKB kb")
                    }
                } else {
                    Log.d(TAG, "可用剩餘外部儲存空間 = $mBytes bytes")
                }
            }
        }
    }
}
 


【Kotlin】Coroutine 複製 assets 的檔案到內部儲存空間

參考資料 ----

 
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)

	// 檢查內部儲存空間是否已存在檔案
    val file = File(this.filesDir, "mymusic.mp3")
    if(!file.exists()) {
            copyAssets()
    } else {
        Toast.makeText(this, "mymusic.mp3 已複製到私有儲存空間", Toast.LENGTH_LONG).show();
    }
}

...
...

// 複製 mymusic.mp3 至私有儲存空間
private fun copyAssets() {
    val mScope = CoroutineScope(Job() + Dispatchers.IO)
    mScope.launch {
        try {
            val outfile = FileOutputStream(File(filesDir, "mymusic.mp3").path)
            val infile: InputStream = assets.open("mymusic.mp3")
            infile.copyTo(outfile)
            outfile.close()
            infile.close()
        } catch(err: IOException) {
            err.printStackTrace();
        }
    }
}
 


相關筆記 ----


2024-02-26

【Kotlin】disable/enable ImageButton

參考資料 ----


雖然在 layout.xml 中定義 enabled="false",AndroidStudio 並不會報錯,但在 app 執行時並沒有產生作用;官網的 ImageButton 參考頁也沒有找到 enabled 的屬性,似乎只能在 app 執行時設定。
 
...
...

<ImageButton
    android:id="@+id/Button1"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:src="@drawable/圖檔"    本次採用 向量圖檔(vector asset), 所以可在程式中改變圖檔的顏色
    android:background="@android:color/transparent"    指定按鈕的背景為透明
    android:enabled="true" />    這個屬性在 app 執行時並沒有產生作用
 


 
 
... 
...

override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        var button1 = findViewById<ImageButton>(R.id.Button1)
        button1.isEnabled = false
        // 即使被 disabled 了, button1 所引用的圖檔顏色並沒有任何變化
        // 所以也要自行改變圖檔的顏色
        button1.setColorFilter(getColor(R.color.black))
    }
 


2024-02-07

【Kotlin】設為 深色/夜晚 模式

參考資料 ----



2024.11.10
踩到雷!
下面程式碼在陽春的 app 可行,但 強制深色模式會令 Activity 重建一個 instance(實例),以 onCreate() 為例,會同時間執行 2 次;這次的雷就是 SQLite 同時間寫入 2 筆相同的記錄,造成 primary key 衝突才發現。

這篇似乎有解:實作深色主題



以 Android Studio 精靈建立新專案,若看到有 /res/values-night/ 目錄,且目錄下有 themes.xml,就表示精靈有幫您建立 深色/夜晚 模式的主題配置。
 
override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        
        ...
        ...

        // 設為 深色/夜晚 模式
        AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)

        ...
        ...
}
 
這指令到底該在什麼場合使用...再說吧... 😝
===== 2024.11.10 =====
這篇似乎有解:實作深色主題


目前找到的替代做法,是修改預設(白天) 的 themes.xml ↓
 
<style name="Theme.blablabla" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
	...
	...
	
    <!-- Customize your theme here. -->
    <item name="android:windowBackground">@color/black</item>
 

2023-09-09

【Kotlin】GeckoView 動態顯示本機網頁

參考資料 ----
Match patterns

動機:
因為 GeckoView 的顯示效果比 Android 系統內建 WebView 元件強上許多,所以想探索是否能以 GeckoView 取代 WebView,達到 WebView.loadDataWithBaseURL() 的目的。

先說結論:截至目前為止(2023.09.09), GeckoView 版本為 117.0.20230824132758 之際,
無法實作動態顯示本機網頁,只能顯示靜態網頁,原因在:

GeckoView 目前尚 不支援 url 為 resource://... 的 scheme


所以當您想顯示動態內容網頁,而網頁又引入了 css、javascript... 等檔案,則這些 css、javascript...檔案必須置放在 internet 的網站上,然後呼叫 loadString(html string)。





下面的筆記就先不予理會了...



有時候我們並不是要去下載某個網站的網頁,而是想利用 GeckoView 顯示 HTML+CSS 豐富的版面樣式,要讓 app 和 GeckoView 能互傳訊息,這就要利用 WebExtension(擴充套件) 的功能。

開發工具:Android Studio Chipmunk 2021.2.1 Patch 2

在 /assets 內建立要置放 javascript 、 CSS 和 WebExtension 的目錄,並將需要的檔案分別置放於內;再建立一個 html 首頁檔,該網頁的 <head> 區塊要引入前述的 javascript 和 css。

WebExtension 在 GeckoView 和 app 之間扮演重要的角色,是兩者的溝通橋樑。

每建立一個 WebExtension,WebExtension 要有自己的目錄,在本例,我們建立 /assets/messaging/ ,然後每個 WebExtension 要有自己的 manifest.json

manifest.json
 
{
  "manifest_version": 2,
  "name": "messaging",
  "version": "1.0",
  "description": "Example messaging web extension.",
  "browser_specific_settings": {
    "gecko": {
      "id": "messaging@example.com"
    }
  },
  "content_scripts": [
    {
      // "matches": ["*://*.twitter.com/*"],
      "matches": ["*://*/*.htm"],
      "js": ["messaging.js"]
    }
  ],
  "permissions": [
    // "activeTab",
    "nativeMessaging",
    "nativeMessagingFromContent",
    "geckoViewAddons"
  ]
}
 


再建立 app 要與 GeckoView 通訊的 javascript 程式,在本例為 background.js
 
background.js
 
// Establish connection with app
let port = browser.runtime.connectNative("browser");
port.onMessage.addListener(response => {
    // Let's just echo the message back
    port.postMessage(`Received: ${JSON.stringify(response)}`);
});
port.postMessage("Hello from WebExtension!");
 


WWW


index.htm (檔名可自定喔)
 
<!DOCTYPE html>
<html>
<head>
    <meta http-equiv="content-type" content="text/html; charset=UTF-8">
    <script type="text/javascript" src="./js/jquery-3.5.1.min.js"></script>
    <script type="text/javascript" src="./js/jquery-ui-1.8.16.custom.min.js"></script>
    <link rel="stylesheet" href="./css/jquery-ui-1.8.16.custom.css">
	<style>
        img{    max-width:50%;  }
        .matrix-block		{ font-size: 150%; text-align: center }
	    mtable.thin-column-padding > mtr > mtd,
		fmath span.thin-column-padding > table > tbody > tr > td
		{ padding: 0 0.11em !important }
    </style>
</head>
<body>
hello
</body>
</html>
 


MainActivity.kt
 
...
...

import org.mozilla.geckoview.GeckoRuntime;
import org.mozilla.geckoview.GeckoSession;
import org.mozilla.geckoview.GeckoView;

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        var view:GeckoView = findViewById(R.id.geckoview)
        val session = GeckoSession()
        val runtime = GeckoRuntime.create(this)
        
        session.contentDelegate = object : ContentDelegate {}
        if(runtime==null) {
            runtime = GeckoRuntime.create(this)
        }
        
        session.open(runtime)
        view.setSession(session)
        session.loadUri(""resource://android/assets/index.htm"")    // 先載入首頁,讓 GeckoView 記住相關的設定
        
        
        // 再載入自定的網頁內容
        
    }
}
 



相關筆記 ----


2023-06-01

【Kotlin】計算起、迄時刻所經歷的時間

app 層級 build.gradle

 
android {
    ...
    ...
    
    buildFeatures {
        viewBinding true
    }
}
 



activity_main.xml
 
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <TextView
        android:id="@+id/txtHello"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Hello World!"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        android:onClick="timeDuration"
        />

</androidx.constraintlayout.widget.ConstraintLayout>
 



MainActivity.kt
 
package 完整package名

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.os.SystemClock
import android.view.View
import com.example.timeduration.databinding.ActivityMainBinding
import java.util.Date


class MainActivity : AppCompatActivity() {
    private lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        val view = binding.root
        setContentView(view)
    }

    fun timeDuration(vv: View) {
        // 狀況 1: 變數型態為 Date 類別
        val a1 = Date()
        SystemClock.sleep(123)
        val a2 = Date()

        // 狀況 2: 變數型態為字串, 則轉換為 Date 類別
        /*
        val pattern = "yyyy.MM.dd HH:mm:ss.SSS"
        val formatter = SimpleDateFormat(pattern, Locale.getDefault())
        val a1 = formatter.parse("2023.05.01 00:00:00.000")
        val a2 = formatter.parse("2023.05.01 01:10:11.123")
        */

        val durationMillis = a2.time - a1.time

        // 精確度達 0.001 秒
        val hours = durationMillis / (1000 * 60 * 60)
        val minutes = durationMillis % (1000 * 60 * 60) / (1000 * 60)
        val seconds = durationMillis % (1000 * 60) / 1000
        val millis = durationMillis % 1000

        // 輸出格式為  時:分:秒.毫秒
        val sDuration = String.format("%02d:%02d:%02d.%03d", hours, minutes, seconds, millis)
        binding.txtHello.text = sDuration
    }
}
 



【Kotlin】socket ping 判斷是否連上網際網路

2023.06.01
ping 的回應速度極快,還可指定等待 timeout 的時間限制,目前為止找到的最佳解


app 層級 build.gradle
 
android {
    ...
    ...
    
    buildFeatures {
        viewBinding true
    }
}

dependencies {
    ...
    ...
    
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4")
}
 



AndroidManifest.xml
 
<uses-permission android:name="android.permission.INTERNET" />

<application
    ...
    ...
 



activity_main.xml
 
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <TextView
        android:id="@+id/lblHello"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Hello World!"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        android:onClick="pingDuration"
        />

</androidx.constraintlayout.widget.ConstraintLayout>
 



MainActivity.kt
 
...
...

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.view.View
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import java.net.InetSocketAddress
import java.net.Socket
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale


class MainActivity : AppCompatActivity() {
    private lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        val view = binding.root
        setContentView(view)
        
        pingDuration()
    }


    // 偵測 internet 連線狀況
    fun pingDuration() {
        val mScope = CoroutineScope(Job() + Dispatchers.IO)
        mScope.launch(Dispatchers.IO) {
            var sDuration = ""
            try {
                val a1 = Date()
                val timeoutMs = 1000    // 設定 timeout 為 1 秒
                val socket = Socket()
                val socketAddress = InetSocketAddress("8.8.8.8", 53)
                socket.connect(socketAddress, timeoutMs)
                socket.close()
                val a2 = Date()

                val durationMillis = a2.time - a1.time    // 平均小於 0.07 秒
                val hours = durationMillis / (1000 * 60 * 60)
                val minutes = durationMillis % (1000 * 60 * 60) / (1000 * 60)
                val seconds = durationMillis % (1000 * 60) / 1000
                val millis = durationMillis % 1000
                sDuration = String.format("%02d:%02d:%02d.%03d", hours, minutes, seconds, millis)
            } catch (e: Exception) {
                sDuration = "斷網 -- "+e.message
            }
            
            runOnUiThread {
                binding.lblHello.text = sDuration
            }
        }
    }
}
 



2023-04-16

【Kotlin】AdMob 插頁式廣告(InterstitialAd AdMob20[含]↑)

參考資料 ----

插页式广告

InterstitialAd

不允許的插頁式廣告導入方式

 

 

app 層級的 build.gradle 

 
dependencies {
    ...
    ...
    
    // adMob
    implementation 'com.google.android.gms:play-services-ads:22.0.0'
}

apply plugin: 'com.google.gms.google-services'
 

 

 

AndroidManifest.xml 

 
<application
    ...
    ...>
    
    <meta-data
        android:name="com.google.android.gms.ads.APPLICATION_ID"
        android:value="@string/admob_app_id" />

    ...
    ...
 

 

 

string.xml 

 
<!-- adMob app id 測試 -->
<string name="admob_app_id">ca-app-pub-3940256099942544~3347511713</string>
<!-- 橫幅 測試 -->
<string name="banner_ad_unit_id">ca-app-pub-3940256099942544/6300978111</string>;
<!-- 插頁 測試 -->
<string name="interstitial_ad_unit_id">ca-app-pub-3940256099942544/8691691433</string>
<!-- 影片 測試 -->
<string name="video_ad_id">ca-app-pub-3940256099942544/5224354917</string>
 

 

 

MainActivity.kt 

 
...
...
import com.google.android.gms.ads.AdError
import com.google.android.gms.ads.AdRequest
import com.google.android.gms.ads.FullScreenContentCallback
import com.google.android.gms.ads.interstitial.InterstitialAd
import com.google.android.gms.ads.interstitial.InterstitialAdLoadCallback



class MyActivity : Activity()
{
    var mInterstitialAd: InterstitialAd? = null
    
    ...
    ...
    
    override fun onResume()
    {
        var adRequest = AdRequest.Builder().build()
        InterstitialAd.load(this,getString(R.string.interstitial_ad_unit_id), adRequest, object : InterstitialAdLoadCallback() {
            override fun onAdFailedToLoad(adError: LoadAdError) {
                Log.d(TAG, adError.toString())
                mInterstitialAd = null
            }

            override fun onAdLoaded(interstitialAd: InterstitialAd) {
                Log.d(TAG, "Ad was loaded.")
                mInterstitialAd = interstitialAd
            }
        })
        super.onResume()
    }
    
    
    // 當 usser 按了按鈕時
    fun onImgClicked(vv: View)
    {
        toWatchIntersticialAd()
    }
    
    
    // 觀看插頁廣告
    private fun toWatchIntersticialAd()
    {
        val random = Random()
        if ((mInterstitialAd!=null) && (random.nextInt(3)==0))
        // if(mInterstitialAd!= null) // 測試用, 每次都播放
            {
                // 若廣告已載入, 且亂數是 3 的倍數, 則播放
                // (AdMob 規定: 插頁播放的比例須 < 50%)
                mInterstitialAd?.fullScreenContentCallback =
                    object : FullScreenContentCallback() {
                        override fun onAdDismissedFullScreenContent() {
                            Log.d(TAG, "Ad was dismissed.")
                            // user 關閉廣告, 回主畫面
                            mInterstitialAd = null
                            // 返回主畫面
                            NavUtils.navigateUpFromSameTask(this@MyActivity)
                        }

                        override fun onAdFailedToShowFullScreenContent(adError: AdError) {
                            Log.d(TAG, "Ad failed to show.")
                            mInterstitialAd = null
                            // 返回主畫面
                            NavUtils.navigateUpFromSameTask(this@MyActivity)
                        }

                        override fun onAdShowedFullScreenContent() {
                            Log.d(TAG, "Ad showed fullscreen content.")
                            // Called when ad is dismissed.
                        }
                    }
                mInterstitialAd!!.show(this)
            }
        else
            // 直接返回主畫面
            NavUtils.navigateUpFromSameTask(this)
    }
}
 


 

 

 

 

2023-03-19

【Kotlin】ping 判斷 android 裝置是否連線至網際網路

參考資料 ----







以下備存,做為學習歷程的記錄


在爬文的過程中, 也有看到認為這不可靠的觀點......所以,再繼續尋找更好的做法吧......

===== 2023.03.23 =====
拿手邊的幾支 Android 裝置實測,在同樣的 Wi-Fi 環境中
ASUS Zenfone3(Android8.0) -- 正常
華為 T3 平板(Android7.0) -- 反應網路不通,但 app 內的 AdMob 廣告播放正常,表示 app 在 T3 執行時誤判。
SugarY12S(AndroidGo8.1.0) -- 正常
ASUS Zenfone5(Android9) -- 正常
小米 POCO X4 PRO(Android12) -- 正常


app 層級的 build.gradle

 
...
...

android {
    ...
    ...
    
    buildFeatures {
            viewBinding true
    }
    
    buildTypes {
        ...
        ...
    }
}

dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4")
}
...
...
 



AndroidManifest.xml
 

<uses-permission android:name="android.permission.INTERNET"/>

<application
    ...
    ...
    
 


activity_main.xml
 
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <TextView
        android:id="@+id/lblHello"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:onClick="isConnected"
        android:text="Hello World!"
        android:textSize="20sp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>
 



MainActivity.kt
 
import android.content.ContentValues.TAG
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.util.Log
import android.view.View
import android.widget.Toast
import com.example.pingconn.databinding.ActivityMainBinding
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.IOException
import java.net.InetAddress

class MainActivity : AppCompatActivity() {

    val TAG = "MainActivity"
    private lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        val view = binding.root
        setContentView(view)
    }

    fun isConnected(vv: View)
    {
        // Toast.makeText(this, "test", Toast.LENGTH_SHORT).show()
        GlobalScope.launch {
            val address = "8.8.8.8"
            val isReachable = myPing(address)
            withContext(Dispatchers.Main) {
                updateUI(isReachable)
            }
        }
    }

    private fun updateUI(isReachable: Boolean) {
        if(isReachable)
            binding.lblHello.text = "internet connected"
        else
            binding.lblHello.text = "no internet"
    }

    suspend fun myPing(address: String): Boolean = withContext(Dispatchers.IO) {
        try {
            val inetAddress = InetAddress.getByName(address)
            inetAddress.isReachable(500)    // 單位:毫秒, 
        } catch (e: Exception) {
            false
        }
    }
}
 


相關筆記 ----

2023-03-11

【Kotlin】以 OkHttp3 偵測是否有網際網路連線能力

參考資料 ----

OkHttp


OkHttp 是輕量型的 3rd party library,這篇筆記只是記錄透過 OkHttp 偵測 Android 裝置是否有連上網際網路。

上網比較了幾個網站,選定目標網站是網頁內容(html 字數,包含 CSS, javascript...) 最少的 example.com。


app 層級的 build.gradle

 
...
...

android {
    ...
    ...
    
    buildFeatures {
            viewBinding true
    }
    
    buildTypes {
        ...
        ...
    }
    
}

...
...

dependencies {
    ...
    ...
    implementation 'com.squareup.okhttp3:okhttp:4.7.2'
}
 



AndroidManifest.xml
 

<uses-permission android:name="android.permission.INTERNET"/>

<application
    ...
    ...
    
 



activity_main.xml
 
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <TextView
        android:id="@+id/lblHello"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Hello World!"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <Button
        android:id="@+id/btnCheck"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="check internet"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHorizontal_bias="0.064"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintVertical_bias="0.023"
        android:onClick="onBtnClicked"/>

</androidx.constraintlayout.widget.ConstraintLayout>
 



MainActivity.kt
 
...
...

import android.os.Bundle
import android.util.Log
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import com.example.okhp.databinding.ActivityMainBinding
import okhttp3.*
import okio.IOException


class MainActivity : AppCompatActivity() {

    val TAG = "MyTag"
    private lateinit var binding: ActivityMainBinding
    private lateinit var client: OkHttpClient

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        val view = binding.root
        setContentView(view)
    }


    fun onBtnClicked(vv: View) {
        val client = OkHttpClient()
        val request = Request.Builder()
            .url("https://example.com")
            .build()

        client.newCall(request).enqueue(object : Callback {
            override fun onResponse(call: Call, response: Response) {
                // Log.d(TAG, "response: ${response.body?.string()}")
                runOnUiThread {
                    binding.lblHello.text = "connected"
                }
            }

            override fun onFailure(call: Call, e: IOException) {
                // Log.d(TAG, e.toString())
                runOnUiThread {
                    binding.lblHello.text = "no internet"
                }
            }
        })
    }
}
 



跟 Volley 稍微比較了一下,OkHttp3 速度確實快了一些。


相關筆記 ----


2023-02-05

【Kotlin】以 BroadcastReceiver 通知裝置網路連線狀態 -- 適用 API28(含)之前

參考資料 ----
ConnectivityManager
NetworkInfo


授予 app 權限

AndroidManifest.xml:
 
...
...

<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>

...
...
 


app 層級 build.gradle
android {
    compileSdk 33

    ...
    ...

    buildFeatures {
        viewBinding true
    }

    ...
    ...

}


activity_main.xml:
 
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:id="@+id/txtHello"
        android:text="Hello World!"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>
 



MainActivity.kt
 
...
...
class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        val view = binding.root
        setContentView(view)
    }

    override fun onResume() {
        super.onResume()
        val filter = IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION)
        registerReceiver(connectivityReceiver, filter)
    }

    override fun onPause() {
        super.onPause()
        unregisterReceiver(connectivityReceiver)
    }

    fun updateTextView(message: String) {
        binding.txtHello.text = message
    }
    
    
    ...
    ...
    
    private val connectivityReceiver = object : BroadcastReceiver() {
        override fun onReceive(context: Context?, intent: Intent?) {
            val connectivityManager = context?.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
            val networkInfo = connectivityManager.activeNetworkInfo
            if (networkInfo != null && networkInfo.isConnected) {
                (context as MainActivity).updateTextView("已連線")
            } else {
                (context as MainActivity).updateTextView("已斷線")
            }
        }
    }

}
...
...
 


相關筆記 ----

2023-01-22

【Kotlin】以 Mozilla GeckoView 取代 webview

參考資料 ----


Android 內建的 webview 元件在顯示的體驗上不是很好,某些網頁版面的顯示結果就是不像真正的瀏覽器,而 Mozilla 的 Gecko View 是真正的瀏覽器引擎。

去 GeckoView Maven Repository 尋找您要的版本號;在本筆記,選擇穩定版 100.0.20220425210429


開發工具:Android Studio Chipmunk 2021.2.1 Patch 2


Project 層級 build.gradle
 
buildscript {
    ext.kotlin_version = "1.7.10"
    repositories {
        google()
        mavenCentral()
    }

    dependencies {
        classpath 'com.android.tools.build:gradle:7.2.2'
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
    }
}


allprojects {
    repositories {
        google()
        mavenCentral()
    }
}

task clean(type: Delete) {
    delete rootProject.buildDir
}
 


app 層級 build.gradle
 
plugins {
    id 'com.android.application'
    id 'kotlin-android'
}

ext {
    geckoviewChannel="arm64-v8a"
    geckoviewVersion="100.0.20220425210429"
}

...
...

android {
    compileSdkVersion 33
    
    ...
    ...
    
    // Note: compileOptions is only required for minSdkVersion < 24
    compileOptions {
        // sourceCompatibility JavaVersion.VERSION_1_8
        // targetCompatibility JavaVersion.VERSION_1_8
        sourceCompatibility JavaVersion.VERSION_11  // 若採用這個, 則 compileSdkVersion 要指定 33(含) 以上
        targetCompatibility JavaVersion.VERSION_11
    }
}

...
...

repositories {
    maven {
        url "https://maven.mozilla.org/maven2/"
    }
}

...
...

dependencies {

    ...
    ...
    
    // mozilla geckoview
    implementation "org.mozilla.geckoview:geckoview-${geckoviewChannel}:${geckoviewVersion}"
}

 

如果有出現類似

Build was configured to prefer settings repositories over project repositories but repository '???' was added by build file 'build.gradle' 

的錯誤訊息,可能是您的專案套用了 Gradle6.8 的新規定,那就修改 settings.gradle ,將 dependencyResolutionManagement 這個區塊 註解 或 刪除,我是去參照其他舊專案,只留下 2 列,如下:

settings.gradle
 
rootProject.name = "專案名"
include ':app'
 



AndroidManifest.xml
 
<manifest ... >
    <uses-permission android:name="android.permission.INTERNET" />
        ...
</manifest>
 


activity_main.xml
 
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity" >

    <org.mozilla.geckoview.GeckoView
        xmlns:android="http://schemas.android.com/apk/res/android"
        android:id="@+id/geckoview"
        android:layout_width="fill_parent"
        android:layout_height="fill_parent"
        tools:context=".MainActivity" />

</RelativeLayout>
 


MainActivity.kt
 
...
...

import org.mozilla.geckoview.GeckoRuntime;
import org.mozilla.geckoview.GeckoSession;
import org.mozilla.geckoview.GeckoView;

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        var view:GeckoView = findViewById(R.id.geckoview)
        val session = GeckoSession()
        val runtime = GeckoRuntime.create(this)
        
        session.contentDelegate = object : ContentDelegate {}
        if(runtime==null) {
            runtime = GeckoRuntime.create(this)
        }
        
        session.open(runtime)
        view.setSession(session)
        session.loadUri("https://tw.yahoo.com")
    }
}
 


在 ZenFon5 跑的反應速度有點慢,且編譯出來的 apk 頗大 -- 139MB !! 不過呈現的畫面的確較 webview 細緻。

2022-10-23

【Kotlin】AdMob(API20(含)↑) 自適應橫幅 Adaptive Banner, 適 Android R(API30↑)

參考資料 ----


Smart Banner 將停用,全面以 Adaptive Banner 取代!

因應 Java 的改版, AdMob 也跟著更新,並改變了 取得 AdSize 的寫法

app 層級的 build.gradle 要使用最新版的 play-services-ads,目前(2022.10.23) 是 21.3.0。

 
...
...
dependencies {
    ...
    ...
    
    implementation 'com.google.android.gms:play-services-ads:21.3.0'
    
    ...
    ...
}    
 


strings.xml
 
...
...

    < 測試的 AdMob app id -->
    <string name="app_id">ca-app-pub-3940256099942544~3347511713</string>
    < 測試的 AdMob 橫幅 id -->
    <string name="banner">ca-app-pub-3940256099942544/6300978111</string>

...
...
 


AndroidManifest.xml
 
    <application
        ...
        ... >

        <!-- App ID -->
        <meta-data
            android:name="com.google.android.gms.ads.APPLICATION_ID"
            android:value="@string/app_id" />
            
        ...
        ...
        <activity
                ...
                ...
        
 


原本的 layout.xml 是在佈局中直接放一個 adview,因為要動態決定 adview 的尺寸,就不這麼做了。
 
...
...

<com.google.android.gms.ads.adview
    ads:adsize="SMART_BANNER"
    ads:adunitid="@string/banner_ad_unit_id"
    android:id="@+id/adView"
    android:layout_alignparentbottom="true"
    android:layout_alignparentend="true"
    android:layout_alignparentleft="true"
    android:layout_alignparentright="true"
    android:layout_alignparentstart="true"
    android:layout_height="wrap_content"
    android:layout_width="match_parent" />

...
...
 

改成以 framelayout 做為 AdView 的容器(container)
 
...
...

<!-- 只要是 ViewGroup 層級皆可,ex:FrameLayout...,視您的需要自行變化 -->
<LinearLayout
    android:id="@+id/ad_view_container"
    android:layout_alignParentBottom="true"
    android:layout_centerInParent="true"
    android:layout_height="wrap_content"
    android:layout_width="match_parent" />
...
...
 


MainActivity.kt
 
...
...

class MainActivity : AppCompatActivity() 
{
    private lateinit var adView: AdView

    override fun onCreate(savedInstanceState: Bundle?)
    {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        MobileAds.initialize(this) { }

        // 程式執行時,取得當時的螢幕寬度
        // 再動態決定 adView 的尺寸
        adView = AdView(this)
        ad_view_container.addView(adView)
        loadBanner()

    }


    // 取得螢幕尺寸
    private val adSize: AdSize
        get()
        {
            val iScreenWidth = getScreenWidth()
            val fDensity = getDensity()
            val adWidth = (iScreenWidth / fDensity).toInt()
            return AdSize.getCurrentOrientationAnchoredAdaptiveBannerAdSize(this, adWidth)
        }
        
        
    private fun getScreenWidth(): Int {
        val wm = application.getSystemService(WINDOW_SERVICE) as WindowManager
        return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
            val windowMetrics = wm.currentWindowMetrics
            windowMetrics.bounds.width()
        } else {
            val displayMetrics = DisplayMetrics()
            wm.defaultDisplay.getMetrics(displayMetrics)
            displayMetrics.widthPixels
        }
    }

    private fun getDensity(): Float {
        return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
            val config: Configuration = application.resources.configuration
            config.densityDpi / 160f
        } else {
            val metrics = application.resources.displayMetrics
            metrics.density
        }
    }

    // 載入橫幅
    private fun loadBanner()
    {
        adView.adUnitId = getString(R.string.banner)
        // adView.adSize = adSize
        mAdView.setAdSize(adSize)
        val adRequest = AdRequest.Builder().build()
        adView.loadAd(adRequest)
    }
 


相關筆記 ----


2022-10-22

【Kotlin】偵測 Android 裝置的螢幕尺寸

 
...
...
    
when(this.resources.configuration.screenLayout and Configuration.SCREENLAYOUT_SIZE_MASK) {
    Configuration.SCREENLAYOUT_SIZE_SMALL -> Toast.makeText(this, "320x426", Toast.LENGTH_LONG).show()
    Configuration.SCREENLAYOUT_SIZE_NORMAL -> Toast.makeText(this, "320x470", Toast.LENGTH_LONG).show()
    Configuration.SCREENLAYOUT_SIZE_LARGE -> Toast.makeText(this, "480x640", Toast.LENGTH_LONG).show()
    Configuration.SCREENLAYOUT_SIZE_XLARGE -> Toast.makeText(this, "720x960", Toast.LENGTH_LONG).show()
    Configuration.SCREENLAYOUT_SIZE_UNDEFINED -> Toast.makeText(this, "undefined", Toast.LENGTH_LONG).show()
    else -> Toast.makeText(this, "I don't know", Toast.LENGTH_LONG).show()
}


...
...
 

手機


平板




2022-10-10

【Kotlin】原始/陽春/樸實的 banner 橫幅廣告

參考資料 ----

AdMob 一直在變更 banner 的樣式, Smart Banner 即將棄用,取而代之的是 Adaptive Banners ,而 Adaptive Banners 因為要對應 Java 新版本 API 的因素也一直在變更寫法。

如果 不想 或 懶得 採用最新的廣告樣式,可以考慮 原始/陽春/樸實 的橫幅廣告。(本筆記結合了視圖綁定)

app 層級 build.gradle
 
...
...

android {
    
    buildFeatures {
        viewBinding true
    }
    
    ...
    ...
    
}

...
...

dependencies {

    // adMob
    implementation 'com.google.android.gms:play-services-ads:21.2.0'
    
}
 


strings.xml
 
<resources>
    
    ...
    ...

    <!-- adMob app id 測試 -->
    <string name="admob_app_id">ca-app-pub-3940256099942544~3347511713</string>
    <!-- 橫幅 測試 -->
    <string name="banner_ad_unit_id">ca-app-pub-3940256099942544/6300978111</string>

</resources>
 


AndroidManifest.xml
 
<manifest

    <!-- 存取網路連線 -->
    <uses-permission android:name="android.permission.INTERNET" />

    ...
    ...

    <application  ... >

        <meta-data
            android:name="com.google.android.gms.version"
            android:value="@integer/google_play_services_version" />
        <meta-data
            android:name="com.google.android.gms.ads.APPLICATION_ID"
            android:value="@string/admob_app_id" />

        ...
        ...

        <!-- AdMob 的 AdActivity -->
        <activity
            android:name="com.google.android.gms.ads.AdActivity"
            android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|uiMode|screenSize|smallestScreenSize"
            android:theme="@android:style/Theme.Translucent" />

        ...
        ...

    </application>

</manifest>
 


activity_main.xml
 
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    xmlns:ads="http://schemas.android.com/apk/res-auto"  ←← 注意這行
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:paddingBottom="0dp"
    android:paddingLeft="0dp"
    android:paddingRight="0dp"
    android:paddingTop="0dp"
    tools:context=".MainActivity">
        
    <com.google.android.gms.ads.AdView
        android:id="@+id/adView"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_alignParentBottom="true"
        android:layout_alignParentStart="true"
        android:layout_alignParentEnd="true"
        ads:adSize="BANNER"
        ads:adUnitId="@string/banner_ad_unit_id" />

    <TextView
        android:id="@+id/txtHello"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignParentStart="true"
        android:layout_alignParentTop="true"
        android:layout_alignParentEnd="true"
        android:text="Hello World!" />

</RelativeLayout>
 
adSize  除了 BANNER 外,另有其他值可填入
大小 (寬 x 高)說明適用裝置AdSize 常值
320x50標準橫幅廣告手機和平板電腦BANNER
320x100大横幅手機和平板電腦LARGE_BANNER
300x250IAB 中矩形廣告手機和平板電腦MEDIUM_RECTANGLE
468x60IAB 完整橫幅廣告平板電腦FULL_BANNER
728x90IAB 超級橫幅廣告平板電腦LEADERBOARD

                           


MainActivity.kt
 
class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        val view = binding.root
        setContentView(view)

        binding.txtHello.text = "大家好"

        // AdMob 初始化
        MobileAds.initialize(this)
        binding.adView.loadAd(AdRequest.Builder().build())
    }
}
 

採用陽春型橫幅,缺點當然就是外觀顯示不夠漂亮,使用者體驗不好,因為尺寸都是固定的。

(手機直) BANNER 標準尺寸 320×50



(手機橫) BANNER 標準尺寸 320×50



大橫幅(LARGE_BANNER) 320×100



IAB 中矩形(MEDIUM_RECTANGLE) 300×250



平板 IAB 完整橫幅(FULL_BANNER) 468×60




平板(直) IAB 超級橫幅(LEADERBOARD) 728×90


平板(橫) IAB 超級橫幅(LEADERBOARD) 728×90



若希望不同尺寸螢幕的設備展示相應的橫幅,可以複製 activity_main.xml 到
/layout-w480dp/ 、 /layout-w740dp/ 並將 adSize 屬性對應不同的橫幅。


/layout-w480dp/
 
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
    ...
    ...
    tools:context=".MainActivity">
        
    <com.google.android.gms.ads.AdView
        android:id="@+id/adView"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_alignParentBottom="true"
        android:layout_alignParentStart="true"
        android:layout_alignParentEnd="true"
        ads:adSize="FULL_BANNER"
        ads:adUnitId="@string/banner_ad_unit_id" />

    ...
    ...

</RelativeLayout>
 


/layout-w740dp/
 
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
    ...
    ...
    tools:context=".MainActivity">
        
    <com.google.android.gms.ads.AdView
        android:id="@+id/adView"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_alignParentBottom="true"
        android:layout_alignParentStart="true"
        android:layout_alignParentEnd="true"
        ads:adSize="LEADERBOARD"
        ads:adUnitId="@string/banner_ad_unit_id" />

    ...
    ...

</RelativeLayout>
 


方法二:
想到...不然自己土砲打造 "類自適應橫幅" 好了...😁

activity_main.xml
 
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:paddingBottom="0dp"
    android:paddingLeft="0dp"
    android:paddingRight="0dp"
    android:paddingTop="0dp"
    tools:context=".MainActivity">

    <TextView
        android:id="@+id/txtHello"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignParentStart="true"
        android:layout_alignParentTop="true"
        android:layout_alignParentEnd="true"
        android:text="Hello World!" />

    <!-- 做為 自適應橫幅的 container -->
    <LinearLayout
        android:id="@+id/ad_view_container"
        android:layout_alignParentBottom="true"
        android:layout_centerInParent="true"
        android:layout_height="wrap_content"
        android:layout_width="match_parent"
        android:orientation="horizontal" />

</RelativeLayout>
 


MainActivity.kt
 

...
...

class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        val view = binding.root
        setContentView(view)

        binding.txtHello.text = "大家好"

        // AdMob 初始化
        MobileAds.initialize(this)

        // 橫幅廣告
        // 依螢幕尺寸變更橫幅的樣式
        val adView = AdView(this)
        when(this.resources.configuration.screenLayout and Configuration.SCREENLAYOUT_SIZE_MASK) {
            Configuration.SCREENLAYOUT_SIZE_XLARGE -> adView.setAdSize(AdSize.LEADERBOARD)
            Configuration.SCREENLAYOUT_SIZE_LARGE -> adView.setAdSize(AdSize.FULL_BANNER)
            Configuration.SCREENLAYOUT_SIZE_NORMAL -> adView.setAdSize(AdSize.LARGE_BANNER)
            else -> adView.setAdSize(AdSize.BANNER)
        }
        val lp = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT,LinearLayout.LayoutParams.MATCH_PARENT)
        lp.gravity = Gravity.CENTER
        adView.adUnitId = getString(R.string.banner_ad_unit_id)
        binding.adViewContainer.addView(adView,lp)     // 注意這裡 ad_view_container 的命名方式是 "駝峰式命名"
        adView.loadAd(AdRequest.Builder().build())

    }
}
 


相關筆記 ----