Browse Source

agora eh possivel realizar chamadas de audio, sem o video, mas ainda com bugs a resolver

app-only-audio
Matheo Bonucia 4 months ago
parent
commit
c33681efa4
  1. 658
      react-native-webrtc-app/client/App.js
  2. 9
      react-native-webrtc-app/client/package-lock.json
  3. 1
      react-native-webrtc-app/client/package.json
  4. 7
      react-native-webrtc-app/client/yarn.lock

658
react-native-webrtc-app/client/App.js vendored

@ -8,11 +8,10 @@ import {
Text,
TouchableOpacity,
StyleSheet,
DeviceEventEmitter,
TextInput,
} from 'react-native';
import * as Animatable from 'react-native-animatable';
import TextInputContainer from './components/TextInputContainer';
import SocketIOClient from 'socket.io-client';
import {
mediaDevices,
RTCPeerConnection,
@ -32,52 +31,74 @@ import IconContainer from './components/IconContainer';
import InCallManager from 'react-native-incall-manager';
import Logo from './asset/Logo';
import * as JsSIP from 'react-native-jssip';
import EventEmitter from 'react-native/Libraries/vendor/emitter/EventEmitter';
export default function App({}) {
const eventoSip = new EventEmitter();
const [session, setSession] = useState(null);
const [ua, setUa] = useState(null);
const [screen, setScreen] = useState('JOIN');
const [incomingCaller, setIncomingCaller] = useState(null);
const [calle, setCalle] = useState(null);
const [myRamal, setMyRamal] = useState(null);
const [authData, setAuthData] = useState({
protocolo: 'ws',
servidor: '192.168.115.179',
porta: '8088',
username: 'Matheo',
ramal: '1100',
senha: 'SIP1100',
});
const [localMicOn, setlocalMicOn] = useState(true);
const [localWebcamOn, setlocalWebcamOn] = useState(false);
const [localStream, setLocalStream] = useState(null);
const [remoteStream, setRemoteStream] = useState(null);
const remoteAudio = useRef(null);
const localAudio = useRef(null);
function Autenticacao(PROTOCOLO, SERVIDOR, PORTA, NOME, RAMAL, SENHA) {
this.PROTOCOLO = 'ws';
this.SERVIDOR = '192.168.115.179';
this.PORTA = '8088';
this.NOME = 'matheo';
this.RAMAL = '1100';
this.SENHA = 'SIP1100';
class EventEmitter {
constructor() {
this.events = {};
}
on(eventName, listener) {
if (!this.events[eventName]) {
this.events[eventName] = [];
}
this.events[eventName].push(listener);
}
const _Autenticacao = new Autenticacao();
emit(eventName, ...args) {
if (this.events[eventName]) {
this.events[eventName].forEach(listener => listener(...args));
}
}
let phone;
let session;
off(eventName, listener) {
if (this.events[eventName]) {
this.events[eventName] = this.events[eventName].filter(
fn => fn !== listener,
);
}
}
}
const eventoSip = new EventEmitter();
const {protocolo, servidor, porta, username, ramal, senha} = authData;
function iniciandoAutenticacaonoPBX() {
useEffect(() => {
setMyRamal(ramal);
const socket = new JsSIP.WebSocketInterface(
_Autenticacao.PROTOCOLO +
'://' +
_Autenticacao.SERVIDOR +
':' +
_Autenticacao.PORTA +
'/ws',
protocolo + '://' + servidor + ':' + porta + '/ws',
);
const configuration = {
uri: 'sip:' + _Autenticacao.RAMAL + '@' + _Autenticacao.SERVIDOR,
password: _Autenticacao.SENHA,
uri: 'sip:' + ramal + '@' + servidor,
password: senha,
sockets: [socket],
session_timers: false,
no_answer_timeout: 180,
hack_via_tcp: false,
hack_via_ws: true,
display_name:
_Autenticacao.NOME !== null ? _Autenticacao.NOME : _Autenticacao.RAMAL,
user_agent: 'UASimpleSIP 1.2.0',
contact_uri:
'sip:' +
_Autenticacao.RAMAL +
'@' +
_Autenticacao.SERVIDOR +
';transport=' +
_Autenticacao.PROTOCOLO,
display_name: username !== null ? username : ramal,
user_agent: 'Softphone React Native',
contact_uri: 'sip:' + ramal + '@' + servidor + ';transport=' + protocolo,
pcConfig: {
iceServers: [
{
@ -92,8 +113,8 @@ export default function App({}) {
],
},
};
if (configuration.uri && configuration.password) {
phone = new JsSIP.UA(configuration);
const phone = new JsSIP.UA(configuration);
phone.on('registrationFailed', function (ev) {
console.log('Registering on SIP server failed with error: ' + ev.cause);
@ -101,21 +122,10 @@ export default function App({}) {
configuration.password = null;
});
phone.on('newRTCSession', function (ev) {
phone.on('newRTCSession', ev => {
const newSession = ev.session;
session = newSession;
eventoSip.on('endCall', () => {
if (session) {
eventoSip.emit('confirmedEnded');
session.terminate();
}
});
eventoSip.on('evento', payload => {
console.log(payload);
});
///////////////////////////////////////////////////
eventoSip.on('mute', () => {
session.mute();
console.log(session?.isMuted());
@ -133,54 +143,82 @@ export default function App({}) {
eventoSip.on('transferir', data => {
session.refer(data);
});
///////////////////////////////////////////////////
//RECEBENDO UMA CHAMADA
if (newSession.direction === 'incoming') {
setScreen('INCOMING_CALL');
setIncomingCaller(newSession.remote_identity.uri.user);
setSession(newSession);
// Set InCallManager settings when a call starts
InCallManager.start({media: 'audio'});
InCallManager.setForceSpeakerphoneOn(true);
session.on('candidate', event => {
console.log(event);
//Quando finalizar a chamada
newSession.on('ended', () => {
InCallManager.stop();
setSession(null);
setIncomingCaller(null);
setScreen('JOIN');
});
session.on('ended', () => {
endCallAudio.play();
eventoSip.emit('confirmedEnded');
//QUANDO UMA CHAMADA APRESENTAR PROBLEMAS
newSession.on('failed', () => {
InCallManager.stop();
setSession(null);
setIncomingCaller(null);
setScreen('JOIN');
});
} else {
setSession(newSession);
session.on('newDTMF', function (event) {
console.log('DTMF recebido:', event.dtmf.tone);
//Quando finalizar a chamada
newSession.on('ended', () => {
InCallManager.stop();
setSession(null);
});
//QUANDO UMA CHAMADA É REJEITADA
session.on('failed', () => {
incomingCallAudio.pause();
outgoingCallAudio.pause();
eventoSip.emit('home');
//QUANDO UMA CHAMADA APRESENTAR PROBLEMAS
newSession.on('failed', () => {
InCallManager.stop();
setSession(null);
});
}
session.on('confirmed', function (confirmed) {
// Verifica se session.connection está definido
if (
session.connection &&
session.connection.getRemoteStreams().length > 0
) {
const remoteStreams = session.connection.getRemoteStreams()[0];
remoteAudio.srcObject = remoteStreams;
remoteAudio.volume = 1;
newSession.on('peerconnection', e => {
const peerconnection = e.peerconnection;
//////////////Media/////////START//////
if (peerconnection) {
peerconnection.addEventListener('addstream', event => {
console.log('Remote stream added:', event.stream);
setRemoteStream(event.stream);
});
}
//////////////Media/////////END////////
// Verifica se session.connection está definido e se existem streams locais
if (
session.connection &&
session.connection.getLocalStreams().length > 0
) {
const localStreams = session.connection.getLocalStreams()[0];
localAudio.srcObject = localStreams;
localAudio.volume = 0;
peerconnection.addEventListener('icecandidate', event => {
if (event.candidate) {
console.log('Have NEW ICE Candidate: ', event.candidate);
}
//PAUSA TODOS OS AUDIOS DE FEEDBACK
incomingCallAudio.pause();
outgoingCallAudio.pause();
});
eventoSip.emit('incall');
peerconnection.addEventListener('iceconnectionstatechange', () => {
console.log(
'ICE connection state change: ',
peerconnection.iceConnectionState,
);
});
session.on('icecandidate', function (event) {
});
newSession.on('newDTMF', function (event) {
console.log('DTMF recebido:', event.dtmf.tone);
});
newSession.on('confirmed', function (confirmed) {
setScreen('WEBRTC_ROOM');
});
newSession.on('icecandidate', function (event) {
if (
event.candidate.type === 'srflx' &&
event.candidate.relatedAddress !== null &&
@ -190,43 +228,9 @@ export default function App({}) {
}
});
session.on('addstream', function (e) {
// Verifica se session.connection está definido
if (session.connection) {
remoteAudio.srcObject = e.stream;
remoteAudio.play();
}
});
//RECEBENDO UMA CHAMADA
if (session.direction === 'incoming') {
incomingCallAudio.play();
eventoSip.emit('incomingcall', session.remote_identity.uri.user);
eventoSip.on('rejected', () => {
if (session?.isInProgress()) {
incomingCallAudio.pause();
eventoSip.emit('home');
session.terminate();
session = null;
} else {
eventoSip.emit('home');
}
});
eventoSip.on('accepted', () => {
if (session?.isInProgress()) {
session.answer();
incomingCallAudio.pause();
}
});
}
//REALIZANDO UMA CHAMADA
if (session.direction === 'outgoing') {
eventoSip.emit('outgoingcall', session.remote_identity.uri.user);
if (session.isInProgress()) {
outgoingCallAudio.play();
}
if (newSession.direction === 'outgoing') {
setScreen('OUTGOING_CALL');
}
});
phone.on('registered', function (e) {
@ -249,375 +253,104 @@ export default function App({}) {
eventoSip.emit('statusChange', 'disconnected');
});
phone.start();
}
}
setUa(phone);
useEffect(() => {
iniciandoAutenticacaonoPBX();
return () => {
InCallManager.stop();
phone.stop();
};
}, []);
const [localStream, setlocalStream] = useState(null);
const [remoteStream, setRemoteStream] = useState(null);
const [isSpeakerOn, setIsSpeakerOn] = useState(false);
const [type, setType] = useState('JOIN');
const [callerId] = useState(
Math.floor(100000 + Math.random() * 900000).toString(),
);
const otherUserId = useRef(null);
const socket = SocketIOClient('http://129.148.58.190:8088', {
transports: ['websocket'],
query: {
callerId,
},
reconnectionAttempts: 3,
reconnectionDelay: 1000,
});
const [localMicOn, setlocalMicOn] = useState(true);
const [localWebcamOn, setlocalWebcamOn] = useState(true);
const [iniciarChamada, setIniciarChamada] = useState(false);
let remoteRTCMessage = useRef(null);
const peerConnection = useRef(null);
const createPeerConnection = () => {
return new RTCPeerConnection({
iceServers: [
{
urls: 'stun:stun.l.google.com:19302',
},
{
urls: 'stun:stun1.l.google.com:19302',
const handleAceitarChamada = async () => {
if (session) {
session.answer({
mediaConstraints: {
audio: true,
video: false,
},
{urls: 'stun:stun2.l.google.com:19302'},
],
});
};
const initializePeerConnection = async () => {
try {
peerConnection.current = createPeerConnection();
console.log('PeerConnection initialized successfully.');
} catch (error) {
console.error('Error initializing PeerConnection:', error);
setScreen('WEBRTC_ROOM');
}
};
//Função para limpar todos os listeners do socket
const cleanUp = () => {
try {
if (peerConnection.current) {
peerConnection.current.close();
}
// Fechar o socket e remover os listeners
socket.off('newCall');
socket.off('callAnswered');
socket.off('ICEcandidate');
socket.close();
// Parar o InCallManager e fechar a conexão Peer
const handleRejectCall = () => {
if (session) {
session.terminate();
setSession(null);
setIncomingCaller(null);
InCallManager.stop();
// Parar e remover os fluxos de mídia
if (localStream) {
localStream.getTracks().forEach(track => track.stop());
}
// Limpar o estado localStream e remoteRTCMessage
setlocalStream(null);
remoteRTCMessage.current = null;
if (peerConnection.current) {
peerConnection.current.onconnectionstatechange = function () {
console.log('--->' + peerConnection.current.connectionState);
};
} else {
console.error(
'O objeto peerConnection.current não está definido ou é nulo.',
);
}
console.log('Limpeza concluída com sucesso.');
} catch (error) {
console.error('Erro durante a limpeza:', error);
// Lidar com o erro de limpeza aqui, se necessário
}
};
const socketConfig = () => {
console.log('Iniciando a conexão com o socket');
socket.on('newCall', data => {
console.log('Received new call');
remoteRTCMessage.current = data.rtcMessage;
otherUserId.current = data.callerId;
setType('INCOMING_CALL');
});
socket.on('callAnswered', data => {
console.log('Call answered');
remoteRTCMessage.current = data.rtcMessage;
try {
peerConnection.current.setRemoteDescription(
new RTCSessionDescription(remoteRTCMessage.current),
);
setType('WEBRTC_ROOM');
} catch (error) {
console.log('Erro ao atender chamada: ', error);
}
});
const closePeerConnection = () => {
if (peerConnection.current) {
console.log('Closing peer connection');
peerConnection.current.close();
peerConnection.current = null;
}
};
// No cliente, ouvir o evento "endCallAndLeaveRoom" do servidor
socket.on('endCallAndLeaveRoom', () => {
// Deixar a sala após encerrar a chamada
socket.emit('leaveRoom'); // Envia um evento para o servidor para deixar a sala
closePeerConnection();
setlocalStream(null);
setType('JOIN');
});
// Lógica para sair da sala no cliente
socket.on('leaveRoom', () => {
socket.leave(socket.user); // Deixa a sala com o mesmo nome do usuário
console.log(`${socket.user} left the conference room.`);
});
socket.on('ICEcandidate', data => {
let message = data.rtcMessage;
if (peerConnection.current) {
peerConnection.current
.addIceCandidate(
new RTCIceCandidate({
candidate: message.candidate,
sdpMid: message.id,
sdpMLineIndex: message.label,
}),
)
.then(data => {
console.log('Added ICE candidate successfully:', message);
})
.catch(err => {
console.log('Error adding ICE candidate:', err);
});
}
});
const handleIniciarChamada = () => {
console.log('AQUI:::::::' + calle);
if (ua && calle) {
const eventHandlers = {
progress: () => {
console.log('Call is in progress');
},
failed: e => {
console.log('Call failed with cause: ' + e.cause);
InCallManager.stop();
},
ended: () => {
console.log('Call ended');
InCallManager.stop();
},
confirmed: () => {
console.log('Call confirmed');
InCallManager.setForceSpeakerphoneOn(true);
},
};
const mediaConfig = async () => {
try {
const devices = await mediaDevices.enumerateDevices();
let isFront = true;
let videoSourceId = null;
devices.forEach(device => {
if (
device.kind === 'videoinput' &&
device.facingMode === (isFront ? 'user' : 'environment')
) {
videoSourceId = device.deviceId;
}
});
const constraints = {
const options = {
eventHandlers: eventHandlers,
mediaConstraints: {
audio: true,
video: false,
// video: {
// mandatory: {
// minWidth: 500,
// minHeight: 300,
// minFrameRate: 30,
// },
// facingMode: isFront ? 'user' : 'environment',
// },
};
if (videoSourceId) {
constraints.video.optional = [{sourceId: videoSourceId}];
}
const stream = await mediaDevices.getUserMedia(constraints);
setlocalStream(stream);
peerConnection.current.addStream(stream);
peerConnection.current.onaddstream = event => {
setRemoteStream(event.stream);
};
} catch (error) {
console.error('Error accessing media devices:', error);
}
};
const handleIniciarChamada = async () => {
setIniciarChamada(true);
await initializeApp(); // Aguarda a conclusão da inicialização
setType('OUTGOING_CALL');
await processCall();
};
const handleAceitarChamada = async () => {
setType('WEBRTC_ROOM');
await processAccept();
},
rtcOfferContraints: {
offerToReceiveAudio: true,
offerToReceiveVideo: false,
},
};
async function initializeApp() {
try {
await initializePeerConnection();
await mediaConfig();
socketConfig();
const newSession = ua.call(`sip:${calle}@${servidor}`, options);
console.log('URI: ' + `sip:${calle}@${servidor}`);
setSession(newSession);
peerConnection.current.addEventListener('signalingstatechange', event => {
console.log(
'Signaling state changed:',
event.type,
':',
peerConnection.current.signalingState,
);
});
setIniciarChamada(false);
} catch (error) {
console.error('Erro durante a inicialização:', error);
// Trate o erro conforme necessário
}
}
useEffect(() => {
initializeApp();
setIniciarChamada(false);
}, []);
useEffect(() => {
if (iniciarChamada) {
initializeApp();
setIniciarChamada(false);
InCallManager.start({media: 'audio'});
}
}, [iniciarChamada]);
//useEffect para iniciar o gerenciamento de chamadas
useEffect(() => {
const callOptions = {
media: 'audioVideo',
};
InCallManager.start(callOptions);
InCallManager.setKeepScreenOn(true);
InCallManager.setForceSpeakerphoneOn(true);
setIniciarChamada(false);
return () => {
InCallManager.stop();
};
}, [iniciarChamada]);
function sendICEcandidate(data) {
socket.emit('ICEcandidate', data);
}
async function processCall() {
console.log('Processing call...');
InCallManager.startRingback();
const sessionDescription = await peerConnection.current.createOffer();
await peerConnection.current.setLocalDescription(sessionDescription);
sendCall({
calleeId: otherUserId.current,
rtcMessage: sessionDescription,
});
}
async function processAccept() {
console.log('Processing call acceptance...');
try {
await peerConnection.current.setRemoteDescription(
new RTCSessionDescription(remoteRTCMessage.current),
);
const sessionDescription = await peerConnection.current.createAnswer();
await peerConnection.current.setLocalDescription(sessionDescription);
answerCall({
callerId: otherUserId.current,
rtcMessage: sessionDescription,
});
// Adicionar evento onicecandidate após a criação da resposta local
peerConnection.current.onicecandidate = event => {
if (event.candidate) {
// Enviar ICE candidate apenas quando estiver disponível
sendICEcandidate({
calleeId: otherUserId.current,
rtcMessage: {
label: event.candidate.sdpMLineIndex,
id: event.candidate.sdpMid,
candidate: event.candidate.candidate,
},
const toggleMic = () => {
console.log('Toggling microphone...');
localMicOn ? setlocalMicOn(false) : setlocalMicOn(true);
localStream.getAudioTracks().forEach(track => {
localMicOn ? (track.enabled = false) : (track.enabled = true);
});
} else {
console.log('End of candidates.');
}
};
} catch (error) {
console.error('Error processing call acceptance:', error);
// Tratar o erro conforme necessário
}
}
function answerCall(data) {
socket.emit('answerCall', data);
}
function sendCall(data) {
socket.emit('call', data);
}
function endCall(data) {
socket.emit('endCall', data);
}
function switchCamera() {
localStream.getVideoTracks().forEach(track => {
track._switchCamera();
});
}
function toggleCamera() {
const toggleCamera = () => {
console.log('Toggling camera...');
localWebcamOn ? setlocalWebcamOn(false) : setlocalWebcamOn(true);
localStream.getVideoTracks().forEach(track => {
localWebcamOn ? (track.enabled = false) : (track.enabled = true);
});
}
function toggleMic() {
console.log('Toggling microphone...');
localMicOn ? setlocalMicOn(false) : setlocalMicOn(true);
localStream.getAudioTracks().forEach(track => {
localMicOn ? (track.enabled = false) : (track.enabled = true);
});
}
function toggleSpeaker() {
console.log('Toggling speaker...');
setIsSpeakerOn(!isSpeakerOn);
InCallManager.setSpeakerphoneOn(!isSpeakerOn);
}
};
function cancelCall() {
setType('JOIN');
InCallManager.stopRingback();
otherUserId.current = null;
}
// useEffect(() => {
// if (remoteStream && remoteAudio.current) {
// remoteAudio.current = remoteStream;
// remoteAudio.current.play();
// }
// }, [remoteStream]);
function leave() {
// Emitir o evento 'endCall' para o servidor
endCall({otherPeer: otherUserId.current});
setType('JOIN');
}
// useEffect(() => {
// if (localAudio.current && localStream) {
// localAudio.current = localStream;
// }
// }, [localStream]);
const styles = StyleSheet.create({
container: {
@ -684,7 +417,7 @@ export default function App({}) {
<View style={styles.inner}>
<Text style={styles.titles}>Seu ID para ligação</Text>
<View style={styles.box}>
<Text style={styles.idCall}>{callerId}</Text>
<Text style={styles.idCall}>{myRamal}</Text>
</View>
</View>
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
@ -693,10 +426,9 @@ export default function App({}) {
<Text style={styles.titles}>Ligação</Text>
<TextInputContainer
placeholder={'Insira o ID para ligação'}
value={otherUserId.current}
value={calle}
setValue={text => {
otherUserId.current = text;
console.log('TEST', otherUserId.current);
setCalle(text);
}}
keyboardType={'number-pad'}
/>
@ -744,7 +476,7 @@ export default function App({}) {
color: '#ffff',
letterSpacing: 6,
}}>
{otherUserId.current}
{calle}
</Text>
</View>
<View
@ -792,7 +524,7 @@ export default function App({}) {
marginTop: 15,
color: '#ffff',
}}>
Recebendo ligação de {otherUserId.current}
Recebendo ligação de {incomingCaller}
</Text>
</View>
<View
@ -833,7 +565,7 @@ export default function App({}) {
<TouchableOpacity
onPress={() => {
cancelCall();
handleRejectCall();
}}>
<Animatable.View
animation="pulse"
@ -976,7 +708,7 @@ export default function App({}) {
);
};
switch (type) {
switch (screen) {
case 'JOIN':
return JoinScreen();
case 'INCOMING_CALL':

9
react-native-webrtc-app/client/package-lock.json generated

@ -15,6 +15,7 @@
"react-native-animatable": "^1.4.0",
"react-native-incall-manager": "^4.0.1",
"react-native-jssip": "^3.7.6",
"react-native-sound": "^0.11.2",
"react-native-svg": "^13.7.0",
"react-native-webrtc": "^1.94.2",
"socket.io-client": "^4.5.4"
@ -15891,6 +15892,14 @@
"node": ">=6"
}
},
"node_modules/react-native-sound": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/react-native-sound/-/react-native-sound-0.11.2.tgz",
"integrity": "sha512-LmGc8lgOK3qecYMVQpyHvww/C+wgT6sWeMpVbOe4NCRGC2yKd4fo4U0KBUo9PO7AqKESO3I/2GZg1/C0+bwiiA==",
"peerDependencies": {
"react-native": ">=0.8.0"
}
},
"node_modules/react-native-svg": {
"version": "13.7.0",
"resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-13.7.0.tgz",

1
react-native-webrtc-app/client/package.json

@ -17,6 +17,7 @@
"react-native-animatable": "^1.4.0",
"react-native-incall-manager": "^4.0.1",
"react-native-jssip": "^3.7.6",
"react-native-sound": "^0.11.2",
"react-native-svg": "^13.7.0",
"react-native-webrtc": "^1.94.2",
"socket.io-client": "^4.5.4"

7
react-native-webrtc-app/client/yarn.lock

@ -7233,6 +7233,11 @@ react-native-jssip@^3.7.6:
react-native-webrtc "^1.84.0"
sdp-transform "^2.14.1"
react-native-sound@^0.11.2:
version "0.11.2"
resolved "https://registry.npmjs.org/react-native-sound/-/react-native-sound-0.11.2.tgz"
integrity sha512-LmGc8lgOK3qecYMVQpyHvww/C+wgT6sWeMpVbOe4NCRGC2yKd4fo4U0KBUo9PO7AqKESO3I/2GZg1/C0+bwiiA==
react-native-svg@^13.7.0:
version "13.7.0"
resolved "https://registry.npmjs.org/react-native-svg/-/react-native-svg-13.7.0.tgz"
@ -7251,7 +7256,7 @@ react-native-webrtc@^1.84.0, react-native-webrtc@^1.94.2:
event-target-shim "6.0.2"
tar "6.1.11"
react-native@*, react-native@>=0.40.0, react-native@>=0.60.0, react-native@0.68.2:
react-native@*, react-native@>=0.40.0, react-native@>=0.60.0, react-native@>=0.8.0, react-native@0.68.2:
version "0.68.2"
resolved "https://registry.npmjs.org/react-native/-/react-native-0.68.2.tgz"
integrity sha512-qNMz+mdIirCEmlrhapAtAG+SWVx6MAiSfCbFNhfHqiqu1xw1OKXdzIrjaBEPihRC2pcORCoCHduHGQe/Pz9Yuw==

Loading…
Cancel
Save