blob: 1c29c69239f6742f03603af0e6a25ce8d63537b6 [file] [log] [blame]
/*
* Copyright (C) 2019 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.server.storage;
import static android.service.storage.ExternalStorageService.EXTRA_ERROR;
import static android.service.storage.ExternalStorageService.FLAG_SESSION_ATTRIBUTE_INDEXABLE;
import static android.service.storage.ExternalStorageService.FLAG_SESSION_TYPE_FUSE;
import static com.android.server.storage.StorageSessionController.ExternalStorageServiceException;
import android.annotation.MainThread;
import android.annotation.Nullable;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.ServiceConnection;
import android.os.Bundle;
import android.os.HandlerThread;
import android.os.IBinder;
import android.os.ParcelFileDescriptor;
import android.os.ParcelableException;
import android.os.RemoteCallback;
import android.os.UserHandle;
import android.os.UserManagerInternal;
import android.os.storage.StorageManagerInternal;
import android.os.storage.StorageVolume;
import android.service.storage.ExternalStorageService;
import android.service.storage.IExternalStorageService;
import android.text.TextUtils;
import android.util.Slog;
import com.android.internal.annotations.GuardedBy;
import com.android.internal.util.Preconditions;
import com.android.server.LocalServices;
import java.io.IOException;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
/**
* Controls the lifecycle of the {@link ActiveConnection} to an {@link ExternalStorageService}
* for a user and manages storage sessions associated with mounted volumes.
*/
public final class StorageUserConnection {
private static final String TAG = "StorageUserConnection";
private static final int DEFAULT_REMOTE_TIMEOUT_SECONDS = 20;
private final Object mLock = new Object();
private final Context mContext;
private final int mUserId;
private final StorageSessionController mSessionController;
private final ActiveConnection mActiveConnection = new ActiveConnection();
private final boolean mIsDemoUser;
@GuardedBy("mLock") private final Map<String, Session> mSessions = new HashMap<>();
@GuardedBy("mLock") @Nullable private HandlerThread mHandlerThread;
public StorageUserConnection(Context context, int userId, StorageSessionController controller) {
mContext = Objects.requireNonNull(context);
mUserId = Preconditions.checkArgumentNonnegative(userId);
mSessionController = controller;
mIsDemoUser = LocalServices.getService(UserManagerInternal.class)
.getUserInfo(userId).isDemo();
if (mIsDemoUser) {
mHandlerThread = new HandlerThread("StorageUserConnectionThread-" + mUserId);
mHandlerThread.start();
}
}
/**
* Creates and starts a storage {@link Session}.
*
* They must also be cleaned up with {@link #removeSession}.
*
* @throws IllegalArgumentException if a {@code Session} with {@code sessionId} already exists
*/
public void startSession(String sessionId, ParcelFileDescriptor pfd, String upperPath,
String lowerPath) throws ExternalStorageServiceException {
Objects.requireNonNull(sessionId);
Objects.requireNonNull(pfd);
Objects.requireNonNull(upperPath);
Objects.requireNonNull(lowerPath);
prepareRemote();
synchronized (mLock) {
Preconditions.checkArgument(!mSessions.containsKey(sessionId));
Session session = new Session(sessionId, upperPath, lowerPath);
mSessions.put(sessionId, session);
mActiveConnection.startSessionLocked(session, pfd);
}
}
/**
* Notifies Storage Service about volume state changed.
*
* @throws ExternalStorageServiceException if failed to notify the Storage Service that
* {@code StorageVolume} is changed
*/
public void notifyVolumeStateChanged(String sessionId, StorageVolume vol)
throws ExternalStorageServiceException {
Objects.requireNonNull(sessionId);
Objects.requireNonNull(vol);
prepareRemote();
synchronized (mLock) {
mActiveConnection.notifyVolumeStateChangedLocked(sessionId, vol);
}
}
/**
* Removes a session without ending it or waiting for exit.
*
* This should only be used if the session has certainly been ended because the volume was
* unmounted or the user running the session has been stopped. Otherwise, wait for session
* with {@link #waitForExit}.
**/
public Session removeSession(String sessionId) {
synchronized (mLock) {
return mSessions.remove(sessionId);
}
}
/**
* Removes a session and waits for exit
*
* @throws ExternalStorageServiceException if the session may not have exited
**/
public void removeSessionAndWait(String sessionId) throws ExternalStorageServiceException {
Session session = removeSession(sessionId);
if (session == null) {
Slog.i(TAG, "No session found for id: " + sessionId);
return;
}
Slog.i(TAG, "Waiting for session end " + session + " ...");
prepareRemote();
synchronized (mLock) {
mActiveConnection.endSessionLocked(session);
}
}
/** Restarts all available sessions for a user without blocking.
*
* Any failures will be ignored.
**/
public void resetUserSessions() {
synchronized (mLock) {
if (mSessions.isEmpty()) {
// Nothing to reset if we have no sessions to restart; we typically
// hit this path if the user was consciously shut down.
return;
}
}
StorageManagerInternal sm = LocalServices.getService(StorageManagerInternal.class);
sm.resetUser(mUserId);
}
/**
* Removes all sessions, without waiting.
*/
public void removeAllSessions() {
synchronized (mLock) {
Slog.i(TAG, "Removing " + mSessions.size() + " sessions for user: " + mUserId + "...");
mSessions.clear();
}
}
/**
* Closes the connection to the {@link ExternalStorageService}. The connection will typically
* be restarted after close.
*/
public void close() {
mActiveConnection.close();
if (mIsDemoUser) {
mHandlerThread.quit();
}
}
/** Returns all created sessions. */
public Set<String> getAllSessionIds() {
synchronized (mLock) {
return new HashSet<>(mSessions.keySet());
}
}
private void prepareRemote() throws ExternalStorageServiceException {
try {
waitForLatch(mActiveConnection.bind(), "remote_prepare_user " + mUserId);
} catch (IllegalStateException | TimeoutException e) {
throw new ExternalStorageServiceException("Failed to prepare remote", e);
}
}
private void waitForLatch(CountDownLatch latch, String reason) throws TimeoutException {
try {
if (!latch.await(DEFAULT_REMOTE_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
// TODO(b/140025078): Call ActivityManager ANR API?
Slog.wtf(TAG, "Failed to bind to the ExternalStorageService for user " + mUserId);
throw new TimeoutException("Latch wait for " + reason + " elapsed");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("Latch wait for " + reason + " interrupted");
}
}
private final class ActiveConnection implements AutoCloseable {
// Lifecycle connection to the external storage service, needed to unbind.
@GuardedBy("mLock") @Nullable private ServiceConnection mServiceConnection;
// True if we are connecting, either bound or binding
// False && mRemote != null means we are connected
// False && mRemote == null means we are neither connecting nor connected
@GuardedBy("mLock") @Nullable private boolean mIsConnecting;
// Binder object representing the external storage service.
// Non-null indicates we are connected
@GuardedBy("mLock") @Nullable private IExternalStorageService mRemote;
// Exception, if any, thrown from #startSessionLocked or #endSessionLocked
// Local variables cannot be referenced from a lambda expression :( so we
// save the exception received in the callback here. Since we guard access
// (and clear the exception state) with the same lock which we hold during
// the entire transaction, there is no risk of race.
@GuardedBy("mLock") @Nullable private ParcelableException mLastException;
// Not guarded by any lock intentionally and non final because we cannot
// reset latches so need to create a new one after one use
private CountDownLatch mLatch;
@Override
public void close() {
ServiceConnection oldConnection = null;
synchronized (mLock) {
Slog.i(TAG, "Closing connection for user " + mUserId);
mIsConnecting = false;
oldConnection = mServiceConnection;
mServiceConnection = null;
mRemote = null;
}
if (oldConnection != null) {
try {
mContext.unbindService(oldConnection);
} catch (Exception e) {
// Handle IllegalArgumentException that may be thrown if the user is already
// stopped when we try to unbind
Slog.w(TAG, "Failed to unbind service", e);
}
}
}
public boolean isActiveLocked(Session session) {
if (!session.isInitialisedLocked()) {
Slog.i(TAG, "Session not initialised " + session);
return false;
}
if (mRemote == null) {
throw new IllegalStateException("Valid session with inactive connection");
}
return true;
}
public void startSessionLocked(Session session, ParcelFileDescriptor fd)
throws ExternalStorageServiceException {
if (!isActiveLocked(session)) {
try {
fd.close();
} catch (IOException e) {
// ignore
}
return;
}
CountDownLatch latch = new CountDownLatch(1);
try {
mRemote.startSession(session.sessionId,
FLAG_SESSION_TYPE_FUSE | FLAG_SESSION_ATTRIBUTE_INDEXABLE,
fd, session.upperPath, session.lowerPath, new RemoteCallback(result ->
setResultLocked(latch, result)));
waitForLatch(latch, "start_session " + session);
maybeThrowExceptionLocked();
} catch (Exception e) {
throw new ExternalStorageServiceException("Failed to start session: " + session, e);
} finally {
try {
fd.close();
} catch (IOException e) {
// Ignore
}
}
}
public void endSessionLocked(Session session) throws ExternalStorageServiceException {
if (!isActiveLocked(session)) {
// Nothing to end, not started yet
return;
}
CountDownLatch latch = new CountDownLatch(1);
try {
mRemote.endSession(session.sessionId, new RemoteCallback(result ->
setResultLocked(latch, result)));
waitForLatch(latch, "end_session " + session);
maybeThrowExceptionLocked();
} catch (Exception e) {
throw new ExternalStorageServiceException("Failed to end session: " + session, e);
}
}
public void notifyVolumeStateChangedLocked(String sessionId, StorageVolume vol) throws
ExternalStorageServiceException {
CountDownLatch latch = new CountDownLatch(1);
try {
mRemote.notifyVolumeStateChanged(sessionId, vol, new RemoteCallback(
result -> setResultLocked(latch, result)));
waitForLatch(latch, "notify_volume_state_changed " + vol);
maybeThrowExceptionLocked();
} catch (Exception e) {
throw new ExternalStorageServiceException("Failed to notify volume state changed "
+ "for vol : " + vol, e);
}
}
private void setResultLocked(CountDownLatch latch, Bundle result) {
mLastException = result.getParcelable(EXTRA_ERROR);
latch.countDown();
}
private void maybeThrowExceptionLocked() throws IOException {
if (mLastException != null) {
ParcelableException lastException = mLastException;
mLastException = null;
try {
lastException.maybeRethrow(IOException.class);
} catch (IOException e) {
throw e;
}
throw new RuntimeException(lastException);
}
}
public CountDownLatch bind() throws ExternalStorageServiceException {
ComponentName name = mSessionController.getExternalStorageServiceComponentName();
if (name == null) {
// Not ready to bind
throw new ExternalStorageServiceException(
"Not ready to bind to the ExternalStorageService for user " + mUserId);
}
synchronized (mLock) {
if (mRemote != null || mIsConnecting) {
// Connected or connecting (bound or binding)
// Will wait on a latch that will countdown when we connect, unless we are
// connected and the latch has already countdown, yay!
return mLatch;
} // else neither connected nor connecting
mLatch = new CountDownLatch(1);
mIsConnecting = true;
mServiceConnection = new ServiceConnection() {
@Override
public void onServiceConnected(ComponentName name, IBinder service) {
Slog.i(TAG, "Service: [" + name + "] connected. User [" + mUserId + "]");
handleConnection(service);
}
@Override
@MainThread
public void onServiceDisconnected(ComponentName name) {
// Service crashed or process was killed, #onServiceConnected will be called
// Don't need to re-bind.
Slog.i(TAG, "Service: [" + name + "] disconnected. User [" + mUserId + "]");
handleDisconnection();
}
@Override
public void onBindingDied(ComponentName name) {
// Application hosting service probably got updated
// Need to re-bind.
Slog.i(TAG, "Service: [" + name + "] died. User [" + mUserId + "]");
handleDisconnection();
}
@Override
public void onNullBinding(ComponentName name) {
Slog.wtf(TAG, "Service: [" + name + "] is null. User [" + mUserId + "]");
}
private void handleConnection(IBinder service) {
synchronized (mLock) {
if (mIsConnecting) {
mRemote = IExternalStorageService.Stub.asInterface(service);
mIsConnecting = false;
mLatch.countDown();
// Separate thread so we don't block the main thead
return;
}
}
Slog.wtf(TAG, "Connection closed to the ExternalStorageService for user "
+ mUserId);
}
private void handleDisconnection() {
// Clear all sessions because we will need a new device fd since
// StorageManagerService will reset the device mount state and #startSession
// will be called for any required mounts.
// Notify StorageManagerService so it can restart all necessary sessions
close();
resetUserSessions();
}
};
Slog.i(TAG, "Binding to the ExternalStorageService for user " + mUserId);
if (mIsDemoUser) {
// Schedule on a worker thread for demo user to avoid deadlock
if (mContext.bindServiceAsUser(new Intent().setComponent(name),
mServiceConnection,
Context.BIND_AUTO_CREATE | Context.BIND_IMPORTANT,
mHandlerThread.getThreadHandler(),
UserHandle.of(mUserId))) {
Slog.i(TAG, "Bound to the ExternalStorageService for user " + mUserId);
return mLatch;
} else {
mIsConnecting = false;
throw new ExternalStorageServiceException(
"Failed to bind to the ExternalStorageService for user " + mUserId);
}
} else {
if (mContext.bindServiceAsUser(new Intent().setComponent(name),
mServiceConnection,
Context.BIND_AUTO_CREATE | Context.BIND_IMPORTANT,
UserHandle.of(mUserId))) {
Slog.i(TAG, "Bound to the ExternalStorageService for user " + mUserId);
return mLatch;
} else {
mIsConnecting = false;
throw new ExternalStorageServiceException(
"Failed to bind to the ExternalStorageService for user " + mUserId);
}
}
}
}
}
private static final class Session {
public final String sessionId;
public final String lowerPath;
public final String upperPath;
Session(String sessionId, String upperPath, String lowerPath) {
this.sessionId = sessionId;
this.upperPath = upperPath;
this.lowerPath = lowerPath;
}
@Override
public String toString() {
return "[SessionId: " + sessionId + ". UpperPath: " + upperPath + ". LowerPath: "
+ lowerPath + "]";
}
@GuardedBy("mLock")
public boolean isInitialisedLocked() {
return !TextUtils.isEmpty(upperPath) && !TextUtils.isEmpty(lowerPath);
}
}
}