Android Notification

Android通知

通知有很多,例如

  • 普通通知
  • 前台通知
  • 悬浮通知
  • 锁屏通知

简单通知的使用

Android13+以上需要android.permission.POST_NOTIFICATIONS权限。

package edu.tyut.webviewlearn.ui.screen

import android.Manifest
import android.app.Notification
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import edu.tyut.webviewlearn.R
import edu.tyut.webviewlearn.ui.theme.RoundedCornerShape10
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

private const val TAG: String = "HelloScreen"

@Composable
internal fun NotifyScreen(
    modifier: Modifier,
    snackBarHostState: SnackbarHostState
){
    val context: Context = LocalContext.current
    val coroutineScope: CoroutineScope = rememberCoroutineScope()
    val launcher = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.RequestPermission()
    ) { isSuccess ->
        coroutineScope.launch {
            snackBarHostState.showSnackbar("权限获取是否成功: $isSuccess")
        }
    }
    Column(
        modifier = modifier.fillMaxSize()
    ) {
        Text(
            text = "发送通知",
            Modifier
                .padding(top = 10.dp)
                .background(color = Color.Black, shape = RoundedCornerShape10)
                .padding(all = 5.dp)
                .clickable {
                    if (ActivityCompat.checkSelfPermission(
                            context,
                            Manifest.permission.POST_NOTIFICATIONS
                        ) != PackageManager.PERMISSION_GRANTED
                    ) {
                        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
                            launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
                        }
                        return@clickable
                    }
                    val channelId = "channelId"
                    val notificationId = 0x7FFFFFFF
                    val notificationManager: NotificationManagerCompat = NotificationManagerCompat.from(context)
                    val channel: NotificationChannelCompat = NotificationChannelCompat.Builder(channelId, NotificationManagerCompat.IMPORTANCE_MAX)
                        .setName("HelloChannel")
                        .setDescription("HelloChannel 渠道描述")
                        .build()
                    notificationManager.createNotificationChannel(channel)
                    val notification: Notification = NotificationCompat.Builder(context, channelId)
                        .setSmallIcon(R.drawable.ic_launcher_background)
                        .setContentTitle("Title")
                        .setContentText("Hello World")
                        .build()
                    coroutineScope.launch {
                        delay(3000)
                        notificationManager.notify(notificationId, notification)
                    }
                },
            color = Color.White
        )
    }
}

channelName用户可以在设置里看到,

映射关系

Notification优先级 NotificationChannel重要性 行为描述
PRIORITY_MIN (-2) IMPORTANCE_MIN (1) 仅显示在通知抽屉,无声音/视觉干扰
PRIORITY_LOW (-1) IMPORTANCE_LOW (2) 显示通知但不会干扰用户
PRIORITY_DEFAULT (0) IMPORTANCE_DEFAULT (3) 默认显示,可能有声音但不会弹出
PRIORITY_HIGH (1) IMPORTANCE_HIGH (4) 弹出预览并可能有声音
PRIORITY_MAX (2) IMPORTANCE_MAX (5) 全屏显示(如来电)
  • 设置Notification的优先级是给Android8以下的设备使用的
  • 在Android 8.0+设备会自动将优先级映射到渠道重要性

前台服务

启动前台服务,并开启前台通知
1、需要权限如下

<!-- android 13 -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<!-- android 14 -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />

2、HelloService.kt

import android.app.Notification
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.lifecycle.LifecycleService
import edu.tyut.helloktorfit.R

private const val TAG: String = "HelloService"

internal class HelloService internal constructor() : LifecycleService() {
    internal companion object {
        internal fun getServiceIntent(context: Context): Intent {
            return Intent(context, HelloService::class.java)
        }
    }
    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        Log.i(TAG, "onStartCommand...")
        val channelId = "channelId"
        val notificationId = 0x0000_0001
        val notification: Notification = NotificationCompat.Builder(this, channelId)
            .setSmallIcon(R.drawable.ic_launcher_background)
            .setContentTitle("Hello 通知")
            .build()
        val notificationManager: NotificationManagerCompat = NotificationManagerCompat.from(this)
        val channel: NotificationChannelCompat = NotificationChannelCompat.Builder(channelId,
            NotificationManagerCompat.IMPORTANCE_MAX)
            .setName("Hello Channel")
            .build()
        notificationManager.createNotificationChannel(channel)
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            startForeground(notificationId, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK)
        } else {
            startForeground(notificationId, notification)
        }
        return super.onStartCommand(intent, flags, startId)
    }

    override fun onDestroy() {
        super.onDestroy()
        Log.i(TAG, "onDestroy...")
    }
}

3、注册服务

<service android:name=".service.HelloService"
		 android:foregroundServiceType="mediaPlayback"/>

4、启动服务

@Composable
internal fun ServiceScreen(
    navHostController: NavHostController,
    snackBarHostState: SnackbarHostState,
){
    val context: Context = LocalContext.current
    val coroutineScope: CoroutineScope = rememberCoroutineScope()
    val permissionLauncher = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.RequestPermission()
    ) {
        coroutineScope.launch {
            snackBarHostState.showSnackbar("获取权限${if (it) "成功" else "失败"}")
        }
    }
    Column(
        modifier = Modifier.fillMaxSize()
    ){
        Text(
            text = "创建通知",
            Modifier
                .padding(top = 10.dp)
                .background(color = Color.Black, shape = RoundedCornerShape10)
                .padding(all = 5.dp)
                .clickable {
                    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
                        if (ContextCompat.checkSelfPermission(context, android.Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED){
                            permissionLauncher.launch(android.Manifest.permission.POST_NOTIFICATIONS)
                            return@clickable
                        }
                    }
                    ContextCompat.startForegroundService(context, HelloService.getServiceIntent(context))
                },
            color = Color.White
        )
        Text(
            text = "断开Service",
            Modifier
                .padding(top = 10.dp)
                .background(color = Color.Black, shape = RoundedCornerShape10)
                .padding(all = 5.dp)
                .clickable {
                },
            color = Color.White
        )
    }
}

更新前台通知内容

package edu.tyut.helloktorfit.service

import android.app.Notification
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.PendingIntentCompat
import androidx.lifecycle.LifecycleService
import androidx.lifecycle.lifecycleScope
import edu.tyut.helloktorfit.R
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.random.Random

private const val TAG: String = "HelloService"
private const val CHANNEL_ID: String = "channelId"
private const val NOTIFICATION_ID: Int = 0x0000_0001
private const val REQUEST_CODE: Int = 0x0000_0002
private const val ACTION_UPDATE_KEY: String = "updateKey"
private const val ACTION_UPDATE: Int = 0x0000_0003

internal class HelloService internal constructor() : LifecycleService() {
    internal companion object {
        internal fun getServiceIntent(context: Context): Intent {
            return Intent(context, HelloService::class.java)
        }
    }
    private val notificationManager: NotificationManagerCompat by lazy {
        NotificationManagerCompat.from(this)
    }
    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        Log.i(TAG, "onStartCommand intent: $intent, flags: $flags, startId: $startId")
        if (intent?.getIntExtra(ACTION_UPDATE_KEY, 0) == ACTION_UPDATE){
            val notification: Notification = getNotification(content = "通知: ${Random.nextInt(until = 10000_0000)}")
            lifecycleScope.launch {
                delay(5000)
                updateNotification(notification)
            }
            return super.onStartCommand(intent, flags, startId)
        }
        val channel: NotificationChannelCompat = NotificationChannelCompat.Builder(CHANNEL_ID,
            NotificationManagerCompat.IMPORTANCE_MAX)
            .setName("Hello Channel")
            .build()
        notificationManager.createNotificationChannel(channel)
        val notification: Notification = getNotification(content = "Hello World")
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK)
        } else {
            startForeground(NOTIFICATION_ID, notification)
        }
        return super.onStartCommand(intent, flags, startId)
    }

    private fun getNotification(content: String): Notification {
        val updatePendingIntent: PendingIntent? =
            PendingIntentCompat.getService(
                this, REQUEST_CODE,
                getServiceIntent(this).putExtra(ACTION_UPDATE_KEY, ACTION_UPDATE), PendingIntent.FLAG_UPDATE_CURRENT, false
            )
        updatePendingIntent
        val notification: Notification = NotificationCompat.Builder(this, CHANNEL_ID)
            .setSmallIcon(R.drawable.ic_launcher_background)
            .setContentTitle("Hello 通知")
            .setContentText(content)
            .setOnlyAlertOnce(true)
            .addAction(0, "更新", updatePendingIntent)
            .build()
        return notification
    }

    private fun updateNotification(notification: Notification){
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK)
        } else {
            startForeground(NOTIFICATION_ID, notification)
        }
    }

    override fun onDestroy() {
        super.onDestroy()
        Log.i(TAG, "onDestroy...")
    }
}

文件下载案例

设置KtorFit

private const val TAG: String = "KtorFitModule"

@Module
@InstallIn(value = [SingletonComponent::class])
internal class KtorFitModule {

    @Provides
    @Singleton
    internal fun providerHelloService(ktorfit: Ktorfit): HelloService =
        ktorfit.createHelloService()

    @Provides
    @Singleton
    internal fun providerPhotoService(ktorfit: Ktorfit): PhotoService =
        ktorfit.createPhotoService()

    @Provides
    @Singleton
    internal fun providerHttpClient(): HttpClient {
        return HttpClient(engineFactory = OkHttp){
            engine {
                // addInterceptor {  }
                // addNetworkInterceptor {  }
            }
            install(plugin = ContentNegotiation) {
                json(json = Json {
                    isLenient = true
                    ignoreUnknownKeys = true
                    prettyPrint = true
                })
            }
            install(plugin = HttpTimeout) {
                connectTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS
                requestTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS
                socketTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS
            }
            install(plugin = DefaultRequest) {
                header(key = HttpHeaders.ContentType, value = ContentType.Application.Json)
                header(key = HttpHeaders.Accept, value = ContentType.Application.Json)
            }
            // 不支持流式数据
            // install(plugin = Logging){
            //     logger = Logger.ANDROID
            //     level = LogLevel.ALL
            // }
        }
    }

    @Provides
    @Singleton
    internal fun providerKtorFit(httpClient: HttpClient): Ktorfit {
        Log.i(TAG, "Engine: ${httpClient.engine::class.qualifiedName}")
        // return Ktorfit.Builder()
        //     .httpClient(httpClient)
        //     .baseUrl(Constants.BASE_URL)
        //     .build()
        return ktorfit {
            httpClient(client = httpClient)
            baseUrl(url = Constants.BASE_URL)
        }
    }
}

建议:
建议下载文件和请求使用不同的httpClient

HelloService.kt

import de.jensklingenberg.ktorfit.http.GET
import de.jensklingenberg.ktorfit.http.Query
import de.jensklingenberg.ktorfit.http.Streaming
import io.ktor.client.statement.HttpStatement

internal interface HelloService {
    @GET(value = "hello/download")
    @Streaming
    suspend fun download(@Query(value = "fileName") fileName: String): HttpStatement
}

Hello

import android.content.Context
import android.net.Uri
import android.util.Log
import edu.tyut.helloktorfit.data.bean.Person
import edu.tyut.helloktorfit.data.bean.Result
import edu.tyut.helloktorfit.data.bean.User
import edu.tyut.helloktorfit.data.remote.service.HelloService
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsChannel
import io.ktor.http.contentLength
import io.ktor.utils.io.ByteReadChannel
import io.ktor.utils.io.readAvailable
import jakarta.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.OutputStream
import javax.inject.Singleton

private const val TAG: String = "HelloRepository"

@Singleton
internal class HelloRepository @Inject internal constructor(
    private val helloService: HelloService
) {
    internal suspend fun download(context: Context, fileName: String, output: Uri, onProgress: (progress: Int) -> Unit = {}): Long = withContext(Dispatchers.IO) {
        val fileSize: Long? = helloService.download(fileName = fileName).execute { response: HttpResponse ->
                val totalSize: Long = response.contentLength() ?: 0L
                Log.i(TAG, "download -> totalSize: $totalSize")
                val byteReadChannel: ByteReadChannel = response.bodyAsChannel()
                context.contentResolver?.openOutputStream(output)?.use { outputStream: OutputStream ->
                    var downloadSize = 0L
                    var length = 0
                    val bytes = ByteArray(1024 * 8)
                    while (byteReadChannel.readAvailable(bytes).also { length = it } > 0){
                        downloadSize += length
                        outputStream.write(bytes, 0, length)
                        val progress: Int = (downloadSize / totalSize.toDouble() * 100).toInt()
                        onProgress(progress)
                        Log.i(TAG, "download -> progress: $progress, thread: ${Thread.currentThread()}")
                    }
                    downloadSize
                }
            }
            Log.i(TAG, "download -> fileSize: $fileSize")
            return@withContext fileSize ?: 0L
        }
}

四大组件的DownloadService

import android.app.Notification
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.util.Log
import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.PendingIntentCompat
import androidx.core.content.FileProvider
import androidx.core.graphics.drawable.IconCompat
import androidx.lifecycle.LifecycleService
import androidx.lifecycle.lifecycleScope
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import edu.tyut.helloktorfit.R
import edu.tyut.helloktorfit.data.remote.repository.HelloRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.io.File
import kotlin.random.Random

private const val TAG: String = "DownloadService"
private const val CHANNEL_ID = "downloadChannel"
private const val NOTIFICATION_ID: Int = 0x0000_0002
private const val REQUEST_CODE: Int = 0x0000_0002
private const val ACTION_UPDATE_KEY: String = "updateKey"
private const val ACTION_UPDATE: Int = 0x0000_0003

internal class DownloadService internal constructor() : LifecycleService() {
    internal companion object {
        internal fun getServiceIntent(context: Context): Intent {
            return Intent(context, DownloadService::class.java)
        }
    }

    private val notificationManager: NotificationManagerCompat by lazy {
        NotificationManagerCompat.from(this)
    }
    private val helloRepository: HelloRepository by lazy {
        EntryPointAccessors.fromApplication(
            context = this,
            entryPoint = DownloadServiceEntryPoint::class.java
        ).helloRepository()
    }

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        Log.i(TAG, "onStartCommand helloRepository: $helloRepository")
        if (intent?.getIntExtra(ACTION_UPDATE_KEY, 0) == ACTION_UPDATE) {
            val fileName = "新建文件夹.zip" // nacos-server-3.0.2.zip
            val output: Uri = FileProvider.getUriForFile(
                this, "${packageName}.provider", File(
                    Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
                    fileName
                )
            )
            lifecycleScope.launch {
                helloRepository.download(context = this@DownloadService, fileName = fileName, output = output){
                    val notification: Notification = createNotification(progress = it)
                    updateNotification(notification)
                }
            }
            // val notification: Notification = createNotification(progress = Random.nextInt(until = 100))
            // updateNotification(notification)
            // Log.i(TAG, "onStartCommand -> thread: ${Thread.currentThread()}")
            return super.onStartCommand(intent, flags, startId)
        }
        val channel: NotificationChannelCompat = NotificationChannelCompat.Builder(
            CHANNEL_ID,
            NotificationManagerCompat.IMPORTANCE_MAX
        )
            .setName("下载 Channel")
            .build()
        notificationManager.createNotificationChannel(channel)
        val notification: Notification = createNotification(progress = 0)
        updateNotification(notification)
        return super.onStartCommand(intent, flags, startId)
    }

    private fun createNotification(progress: Int): Notification {
        val updatePendingIntent: PendingIntent? =
            PendingIntentCompat.getService(
                this,
                REQUEST_CODE,
                getServiceIntent(this)
                    .putExtra(ACTION_UPDATE_KEY, ACTION_UPDATE),
                PendingIntent.FLAG_UPDATE_CURRENT,
                false
            )
        val notification: Notification = NotificationCompat.Builder(this, CHANNEL_ID)
            .setSmallIcon(R.drawable.ic_launcher_background)
            .setContentTitle("下载 通知")
            .setProgress(100, progress, false)
            .setOnlyAlertOnce(true)
            .addAction(
                NotificationCompat.Action.Builder(
                    IconCompat.createWithResource(
                        this,
                        R.drawable.ic_launcher_background
                    ), "下载更新", updatePendingIntent
                ).build()
            )
            .build()
        return notification
    }

    private fun updateNotification(notification: Notification) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            startForeground(
                NOTIFICATION_ID,
                notification,
                ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
            )
        } else {
            startForeground(NOTIFICATION_ID, notification)
        }
    }
}

@EntryPoint
@InstallIn(value = [SingletonComponent::class])
private interface DownloadServiceEntryPoint {
    fun helloRepository(): HelloRepository
}

权限和声明

<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />

<service android:name=".service.DownloadService"
		 android:foregroundServiceType="dataSync"/>

案例

使用 Androidx Media3 Library 实现一个音乐播放器.
1、依赖

// https://mvnrepository.com/artifact/androidx.media3/media3-exoplayer
implementation("androidx.media3:media3-exoplayer:1.7.1")
// https://mvnrepository.com/artifact/androidx.media3/media3-session
implementation("androidx.media3:media3-session:1.7.1")

2、声明权限

<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />

3、声明组件

<service android:name=".service.MusicService"
	android:exported="false"
	android:foregroundServiceType="mediaPlayback">
	<intent-filter>
		<action android:name="androidx.media3.session.MediaSessionService"/>
	</intent-filter>
</service>

4、MusicService.kt

package edu.tyut.helloktorfit.service

import android.app.Notification
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.Bundle
import android.util.Log
import android.widget.Toast
import androidx.annotation.OptIn
import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.PendingIntentCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService
import androidx.media3.session.MediaStyleNotificationHelper
import androidx.media3.session.SessionCommand
import androidx.media3.session.SessionResult
import com.google.common.util.concurrent.ListenableFuture
import edu.tyut.helloktorfit.R
import kotlinx.coroutines.coroutineScope

private const val TAG: String = "MusicService"
private const val CHANNEL_ID: String = "MusicChannelId"
private const val NOTIFICATION_ID: Int = 0x0000_0003
private const val REQUEST_CODE: Int = 0x0000_0003
private const val PLAY_PAUSE_ACTION: String = "playPauseAction"
private const val PREV_ACTION: String = "prevAction"
private const val NEXT_ACTION: String = "nextAction"

@UnstableApi
internal class MusicService internal constructor() : MediaSessionService() {


    internal companion object {
        internal fun getServiceIntent(context: Context): Intent {
            return Intent(context, MusicService::class.java)
        }
    }

    private val notificationManager: NotificationManagerCompat by lazy {
        NotificationManagerCompat.from(this)
    }

    private val player: ExoPlayer by lazy {
        val loadControl: DefaultLoadControl = DefaultLoadControl.Builder()
            .setBufferDurationsMs(
                5_000,  // 最小缓冲 5 秒
                15_000, // 最大缓冲 15 秒
                1_000,  // 播放前最小缓冲
                2_000   // 重新缓冲后重新开始播放前的最小缓冲时间
            )
            .setPrioritizeTimeOverSizeThresholds(true)
            .build()
        // zsh
        ExoPlayer.Builder(this)
            .setLoadControl(loadControl)
            .build()
    }

    private val mediaSessionCallback: MediaSession.Callback = @UnstableApi
    object : MediaSession.Callback {

        override fun onMediaButtonEvent(
            session: MediaSession,
            controllerInfo: MediaSession.ControllerInfo,
            intent: Intent
        ): Boolean {
            Log.i(TAG, "onMediaButtonEvent -> session: $session, controller: $controllerInfo, intent: $intent")
            return super.onMediaButtonEvent(session, controllerInfo, intent)
        }

        override fun onSetMediaItems(
            mediaSession: MediaSession,
            controller: MediaSession.ControllerInfo,
            mediaItems: List<MediaItem>,
            startIndex: Int,
            startPositionMs: Long
        ): ListenableFuture<MediaSession.MediaItemsWithStartPosition> {
            Log.i(TAG, "onSetMediaItems -> session: ${mediaSession.id}, controller: $controller, mediaItems: $mediaItems, startIndex: $startIndex, startPositionMs: $startPositionMs")
            return super.onSetMediaItems(
                mediaSession,
                controller,
                mediaItems,
                startIndex,
                startPositionMs
            )
        }

        override fun onConnect(
            session: MediaSession,
            controller: MediaSession.ControllerInfo
        ): MediaSession.ConnectionResult {
            Log.i(TAG, "onConnect -> session: ${session.id}, controller: $controller")
            return super.onConnect(session, controller)
        }

        override fun onCustomCommand(
            session: MediaSession,
            controller: MediaSession.ControllerInfo,
            customCommand: SessionCommand,
            args: Bundle
        ): ListenableFuture<SessionResult> {
            Log.i(TAG, "onCustomCommand -> session: ${session.id}, controller: $controller, customCommand: $customCommand, args: $args")
            return super.onCustomCommand(session, controller, customCommand, args)
        }
    }

    private val mediaSession: MediaSession by lazy {
        MediaSession.Builder(this, player)
            .setCallback(mediaSessionCallback)
            .build()
    }

    @OptIn(UnstableApi::class)
    override fun onCreate() {
        super.onCreate()
        Log.i(TAG, "onCreate...")
        player.addListener(object : Player.Listener {
            override fun onPlayerError(error: PlaybackException) {
                super.onPlayerError(error)
                Log.e(TAG, "onPlayerError -> error: ${error.message} ", error)
            }

            override fun onPlayerErrorChanged(error: PlaybackException?) {
                super.onPlayerErrorChanged(error)
                Log.e(TAG, "onPlayerErrorChanged -> error: ${error?.message}", error)
            }

            override fun onPlaybackStateChanged(playbackState: Int) {
                super.onPlaybackStateChanged(playbackState)
                when(playbackState){
                    Player.STATE_IDLE -> {
                        Log.i(TAG, "onPlaybackStateChanged -> 播放器空闲...")
                    }
                    Player.STATE_BUFFERING -> {
                        Log.i(TAG, "onPlaybackStateChanged -> 缓冲中...")
                    }
                    Player.STATE_READY -> {
                        Log.i(TAG, "onPlaybackStateChanged -> 准备就绪...")
                    }
                    Player.STATE_ENDED -> {
                        Log.i(TAG, "onPlaybackStateChanged -> 播放结束...")
                    }
                }
            }

            @OptIn(UnstableApi::class)
            override fun onIsPlayingChanged(isPlaying: Boolean) {
                super.onIsPlayingChanged(isPlaying)
                Log.i(TAG, "onIsPlayingChanged -> isPlaying: $isPlaying")
                updateNotification(createNotification())
                player.currentMediaItem?.mediaMetadata?.apply {
                    toBundle().keySet()?.forEach {
                        Log.i(TAG, "toBundle onIsPlayingChanged -> key: $it, value: ${toBundle().get(it)}")
                    }
                    extras?.keySet()?.forEach {
                        Log.i(TAG, "extras onIsPlayingChanged -> key: $it, value: ${extras?.get(it)}")
                    }
                }
            }
        })
    }

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        Log.i(TAG, "onStartCommand -> intent: $intent, flags: $flags, startId: $startId")
        if (intent?.action == PLAY_PAUSE_ACTION){
            Log.i(TAG, "onStartCommand play pause...")
            if (player.isPlaying){
                player.pause()
            } else {
                player.play()
            }
            val notification: Notification = createNotification()
            updateNotification(notification)
            return super.onStartCommand(intent, flags, startId)
        }
        if (intent?.action == PREV_ACTION){
            Log.i(TAG, "onStartCommand prev...")
            if (player.hasPreviousMediaItem()){
                player.seekToPreviousMediaItem()
            } else {
                Toast.makeText(this, "当前已是第一首歌曲", Toast.LENGTH_SHORT).show()
            }
            return super.onStartCommand(intent, flags, startId)
        }
        if (intent?.action == NEXT_ACTION){
            Log.i(TAG, "onStartCommand -> next...")
            if (player.hasNextMediaItem()){
                player.seekToNextMediaItem()
            }else {
                Toast.makeText(this, "当前已是最后一首歌曲", Toast.LENGTH_SHORT).show()
            }
            return super.onStartCommand(intent, flags, startId)
        }

        val channel: NotificationChannelCompat = NotificationChannelCompat.Builder(
            CHANNEL_ID,
            NotificationManagerCompat.IMPORTANCE_MAX
        )
            .setName("音频播放")
            .build()
        notificationManager.createNotificationChannel(channel)
        val notification: Notification = createNotification()
        updateNotification(notification)

        val mediaItems = listOf<MediaItem>(
            MediaItem.Builder().setUri("http://192.168.31.90:8080/audio1.flac").build(),
            MediaItem.Builder().setUri("http://192.168.31.90:8080/audio2.flac").build(),
            MediaItem.Builder().setUri("http://192.168.31.90:8080/audio3.ogg").setMediaMetadata(
                androidx.media3.common.MediaMetadata.Builder().setTitle("策马奔腾").setArtist("凤凰传奇").build()).build(),
            MediaItem.Builder().setUri("http://192.168.31.90:8080/audio4.ogg").build(),
            MediaItem.Builder().setUri("http://192.168.31.90:8080/audio5.ogg").build(),
            MediaItem.Builder().setUri("http://192.168.31.90:8080/audio6.ogg").build(),
        )
        player.setMediaItems(mediaItems)
        player.prepare()
        player.play()

        return super.onStartCommand(intent, flags, startId)
    }

    override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? {
        Log.i(TAG, "onGetSession -> controllerInfo: $controllerInfo")
        return mediaSession
    }

    @OptIn(UnstableApi::class)
    private fun createNotification(): Notification {
        val playPauseIntent: PendingIntent? = PendingIntentCompat.getService(this, REQUEST_CODE, getServiceIntent(this).setAction(PLAY_PAUSE_ACTION),
            PendingIntent.FLAG_UPDATE_CURRENT, false)
        val playPauseAction: NotificationCompat.Action
                = NotificationCompat.Action.Builder(IconCompat.createWithResource(this, if(player.isPlaying) android.R.drawable.ic_media_pause else android.R.drawable.ic_media_play), if(player.isPlaying) "暂停" else "播放", playPauseIntent).build()

        val prevIntent: PendingIntent? = PendingIntentCompat.getService(this, REQUEST_CODE, getServiceIntent(this).setAction(PREV_ACTION),
            PendingIntent.FLAG_UPDATE_CURRENT, false)
        val prevAction: NotificationCompat.Action
                = NotificationCompat.Action.Builder(IconCompat.createWithResource(this, android.R.drawable.ic_media_previous), "上一首", prevIntent).build()

        val nextIntent: PendingIntent? = PendingIntentCompat.getService(this, REQUEST_CODE, getServiceIntent(this).setAction(NEXT_ACTION),
            PendingIntent.FLAG_UPDATE_CURRENT, false)
        val nextAction: NotificationCompat.Action
                = NotificationCompat.Action.Builder(IconCompat.createWithResource(this, android.R.drawable.ic_media_next), "下一首", nextIntent).build()


        val mediaStyle = MediaStyleNotificationHelper.MediaStyle(mediaSession)
            .setShowActionsInCompactView(0, 1, 2)

        val title: String = player.currentMediaItem?.mediaMetadata?.title?.toString() ?: "未知歌曲"
        val artist: String = player.currentMediaItem?.mediaMetadata?.artist?.toString() ?: "未知作者"

        return NotificationCompat.Builder(this, CHANNEL_ID)
            .setSmallIcon(R.drawable.icon)
            .setStyle(mediaStyle)
            .setContentTitle(title)
            .setContentText(artist)
            .addAction(prevAction)
            .addAction(playPauseAction)
            .addAction(nextAction)
            .build()
    }

    private fun updateNotification(notification: Notification) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            startForeground(
                NOTIFICATION_ID,
                notification,
                ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
            )
        } else {
            startForeground(NOTIFICATION_ID, notification)
        }
    }

    override fun onDestroy() {
        super.onDestroy()
        mediaSession.run {
            release()
            player.release()
        }
        Log.i(TAG, "onDestroy...")
    }


    @OptIn(UnstableApi::class)
    override fun onTaskRemoved(rootIntent: Intent?) {
        super.onTaskRemoved(rootIntent)
        Log.i(TAG, "onTaskRemoved -> intent: $rootIntent")
        rootIntent?.extras?.keySet()?.forEach {
            Log.i(TAG, "onTaskRemoved -> key: $it, value: ${rootIntent.extras?.get(it)}")
        }
        pauseAllPlayersAndStopSelf()
    }

}

5、启动服务

package edu.tyut.helloktorfit.ui.screen

import android.app.Notification
import android.app.NotificationChannel
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.OptIn
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.media3.common.util.UnstableApi
import androidx.navigation.NavHostController
import edu.tyut.helloktorfit.R
import edu.tyut.helloktorfit.service.DownloadService
import edu.tyut.helloktorfit.service.HelloService
import edu.tyut.helloktorfit.service.MusicService
import edu.tyut.helloktorfit.ui.theme.RoundedCornerShape10
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

private const val TAG: String = "ServiceScreen"

@OptIn(UnstableApi::class)
@Composable
internal fun ServiceScreen(
    navHostController: NavHostController,
    snackBarHostState: SnackbarHostState,
){
    val context: Context = LocalContext.current
    val coroutineScope: CoroutineScope = rememberCoroutineScope()
    val permissionLauncher = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.RequestMultiplePermissions()
    ) { it ->
        coroutineScope.launch {
            snackBarHostState.showSnackbar("获取权限${if (it.values.all { it }) "成功" else "失败"}")
        }
    }
    Column(
        modifier = Modifier.fillMaxSize()
    ){
        Text(
            text = "创建通知",
            Modifier
                .padding(top = 10.dp)
                .background(color = Color.Black, shape = RoundedCornerShape10)
                .padding(all = 5.dp)
                .clickable {
                    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
                        if (ContextCompat.checkSelfPermission(context, android.Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED){
                            permissionLauncher.launch(arrayOf(android.Manifest.permission.POST_NOTIFICATIONS, android.Manifest.permission.FOREGROUND_SERVICE))
                            return@clickable
                        }
                    }
                    ContextCompat.startForegroundService(context, MusicService.getServiceIntent(context))
                },
            color = Color.White
        )
        Text(
            text = "断开Service",
            Modifier
                .padding(top = 10.dp)
                .background(color = Color.Black, shape = RoundedCornerShape10)
                .padding(all = 5.dp)
                .clickable {
                },
            color = Color.White
        )
    }
}

一般使用MediaController去控制ExoPlayer播放

posted @ 2025-07-07 23:00  爱情丶眨眼而去  阅读(12)  评论(0)    收藏  举报