Android之网络视频保存到相册


前言

Android保存网络视频到本地已经是很常见的事情了,刚好今天又遇到这个需求就记录一下,希望能帮到正在找的码友!


一、导入依赖

示例:一般项目都会有这些依赖的

dependencies {
    // OkHttp 网络请求库
    implementation 'com.squareup.okhttp3:okhttp:4.12.0'
    // 协程
    implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
    // 核心库
    implementation 'androidx.core:core-ktx:1.12.0'
    implementation 'androidx.appcompat:appcompat:1.6.1'
}

二、工具类

package com.hbxyy.teacherassistant.util
import android.content.ContentValues
import android.content.Context
import android.media.MediaScannerConnection
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import android.widget.Toast
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.OutputStream
import java.util.concurrent.TimeUnit
/**

 * Created by caoliulang.

 * Date: 2026/2/26

 * Description :

 */
object VideoDownloadUtil {
    // 初始化OkHttp客户端
    private val okHttpClient = OkHttpClient.Builder()
        .connectTimeout(30, TimeUnit.SECONDS)
        .readTimeout(60, TimeUnit.SECONDS)
        .writeTimeout(60, TimeUnit.SECONDS)
        .build()

    /**
     * 下载网络视频并保存到相册
     * @param context 上下文
     * @param videoUrl 视频网络地址
     * @param videoName 保存到相册的视频名称(不带后缀)
     * @param onComplete 完成回调(success: 是否成功, msg: 提示信息)
     */
    suspend fun downloadAndSaveToGallery(
        context: Context,
        videoUrl: String,
        videoName: String = "video_${System.currentTimeMillis()}",
        onComplete: (Boolean, String) -> Unit
    ) = withContext(Dispatchers.IO) {
        try {
            // 1. 检查Android 10以下的存储权限
            if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
                val hasPermission = context.checkSelfPermission(android.Manifest.permission.WRITE_EXTERNAL_STORAGE) ==
                        android.content.pm.PackageManager.PERMISSION_GRANTED
                if (!hasPermission) {
                    withContext(Dispatchers.Main) {
                        onComplete(false, "需要存储权限才能保存视频")
                    }
                    return@withContext
                }
            }

            // 2. 下载视频到临时目录
            val tempFile = downloadVideoToTempDir(context, videoUrl) ?: run {
                withContext(Dispatchers.Main) {
                    onComplete(false, "视频下载失败")
                }
                return@withContext
            }

            // 3. 保存临时文件到相册
            val saveSuccess = saveTempVideoToGallery(context, tempFile, videoName)

            // 4. 删除临时文件(释放空间)
            if (tempFile.exists()) {
                tempFile.delete()
            }

            // 5. 回调结果
            withContext(Dispatchers.Main) {
                val msg = if (saveSuccess) "视频保存到相册成功" else "视频保存到相册失败"
                onComplete(saveSuccess, msg)
            }

        } catch (e: Exception) {
            e.printStackTrace()
            withContext(Dispatchers.Main) {
                onComplete(false, "处理失败:${e.message ?: "未知错误"}")
            }
        }
    }

    /**
     * 下载视频到APP私有临时目录
     */
    private fun downloadVideoToTempDir(context: Context, videoUrl: String): File? {
        return try {
            // 创建临时文件(APP私有目录,无需权限)
            val tempFile = File(context.cacheDir, "temp_${System.currentTimeMillis()}.mp4")
            val request = Request.Builder().url(videoUrl).build()
            val response = okHttpClient.newCall(request).execute()

            if (!response.isSuccessful || response.body() == null) {
                return null
            }

            // 将响应体写入临时文件
            response.body()!!.byteStream().use { inputStream ->
                FileOutputStream(tempFile).use { outputStream ->
                    inputStream.copyTo(outputStream)
                }
            }

            // 检查文件是否有效
            if (tempFile.length() <= 0) {
                tempFile.delete()
                return null
            }
            tempFile
        } catch (e: Exception) {
            e.printStackTrace()
            null
        }
    }

    /**
     * 将临时视频文件保存到相册
     */
    private fun saveTempVideoToGallery(context: Context, tempFile: File, videoName: String): Boolean {
        return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            // Android 10+ 使用MediaStore
            saveToGalleryQ(context, tempFile, videoName)
        } else {
            // Android 10以下 传统方式
            saveToGalleryLegacy(context, tempFile, videoName)
        }
    }

    /**
     * Android 10+ 保存逻辑
     */
    private fun saveToGalleryQ(context: Context, tempFile: File, videoName: String): Boolean {
        val contentValues = ContentValues().apply {
            put(MediaStore.Video.Media.DISPLAY_NAME, "$videoName.mp4")
            put(MediaStore.Video.Media.MIME_TYPE, "video/mp4")
            put(MediaStore.Video.Media.RELATIVE_PATH, Environment.DIRECTORY_MOVIES)
            put(MediaStore.Video.Media.SIZE, tempFile.length())
        }

        val resolver = context.contentResolver
        val uri = resolver.insert(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, contentValues) ?: return false

        return try {
            // 复制文件内容到相册
            resolver.openOutputStream(uri)?.use { outputStream ->
                FileInputStream(tempFile).use { inputStream ->
                    inputStream.copyTo(outputStream)
                }
            }
            true
        } catch (e: Exception) {
            resolver.delete(uri, null, null) // 失败时删除空记录
            false
        }
    }

    /**
     * Android 10以下 保存逻辑
     */
    private fun saveToGalleryLegacy(context: Context, tempFile: File, videoName: String): Boolean {
        // 相册视频目录
        val galleryDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES)
        if (!galleryDir.exists()) galleryDir.mkdirs()

        val targetFile = File(galleryDir, "$videoName.mp4")
        if (targetFile.exists()) targetFile.delete()

        return try {
            // 复制文件
            FileInputStream(tempFile).use { input ->
                FileOutputStream(targetFile).use { output ->
                    input.copyTo(output)
                }
            }

            // 通知媒体库扫描,确保相册显示
            MediaScannerConnection.scanFile(
                context,
                arrayOf(targetFile.absolutePath),
                arrayOf("video/mp4")
            ) { _, _ -> }
            true
        } catch (e: Exception) {
            false
        }
    }
}

三、调用

private val REQUEST_STORAGE_PERMISSION = 1001
//调用
 if (intent.getStringExtra("url").toString().isEmpty()) {
                        ToastUtils.showToast("视频地址有问题不支持下载")
                        return
                    }

                    // Android 10以下先申请权限
                    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
                        if (ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE)
                            != PackageManager.PERMISSION_GRANTED
                        ) {
                            ActivityCompat.requestPermissions(
                                this,
                                arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE),
                                REQUEST_STORAGE_PERMISSION
                            )
                            return
                        }
                    }
                    // 执行下载和保存
                    downloadVideo(intent.getStringExtra("url").toString())
 /**
     * 下载并保存视频
     */
    private fun downloadVideo(videoUrl: String) {
        CoroutineScope(Dispatchers.Main).launch {
            DialogUtils1.showLoadingDialog(this@WorkLibraryDetails)

            VideoDownloadUtil.downloadAndSaveToGallery(
                context = this@WorkLibraryDetails,
                videoUrl = videoUrl,
                videoName = "my_download_video"
            ) { success, msg ->
                ToastUtils.showToast("视频已下载到相册!")
                DialogUtils1.hideLoadingDialog()
            }
        }
    }
    /**
     * 权限申请结果回调
     */
    override fun onRequestPermissionsResult(
        requestCode: Int,
        permissions: Array<out String>,
        grantResults: IntArray
    ) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults)
        if (requestCode == REQUEST_STORAGE_PERMISSION) {
            if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
                // 权限申请成功,执行下载
                if (intent.getStringExtra("url").toString().isNotEmpty()) {
                    downloadVideo(intent.getStringExtra("url").toString())
                }
            } else {
                ToastUtils.showToast("拒绝存储权限将无法保存视频")
            }
        }
    }

四、权限配置

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <!-- 网络权限 -->
    <uses-permission android:name="android.permission.INTERNET" />
    <!-- Android 10以下存储权限 -->
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
        android:maxSdkVersion="28" />
    <!-- Android 13+ 读取媒体权限(可选) -->
    <uses-permission android:name="android.permission.READ_MEDIA_VIDEO"
        android:minSdkVersion="33" />

    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.AppCompat.Light.DarkActionBar">
        <activity
            android:name=".MainActivity"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>
</manifest>

总结

方法就是那么简单,已经避开很多坑了,放心使用,如有问题欢迎指导!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

叶已初秋

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值