001// Licensed under the Apache License, Version 2.0 (the "License");
002// you may not use this file except in compliance with the License.
003// You may obtain a copy of the License at
004//
005//     http://www.apache.org/licenses/LICENSE-2.0
006//
007// Unless required by applicable law or agreed to in writing, software
008// distributed under the License is distributed on an "AS IS" BASIS,
009// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
010// See the License for the specific language governing permissions and
011// limitations under the License.
012
013package org.apache.tapestry5.internal.services;
014
015import org.apache.tapestry5.Link;
016import org.apache.tapestry5.ioc.ScopeConstants;
017import org.apache.tapestry5.ioc.annotations.Scope;
018import org.apache.tapestry5.ioc.internal.util.CollectionFactory;
019import org.apache.tapestry5.ioc.internal.util.InternalUtils;
020import org.apache.tapestry5.services.*;
021
022import java.io.ObjectInputStream;
023import java.io.ObjectOutputStream;
024import java.io.Serializable;
025import java.util.Collection;
026import java.util.Collections;
027import java.util.Map;
028
029/**
030 * Manages client-persistent values on behalf of a {@link ClientPersistentFieldStorageImpl}. Some effort is made to
031 * ensure that we don't uncessarily convert between objects and Base64 (the encoding used to record the value on the
032 * client).
033 */
034@Scope(ScopeConstants.PERTHREAD)
035public class ClientPersistentFieldStorageImpl implements ClientPersistentFieldStorage
036{
037    static final String PARAMETER_NAME = "t:state:client";
038
039    private static class Key implements Serializable
040    {
041        private static final long serialVersionUID = -2741540370081645945L;
042
043        private final String pageName;
044
045        private final String componentId;
046
047        private final String fieldName;
048
049        Key(String pageName, String componentId, String fieldName)
050        {
051            this.pageName = pageName;
052            this.componentId = componentId;
053            this.fieldName = fieldName;
054        }
055
056        public boolean matches(String pageName)
057        {
058            return this.pageName.equals(pageName);
059        }
060
061        public PersistentFieldChange toChange(Object value)
062        {
063            return new PersistentFieldChangeImpl(componentId == null ? "" : componentId,
064                    fieldName, value);
065        }
066
067        @Override
068        public int hashCode()
069        {
070            final int PRIME = 31;
071
072            int result = 1;
073
074            result = PRIME * result + ((componentId == null) ? 0 : componentId.hashCode());
075
076            // fieldName and pageName are never null
077
078            result = PRIME * result + fieldName.hashCode();
079            result = PRIME * result + pageName.hashCode();
080
081            return result;
082        }
083
084        @Override
085        public boolean equals(Object obj)
086        {
087            if (this == obj) return true;
088            if (obj == null) return false;
089            if (getClass() != obj.getClass()) return false;
090            final Key other = (Key) obj;
091
092            // fieldName and pageName are never null
093
094            if (!fieldName.equals(other.fieldName)) return false;
095            if (!pageName.equals(other.pageName)) return false;
096
097            if (componentId == null)
098            {
099                if (other.componentId != null) return false;
100            } else if (!componentId.equals(other.componentId)) return false;
101
102            return true;
103        }
104    }
105
106    private final ClientDataEncoder clientDataEncoder;
107
108    private final SessionPersistedObjectAnalyzer analyzer;
109
110    private final Map<Key, Object> persistedValues = CollectionFactory.newMap();
111
112    private String clientData;
113
114    private boolean mapUptoDate = false;
115
116    public ClientPersistentFieldStorageImpl(Request request, ClientDataEncoder clientDataEncoder, SessionPersistedObjectAnalyzer analyzer)
117    {
118        this.clientDataEncoder = clientDataEncoder;
119        this.analyzer = analyzer;
120
121        // This, here, is the problem of TAPESTRY-2501; this call can predate
122        // the check to set the character set based on meta data of the page.
123
124        String value = request.getParameter(PARAMETER_NAME);
125
126        // MIME can encode to a '+' character; the browser converts that to a space; we convert it
127        // back.
128
129        clientData = value == null ? null : value.replace(' ', '+');
130    }
131
132    public void updateLink(Link link)
133    {
134        refreshClientData();
135
136        if (clientData != null) link.addParameter(PARAMETER_NAME, clientData);
137    }
138
139    public Collection<PersistentFieldChange> gatherFieldChanges(String pageName)
140    {
141        refreshMap();
142
143        if (persistedValues.isEmpty()) return Collections.emptyList();
144
145        Collection<PersistentFieldChange> result = CollectionFactory.newList();
146
147        for (Map.Entry<Key, Object> e : persistedValues.entrySet())
148        {
149            Key key = e.getKey();
150
151            if (key.matches(pageName)) result.add(key.toChange(e.getValue()));
152        }
153
154        return result;
155    }
156
157    public void discardChanges(String pageName)
158    {
159        refreshMap();
160
161        Collection<Key> removedKeys = CollectionFactory.newList();
162
163        for (Key key : persistedValues.keySet())
164        {
165            if (key.pageName.equals(pageName)) removedKeys.add(key);
166        }
167
168        for (Key key : removedKeys)
169        {
170            persistedValues.remove(key);
171            clientData = null;
172        }
173    }
174
175    public void postChange(String pageName, String componentId, String fieldName, Object newValue)
176    {
177        refreshMap();
178
179        Key key = new Key(pageName, componentId, fieldName);
180
181        if (newValue == null)
182            persistedValues.remove(key);
183        else
184        {
185            if (!Serializable.class.isInstance(newValue))
186                throw new IllegalArgumentException(String.format("State persisted on the client must be serializable, but %s does not implement the Serializable interface.", newValue));
187
188            persistedValues.put(key, newValue);
189        }
190
191        clientData = null;
192    }
193
194    /**
195     * Refreshes the _persistedValues map if it is not up to date.
196     */
197    @SuppressWarnings("unchecked")
198    private void refreshMap()
199    {
200        if (mapUptoDate) return;
201
202        // Parse the client data to form the map.
203
204        restoreMapFromClientData();
205
206        mapUptoDate = true;
207    }
208
209    /**
210     * Restores the _persistedValues map from the client data provided in the incoming Request.
211     */
212    private void restoreMapFromClientData()
213    {
214        persistedValues.clear();
215
216        if (clientData == null) return;
217
218        ObjectInputStream in = null;
219
220        try
221        {
222            in = clientDataEncoder.decodeClientData(clientData);
223
224            int count = in.readInt();
225
226            for (int i = 0; i < count; i++)
227            {
228                Key key = (Key) in.readObject();
229                Object value = in.readObject();
230
231                persistedValues.put(key, value);
232            }
233        } catch (Exception ex)
234        {
235            throw new RuntimeException("Serialized client state was corrupted. This may indicate that too much state is being stored, which can cause the encoded string to be truncated by the client web browser.", ex);
236        } finally
237        {
238            InternalUtils.close(in);
239        }
240    }
241
242    private void refreshClientData()
243    {
244        // TAP5-2269: Even in the absense of a change to a persistent field, a mutable persistent object
245        // may have changed.
246
247        if (clientData != null)
248        {
249            for (Object value : persistedValues.values())
250            {
251                if (analyzer.checkAndResetDirtyState(value))
252                {
253                    clientData = null;
254                    break;
255                }
256            }
257        }
258
259        // Client data will be null after a change to the map, or if there was no client data in the
260        // request. In any other case where the client data is non-null, it is by definition
261        // up-to date (since it is reset to null any time there's a change to the map).
262
263        if (clientData != null) return;
264
265        // Very typical: we're refreshing the client data but haven't created the map yet, and there
266        // was no value in the request. Leave it as null.
267
268        if (!mapUptoDate) return;
269
270        // Null is also appropriate when the persisted values are empty.
271
272        if (persistedValues.isEmpty()) return;
273
274        // Otherwise, time to update clientData from persistedValues
275
276        ClientDataSink sink = clientDataEncoder.createSink();
277
278        ObjectOutputStream os = sink.getObjectOutputStream();
279
280        try
281        {
282            os.writeInt(persistedValues.size());
283
284            for (Map.Entry<Key, Object> e : persistedValues.entrySet())
285            {
286                os.writeObject(e.getKey());
287                os.writeObject(e.getValue());
288            }
289        } catch (Exception ex)
290        {
291            throw new RuntimeException(ex.getMessage(), ex);
292        } finally
293        {
294            InternalUtils.close(os);
295        }
296
297        clientData = sink.getClientData();
298    }
299}