Browse Source

预览界面增加保存以及分享功能

zk 1 year ago
parent
commit
5095f99923

+ 10 - 0
app/src/main/AndroidManifest.xml

@@ -48,6 +48,16 @@
         <activity
             android:name=".module.result.VoiceResultActivity"
             android:screenOrientation="portrait" />
+
+        <provider
+            android:name="androidx.core.content.FileProvider"
+            android:authorities="${applicationId}.fileprovider"
+            android:exported="false"
+            android:grantUriPermissions="true">
+            <meta-data
+                android:name="android.support.FILE_PROVIDER_PATHS"
+                android:resource="@xml/atmob_filepath" />
+        </provider>
     </application>
 
 

+ 6 - 6
app/src/main/java/com/atmob/voiceai/data/api/request/TextTosSpeechRequest.java

@@ -8,21 +8,21 @@ public class TextTosSpeechRequest extends BaseRequest {
     private int id;
 
 
-    @SerializedName("type")
-    private int type;
+    @SerializedName("voiceType")
+    private int voiceType;
 
     @SerializedName("content")
     private String content;
 
 
-    public TextTosSpeechRequest(int id, int type, String content) {
+    public TextTosSpeechRequest(int id, int voiceType, String content) {
         this.id = id;
-        this.type = type;
+        this.voiceType = voiceType;
         this.content = content;
     }
 
-    public int getType() {
-        return type;
+    public int getVoiceType() {
+        return voiceType;
     }
 
     public int getId() {

+ 37 - 37
app/src/main/java/com/atmob/voiceai/data/repositories/VoiceAIRepository.java

@@ -115,45 +115,45 @@ public class VoiceAIRepository {
         }
         requestCloneBean = bean;
         textToSpeechTxt = content;
-        /********仅测试使用******/
-        textToSpeechState.setValue(new Pair<>(TextToSpeechState.GENERATED, null));
-        UserVoiceBean userVoiceBean = new UserVoiceBean();
-        userVoiceBean.setVoiceAvatar(bean.getAvatarUrl());
-        userVoiceBean.setVoiceId(bean.getId());
-        userVoiceBean.setVoiceName(bean.getName());
-        userVoiceBean.setContent(content);
-        userVoiceBean.setVoiceUrl(bean.getVoiceUrl());
-        resultBean.setValue(userVoiceBean);
-        /********仅测试使用******/
-//        textToSpeech(bean.getId(), bean.getVoiceType(), content).subscribe(new SingleObserver<TextToSpeechResponse>() {
-//            @Override
-//            public void onSubscribe(@NonNull Disposable d) {
-//                textToSpeechRequest = true;
-//                textToSpeechState.setValue(new Pair<>(TextToSpeechState.GENERATING, null));
-//            }
-//
-//            @Override
-//            public void onSuccess(@NonNull TextToSpeechResponse textToSpeechResponse) {
-//                textToSpeechRequest = false;
-//                resultBean.setValue(textToSpeechResponse.getUserVoice());
-//                textToSpeechState.setValue(new Pair<>(TextToSpeechState.GENERATED, null));
-//            }
-//
-//            @Override
-//            public void onError(@NonNull Throwable throwable) {
-//                textToSpeechRequest = false;
-//                if (throwable instanceof RxHttpHandler.ServerErrorException) {
-//                    RxHttpHandler.ServerErrorException serverErrorException = (RxHttpHandler.ServerErrorException) throwable;
-//                    textToSpeechState.setValue(new Pair<>(TextToSpeechState.ERROR, serverErrorException.getMsg()));
-//                } else {
-//                    textToSpeechState.setValue(new Pair<>(TextToSpeechState.ERROR, ContextUtil.getContext().getString(R.string.generate_error)));
-//                }
-//            }
-//        });
+//        /********仅测试使用******/
+//        textToSpeechState.setValue(new Pair<>(TextToSpeechState.GENERATED, null));
+//        UserVoiceBean userVoiceBean = new UserVoiceBean();
+//        userVoiceBean.setVoiceAvatar(bean.getAvatarUrl());
+//        userVoiceBean.setVoiceId(bean.getId());
+//        userVoiceBean.setVoiceName(bean.getName());
+//        userVoiceBean.setContent(content);
+//        userVoiceBean.setVoiceUrl("http://cdn.atmob.com/upload/project_voice/text_to_speech/20240510/986334800900366336.mp3");
+//        resultBean.setValue(userVoiceBean);
+//        /********仅测试使用******/
+        textToSpeech(bean.getId(), bean.getVoiceType(), content).subscribe(new SingleObserver<TextToSpeechResponse>() {
+            @Override
+            public void onSubscribe(@NonNull Disposable d) {
+                textToSpeechRequest = true;
+                textToSpeechState.setValue(new Pair<>(TextToSpeechState.GENERATING, null));
+            }
+
+            @Override
+            public void onSuccess(@NonNull TextToSpeechResponse textToSpeechResponse) {
+                textToSpeechRequest = false;
+                resultBean.setValue(textToSpeechResponse.getUserVoice());
+                textToSpeechState.setValue(new Pair<>(TextToSpeechState.GENERATED, null));
+            }
+
+            @Override
+            public void onError(@NonNull Throwable throwable) {
+                textToSpeechRequest = false;
+                if (throwable instanceof RxHttpHandler.ServerErrorException) {
+                    RxHttpHandler.ServerErrorException serverErrorException = (RxHttpHandler.ServerErrorException) throwable;
+                    textToSpeechState.setValue(new Pair<>(TextToSpeechState.ERROR, serverErrorException.getMsg()));
+                } else {
+                    textToSpeechState.setValue(new Pair<>(TextToSpeechState.ERROR, ContextUtil.getContext().getString(R.string.generate_error)));
+                }
+            }
+        });
     }
 
-    private Single<TextToSpeechResponse> textToSpeech(int id, int type, String content) {
-        return generateApi.textToSpeech(new TextTosSpeechRequest(id, type, content))
+    private Single<TextToSpeechResponse> textToSpeech(int id, int voiceType, String content) {
+        return generateApi.textToSpeech(new TextTosSpeechRequest(id, voiceType, content))
                 .compose(RxHttpHandler.handle(true))
                 .compose(RxJavaUtil.SingleSchedule.io2Main());
     }

+ 10 - 0
app/src/main/java/com/atmob/voiceai/di/NetworkModule.java

@@ -55,4 +55,14 @@ public class NetworkModule {
                 .create(GenerateApi.class);
     }
 
+    @Singleton
+    @Provides
+    public static OkHttpClient downloadFileHttpClient() {
+        return new OkHttpClient().newBuilder()
+                .connectTimeout(60, TimeUnit.SECONDS)
+                .readTimeout(120, TimeUnit.SECONDS)
+                .writeTimeout(120, TimeUnit.SECONDS)
+                .build();
+    }
+
 }

+ 0 - 12
app/src/main/java/com/atmob/voiceai/helper/VoicePlayHelper.java

@@ -1,12 +0,0 @@
-package com.atmob.voiceai.helper;
-
-
-import androidx.annotation.NonNull;
-
-public class VoicePlayHelper {
-
-
-    private static String getFileName(@NonNull String url) {
-        return url.substring(url.lastIndexOf("/") + 1);
-    }
-}

+ 97 - 1
app/src/main/java/com/atmob/voiceai/module/result/VoiceResultViewModel.java

@@ -5,21 +5,35 @@ import androidx.lifecycle.MutableLiveData;
 
 import com.atmob.app.lib.base.BaseViewModel;
 import com.atmob.app.lib.livedata.SingleLiveEvent;
+import com.atmob.common.logging.AtmobLog;
 import com.atmob.common.runtime.ActivityUtil;
+import com.atmob.common.runtime.ContextUtil;
+import com.atmob.voiceai.R;
 import com.atmob.voiceai.data.api.bean.UserVoiceBean;
 import com.atmob.voiceai.data.api.bean.VoiceListBean;
 import com.atmob.voiceai.data.api.response.VoiceListResponse;
 import com.atmob.voiceai.data.repositories.VoiceAIRepository;
+import com.atmob.voiceai.utils.DateUtil;
+import com.atmob.voiceai.utils.MediaStoreHelper;
+import com.atmob.voiceai.utils.ShareUtils;
+import com.atmob.voiceai.utils.ToastUtil;
+import com.atmob.voiceai.utils.VoiceFileUtil;
 
+import java.io.File;
 import java.util.List;
+import java.util.Random;
 import java.util.Timer;
 import java.util.TimerTask;
+import java.util.UUID;
 
 import javax.inject.Inject;
 
+import atmob.okhttp3.OkHttpClient;
 import atmob.reactivex.rxjava3.annotations.NonNull;
 import atmob.reactivex.rxjava3.core.SingleObserver;
+import atmob.reactivex.rxjava3.core.SingleSource;
 import atmob.reactivex.rxjava3.disposables.Disposable;
+import atmob.reactivex.rxjava3.functions.Function;
 import dagger.hilt.android.lifecycle.HiltViewModel;
 
 @HiltViewModel
@@ -33,12 +47,16 @@ public class VoiceResultViewModel extends BaseViewModel {
     private final MutableLiveData<Long> currentDuration = new MutableLiveData<>();
     private final SingleLiveEvent<?> refreshAudioCurrentProgress = new SingleLiveEvent<>();
     private final MutableLiveData<List<VoiceListBean>> voiceList = new MutableLiveData<>();
+    private final OkHttpClient okHttpClient;
     private boolean isSeekbarChanging;
     private Timer timer;
 
+    private boolean downloadFileDisposable;
+
     @Inject
-    public VoiceResultViewModel(VoiceAIRepository voiceAIRepository) {
+    public VoiceResultViewModel(VoiceAIRepository voiceAIRepository, OkHttpClient okHttpClient) {
         this.voiceAIRepository = voiceAIRepository;
+        this.okHttpClient = okHttpClient;
         refreshVoiceRecommendList();
     }
 
@@ -134,4 +152,82 @@ public class VoiceResultViewModel extends BaseViewModel {
         voiceAIRepository.setRecommendClickBean(voiceListBean);
     }
 
+
+    public void onSaveClick() {
+        UserVoiceBean userVoiceBean = getResultBean().getValue();
+        if (userVoiceBean == null) {
+            return;
+        }
+        if (downloadFileDisposable) {
+            return;
+        }
+        VoiceFileUtil.getVoiceFile(okHttpClient, userVoiceBean.getVoiceUrl())
+                .map(file -> {
+                    MediaStoreHelper.saveToSharedStorage(MediaStoreHelper.TYPE_AUDIO, file, getVoiceFileName());
+                    return file;
+                })
+                .subscribe(new SingleObserver<File>() {
+                    @Override
+                    public void onSubscribe(@NonNull Disposable d) {
+                        addDisposable(d);
+                        downloadFileDisposable = true;
+                    }
+
+                    @Override
+                    public void onSuccess(@NonNull File file) {
+                        downloadFileDisposable = false;
+                        ToastUtil.show(R.string.voice_save_success, ToastUtil.LENGTH_SHORT);
+                        AtmobLog.d("VoiceResultViewModel", "onSuccess: " + file.getAbsolutePath());
+                    }
+
+                    @Override
+                    public void onError(@NonNull Throwable e) {
+                        downloadFileDisposable = false;
+                        ToastUtil.show(R.string.voice_save_error, ToastUtil.LENGTH_SHORT);
+                        AtmobLog.d("VoiceResultViewModel", "onError: " + e.getMessage());
+                    }
+                });
+    }
+
+    private String getVoiceFileName() {
+        //月日年时分毫秒+3位随机数
+        return "VoiceAI_" + DateUtil.formatNormalDate("ddMMyyyyHHmmssSSS", System.currentTimeMillis()) + (new Random().nextInt(900) + 100) + ".mp3";
+    }
+
+    public void onShareClick() {
+        UserVoiceBean userVoiceBean = getResultBean().getValue();
+        if (userVoiceBean == null) {
+            return;
+        }
+        if (downloadFileDisposable) {
+            return;
+        }
+        VoiceFileUtil.getVoiceFile(okHttpClient, userVoiceBean.getVoiceUrl())
+                .map(file -> {
+                    ShareUtils.shareAudioFile(ActivityUtil.getTopActivity(), file);
+                    return file;
+                })
+                .subscribe(new SingleObserver<File>() {
+                    @Override
+                    public void onSubscribe(@NonNull Disposable d) {
+                        addDisposable(d);
+                        downloadFileDisposable = true;
+                    }
+
+                    @Override
+                    public void onSuccess(@NonNull File file) {
+                        downloadFileDisposable = false;
+                        AtmobLog.d("VoiceResultViewModel", "Share-onSuccess: " + file.getAbsolutePath());
+                    }
+
+                    @Override
+                    public void onError(@NonNull Throwable e) {
+                        downloadFileDisposable = false;
+                        ToastUtil.show(R.string.voice_file_get_error, ToastUtil.LENGTH_SHORT);
+                        AtmobLog.d("VoiceResultViewModel", "Share-onError: " + e.getMessage());
+                    }
+                });
+    }
+
+
 }

+ 5 - 0
app/src/main/java/com/atmob/voiceai/module/voiceai/VoiceAIFragment.java

@@ -105,6 +105,7 @@ public class VoiceAIFragment extends BaseFragment<FragmentVoiceAiBinding> {
         initVoiceList();
     }
 
+
     private void initExoPlayer() {
         player = new ExoPlayer.Builder(requireContext()).build();
     }
@@ -212,6 +213,10 @@ public class VoiceAIFragment extends BaseFragment<FragmentVoiceAiBinding> {
     @Override
     public void onStop() {
         super.onStop();
+        if (player != null) {
+            player.stop();
+            voiceViewModel.setVoicePlayEnd();
+        }
         voiceViewModel.setLastVoiceGenerateTxt(binding.etVoicePrint.getText() == null ? "" : binding.etVoicePrint.getText().toString());
     }
 

+ 12 - 0
app/src/main/java/com/atmob/voiceai/utils/DateUtil.java

@@ -1,5 +1,7 @@
 package com.atmob.voiceai.utils;
 
+import java.text.SimpleDateFormat;
+import java.util.Date;
 import java.util.Locale;
 
 public class DateUtil {
@@ -8,10 +10,20 @@ public class DateUtil {
 
     }
 
+
     public static String formatDuration(long duration) {
         long seconds = duration / 1000;
         long minutes = seconds / 60;
         seconds = seconds % 60;
         return String.format(Locale.getDefault(), "%02d:%02d", minutes, seconds);
     }
+
+    public static String formatNormalDate(String format, long timestamp) {
+        if (timestamp == 0) {
+            return "";
+        }
+        Date date = new Date(timestamp);
+        SimpleDateFormat dateFormat = new SimpleDateFormat(format, Locale.getDefault());
+        return dateFormat.format(date);
+    }
 }

+ 80 - 0
app/src/main/java/com/atmob/voiceai/utils/DownloadUtils.java

@@ -0,0 +1,80 @@
+package com.atmob.voiceai.utils;
+
+import android.os.Environment;
+
+import com.atmob.common.runtime.ContextUtil;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.Objects;
+
+import atmob.okhttp3.OkHttpClient;
+import atmob.okhttp3.Request;
+import atmob.okhttp3.Response;
+import atmob.okhttp3.ResponseBody;
+import atmob.reactivex.rxjava3.core.Observable;
+import atmob.reactivex.rxjava3.core.ObservableOnSubscribe;
+import atmob.reactivex.rxjava3.schedulers.Schedulers;
+
+public class DownloadUtils {
+
+    public static File voiceFile = getVoiceSaveRootFile();
+
+    private DownloadUtils() {
+
+    }
+
+    private static File getVoiceSaveRootFile() {
+        File cacheDir;
+        if (Objects.equals(Environment.getExternalStorageState(), Environment.MEDIA_MOUNTED)
+                && Environment.getExternalStorageDirectory().canWrite()) {
+            cacheDir = ContextUtil.getApplication().getExternalCacheDir();
+        } else {
+            cacheDir = ContextUtil.getApplication().getCacheDir();
+        }
+        return cacheDir;
+    }
+
+    public static void downLoad(String url, final File rootFile, final String fileName,
+                                final FileDownLoadObserver<File> fileDownLoadObserver) {
+        downLoad(new OkHttpClient(), url, rootFile, fileName, fileDownLoadObserver);
+    }
+
+    public static void downLoad(OkHttpClient client, String url, final File rootFile, final String fileName,
+                                final FileDownLoadObserver<File> fileDownLoadObserver) {
+        Observable.create((ObservableOnSubscribe<ResponseBody>) emitter -> {
+                    Request request = new Request.Builder()
+                            .url(url)
+                            .build();
+                    try (Response response = client.newCall(request).execute()) {
+                        if (!response.isSuccessful()) {
+                            emitter.onError(new IOException("Failed to download audio: " + response));
+                            return;
+                        }
+                        ResponseBody body = response.body();
+                        if (body == null) {
+                            emitter.onError(new IOException("Response body is null"));
+                            return;
+                        }
+//                        AtmobLog.d("zk", String.format("文件类型为%s,文件大小为%d", response.body().contentType().toString(), response.body().contentLength()));
+                        emitter.onNext(body);
+                        emitter.onComplete();
+                    } catch (IOException e) {
+                        emitter.onError(e);
+                    }
+                })
+                .map(responseBody -> fileDownLoadObserver.saveFile(responseBody,
+                        rootFile.getPath(), fileName))
+                .subscribeOn(Schedulers.io())
+                .observeOn(Schedulers.io())
+                .map(file -> {
+                    if (file == null) {
+                        throw new IOException("Failed to save file");
+                    }
+                    return file;
+                })
+                .subscribe(fileDownLoadObserver);
+    }
+
+
+}

+ 86 - 0
app/src/main/java/com/atmob/voiceai/utils/FileDownLoadObserver.java

@@ -0,0 +1,86 @@
+package com.atmob.voiceai.utils;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+
+import atmob.okhttp3.ResponseBody;
+import atmob.reactivex.rxjava3.observers.DefaultObserver;
+
+public abstract class FileDownLoadObserver<T> extends DefaultObserver<T> {
+
+    public abstract void onDownLoadStart();
+
+    //下载成功的回调
+    public abstract void onDownLoadSuccess(T t);
+
+    //下载失败回调
+    public abstract void onDownLoadFail(Throwable throwable);
+
+
+    @Override
+    protected void onStart() {
+        super.onStart();
+        onDownLoadStart();
+    }
+
+    @Override
+    public void onNext(T t) {
+        onDownLoadSuccess(t);
+    }
+
+    @Override
+    public void onError(Throwable e) {
+        onDownLoadFail(e);
+    }
+
+    @Override
+    public void onComplete() {
+
+    }
+
+    private void copyStream(InputStream is, OutputStream os) throws Exception {
+        byte[] buf = new byte[2048];
+        int n;
+        while ((n = is.read(buf)) > 0) {
+            os.write(buf, 0, n);
+        }
+        os.flush();
+    }
+
+    public File saveFile(ResponseBody responseBody, String destFileDir, String destFileName)
+            throws IOException {
+        InputStream is = null;
+        FileOutputStream fos = null;
+        File file = null;
+        try {
+            is = responseBody.byteStream();
+            File dir = new File(destFileDir);
+            if (!dir.exists()) {
+                dir.mkdirs();
+            }
+            file = new File(dir, destFileName);
+            fos = new FileOutputStream(file);
+            copyStream(is, fos);
+            return file;
+        } catch (Exception e) {
+            if (file != null) {
+                file.delete();
+            }
+        } finally {
+            try {
+                if (is != null) is.close();
+            } catch (IOException e) {
+                e.printStackTrace();
+            }
+            try {
+                if (fos != null) fos.close();
+            } catch (IOException e) {
+                e.printStackTrace();
+            }
+        }
+        return file;
+    }
+}

+ 238 - 0
app/src/main/java/com/atmob/voiceai/utils/MediaStoreHelper.java

@@ -0,0 +1,238 @@
+package com.atmob.voiceai.utils;
+
+import android.content.ContentResolver;
+import android.content.ContentValues;
+import android.content.Context;
+import android.net.Uri;
+import android.os.Build;
+import android.os.ParcelFileDescriptor;
+import android.provider.MediaStore;
+
+import androidx.annotation.IntDef;
+import androidx.annotation.RequiresApi;
+
+import com.atmob.common.runtime.ContextUtil;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.InputStream;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+public class MediaStoreHelper {
+
+
+    public static final int TYPE_IMAGE = 1;
+
+    public static final int TYPE_VIDEO = 2;
+
+    public static final int TYPE_AUDIO = 3;
+
+    public static final int TYPE_FILES = 4;
+
+    public static final int TYPE_DOWNLOADS = 5;
+
+    @Retention(RetentionPolicy.SOURCE)
+    @IntDef({TYPE_IMAGE, TYPE_VIDEO, TYPE_AUDIO, TYPE_FILES, TYPE_DOWNLOADS})
+    @interface MediaType {
+    }
+
+
+    public static void saveToSharedStorage(@MediaType int mediaType, File file, String fileName) throws Exception {
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+            saveToSharedStorage(mediaType, java.nio.file.Files.newInputStream(file.toPath()), fileName);
+        } else {
+            saveToSharedStorage(mediaType, new FileInputStream(file), fileName);
+        }
+    }
+
+    public static void saveToSharedStorage(@MediaType int mediaType, InputStream inputStream, String fileName) throws Exception {
+        // Add a media item that other apps don't see until the item is
+        // fully written to the media store.
+        Context applicationContext = ContextUtil.getContext().getApplicationContext();
+
+        ContentResolver resolver = applicationContext.getContentResolver();
+
+        Media targetMedia = getTargetMedia(mediaType);
+
+        // Find all media files on the primary external storage device.
+        Uri mediaCollection = targetMedia.getMediaCollection();
+
+        ContentValues mediaDetails = new ContentValues();
+        mediaDetails.put(targetMedia.displayName(), fileName);
+
+        // lock media file until it's fully written.
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+            mediaDetails.put(targetMedia.isPending(), 1);
+        }
+
+        Uri mediaContentUri = resolver.insert(mediaCollection, mediaDetails);
+
+        if (mediaContentUri == null) {
+            throw new IllegalStateException("Failed to create new media item.");
+        }
+
+        // "w" for write.
+        try (ParcelFileDescriptor pfd =
+                     resolver.openFileDescriptor(mediaContentUri, "w", null);
+             InputStream is = inputStream;
+             FileOutputStream fos = pfd == null ? null : new FileOutputStream(pfd.getFileDescriptor())
+        ) {
+            if (fos == null) {
+                throw new IllegalStateException("Failed to open new media item.");
+            }
+            // Write data into the pending media file.
+            byte[] buf = new byte[8192];
+            int len;
+            while ((len = is.read(buf)) > 0) {
+                fos.write(buf, 0, len);
+            }
+            fos.flush();
+        } finally {
+            // finished, release the "pending" status.
+            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+                mediaDetails.clear();
+                mediaDetails.put(targetMedia.isPending(), 0);
+                resolver.update(mediaContentUri, mediaDetails, null, null);
+            }
+        }
+    }
+
+    private static Media getTargetMedia(int mediaType) {
+        switch (mediaType) {
+            case TYPE_IMAGE:
+                return new Image();
+            case TYPE_VIDEO:
+                return new Video();
+            case TYPE_AUDIO:
+                return new Audio();
+            case TYPE_FILES:
+                return new Files();
+            case TYPE_DOWNLOADS:
+                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+                    return new Downloads();
+                }
+                return new Files();
+            default:
+                throw new IllegalArgumentException("Unknown media type: " + mediaType);
+        }
+    }
+
+    private static class Image implements Media {
+        @Override
+        public Uri getMediaCollection() {
+            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+                return MediaStore.Images.Media
+                        .getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY);
+            } else {
+                return MediaStore.Images.Media.EXTERNAL_CONTENT_URI;
+            }
+        }
+
+        @Override
+        public String displayName() {
+            return MediaStore.Images.Media.DISPLAY_NAME;
+        }
+
+        @Override
+        public String isPending() {
+            return MediaStore.Images.Media.IS_PENDING;
+        }
+    }
+
+    private static class Video implements Media {
+
+        @Override
+        public Uri getMediaCollection() {
+            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+                return MediaStore.Video.Media
+                        .getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY);
+            } else {
+                return MediaStore.Video.Media.EXTERNAL_CONTENT_URI;
+            }
+        }
+
+        @Override
+        public String displayName() {
+            return MediaStore.Video.Media.DISPLAY_NAME;
+        }
+
+        @Override
+        public String isPending() {
+            return MediaStore.Video.Media.IS_PENDING;
+        }
+    }
+
+    private static class Audio implements Media {
+
+        @Override
+        public Uri getMediaCollection() {
+            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+                return MediaStore.Audio.Media
+                        .getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY);
+            } else {
+                return MediaStore.Audio.Media.EXTERNAL_CONTENT_URI;
+            }
+        }
+
+        @Override
+        public String displayName() {
+            return MediaStore.Audio.Media.DISPLAY_NAME;
+        }
+
+        @Override
+        public String isPending() {
+            return MediaStore.Audio.Media.IS_PENDING;
+        }
+    }
+
+    private static class Files implements Media {
+
+        @Override
+        public Uri getMediaCollection() {
+            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+                return MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY);
+            } else {
+                return MediaStore.Files.getContentUri("external");
+            }
+        }
+
+        @Override
+        public String displayName() {
+            return MediaStore.Files.FileColumns.DISPLAY_NAME;
+        }
+
+        @Override
+        public String isPending() {
+            return MediaStore.Files.FileColumns.IS_PENDING;
+        }
+    }
+
+    private static class Downloads implements Media {
+
+        @RequiresApi(api = Build.VERSION_CODES.Q)
+        @Override
+        public Uri getMediaCollection() {
+            return MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY);
+        }
+
+        @Override
+        public String displayName() {
+            return MediaStore.Downloads.DISPLAY_NAME;
+        }
+
+        @Override
+        public String isPending() {
+            return MediaStore.Downloads.IS_PENDING;
+        }
+    }
+
+    private interface Media {
+        Uri getMediaCollection();
+
+        String displayName();
+
+        String isPending();
+    }
+}

+ 29 - 0
app/src/main/java/com/atmob/voiceai/utils/ShareUtils.java

@@ -0,0 +1,29 @@
+package com.atmob.voiceai.utils;
+
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Build;
+
+import androidx.core.content.FileProvider;
+
+import java.io.File;
+
+public class ShareUtils {
+
+
+    public static void shareAudioFile(Context context, File file) {
+        Intent shareIntent = new Intent();
+        shareIntent.setAction(Intent.ACTION_SEND);
+        shareIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+        shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+            Uri contentUri = FileProvider.getUriForFile(context, context.getPackageName() + ".fileprovider", file);
+            shareIntent.putExtra(Intent.EXTRA_STREAM, contentUri);
+        } else {
+            shareIntent.putExtra(Intent.EXTRA_STREAM, Uri.fromFile(file));
+        }
+        shareIntent.setType("audio/*");
+        context.startActivity(Intent.createChooser(shareIntent, "Share audio file"));
+    }
+}

+ 59 - 0
app/src/main/java/com/atmob/voiceai/utils/VoiceFileUtil.java

@@ -0,0 +1,59 @@
+package com.atmob.voiceai.utils;
+
+
+import androidx.annotation.NonNull;
+
+
+import java.io.File;
+
+import atmob.okhttp3.OkHttpClient;
+import atmob.reactivex.rxjava3.core.Single;
+import atmob.reactivex.rxjava3.core.SingleOnSubscribe;
+import atmob.rxjava.utils.RxJavaUtil;
+
+public class VoiceFileUtil {
+
+
+    /**
+     * 先判断本地是否有,没有再下载,然后返回
+     *
+     * @param url
+     * @return
+     */
+    public static Single<File> getVoiceFile(OkHttpClient okHttpClient, @NonNull String url) {
+        return Single.create((SingleOnSubscribe<File>) emitter -> {
+                    File file = net2LocalFile(url);
+                    if (file.exists()) {
+                        emitter.onSuccess(file);
+                    } else {
+                        DownloadUtils.downLoad(okHttpClient, url, DownloadUtils.voiceFile, getFileName(url), new FileDownLoadObserver<File>() {
+                            @Override
+                            public void onDownLoadStart() {
+
+                            }
+
+                            @Override
+                            public void onDownLoadSuccess(File file) {
+                                emitter.onSuccess(file);
+                            }
+
+                            @Override
+                            public void onDownLoadFail(Throwable throwable) {
+                                emitter.onError(throwable);
+                            }
+
+                        });
+                    }
+                })
+                .compose(RxJavaUtil.SingleSchedule.io2Main());
+    }
+
+    private static File net2LocalFile(@NonNull String url) {
+        String fileName = getFileName(url);
+        return new File(DownloadUtils.voiceFile, fileName);
+    }
+
+    private static String getFileName(@NonNull String url) {
+        return url.substring(url.lastIndexOf("/") + 1);
+    }
+}

+ 14 - 5
app/src/main/res/layout/activity_voice_result.xml

@@ -31,6 +31,13 @@
             android:layout_height="@{SizeUtil.getStatusBarHeight(), default=@dimen/app_status_bar_height}"
             app:layout_constraintTop_toTopOf="parent" />
 
+        <View
+            android:layout_width="match_parent"
+            android:layout_height="0dp"
+            android:background="#32333C"
+            app:layout_constraintBottom_toBottomOf="parent"
+            app:layout_constraintDimensionRatio="360:200" />
+
         <ScrollView
             android:layout_width="match_parent"
             android:layout_height="0dp"
@@ -145,11 +152,11 @@
 
                 <ImageView
                     android:id="@+id/iv_voice_reduce"
-                    android:src="@drawable/icon_voice_reduce"
-                    app:layout_constraintDimensionRatio="1:1"
                     android:layout_width="0dp"
                     android:layout_height="0dp"
+                    android:src="@drawable/icon_voice_reduce"
                     app:layout_constraintBottom_toBottomOf="@+id/iv_play"
+                    app:layout_constraintDimensionRatio="1:1"
                     app:layout_constraintStart_toStartOf="@+id/space_play"
                     app:layout_constraintTop_toTopOf="@+id/iv_play" />
 
@@ -168,11 +175,11 @@
 
                 <ImageView
                     android:id="@+id/iv_voice_speed"
-                    android:src="@drawable/icon_voice_speed_up"
-                    app:layout_constraintDimensionRatio="1:1"
                     android:layout_width="0dp"
                     android:layout_height="0dp"
+                    android:src="@drawable/icon_voice_speed_up"
                     app:layout_constraintBottom_toBottomOf="@+id/iv_play"
+                    app:layout_constraintDimensionRatio="1:1"
                     app:layout_constraintEnd_toEndOf="@+id/space_play"
                     app:layout_constraintTop_toTopOf="@+id/iv_play" />
 
@@ -189,7 +196,7 @@
                     android:layout_height="0dp"
                     android:background="@drawable/bg_voice_recommend_container"
                     app:layout_constraintBottom_toBottomOf="parent"
-                    app:layout_constraintDimensionRatio="360:289"
+                    app:layout_constraintDimensionRatio="360:260"
                     app:layout_constraintTop_toBottomOf="@+id/sapce5" />
 
                 <View
@@ -265,6 +272,7 @@
             android:layout_marginBottom="12dp"
             android:background="@drawable/bg_voice_result_save"
             android:gravity="center"
+            android:onClick="@{()-> voiceResultViewModel.onSaveClick()}"
             android:text="@string/voice_result_save"
             android:textColor="@color/white"
             android:textSize="17sp"
@@ -283,6 +291,7 @@
             android:layout_marginStart="12dp"
             android:background="@drawable/bg_voice_ai_btn"
             android:gravity="center"
+            android:onClick="@{()-> voiceResultViewModel.onShareClick()}"
             android:text="@string/voice_result_share"
             android:textColor="@color/colorPrimary"
             android:textSize="17sp"

+ 3 - 0
app/src/main/res/values/strings.xml

@@ -25,4 +25,7 @@
     <string name="voice_result_recommend">Try other voices with this text</string>
     <string name="voice_result_save">Save</string>
     <string name="voice_result_share">Share</string>
+    <string name="voice_save_error">Save failed.</string>
+    <string name="voice_save_success">Save successful.</string>
+    <string name="voice_file_get_error">File retrieval exception</string>
 </resources>

+ 9 - 0
app/src/main/res/xml/atmob_filepath.xml

@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+<paths xmlns:android="http://schemas.android.com/apk/res/android">
+    <cache-path
+        name="app_cache"
+        path="." />
+    <external-cache-path
+        name="external_cache"
+        path="." />
+</paths>